mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
feat: add custom header/footer regions for extensions
Extensions can now place persistent header (above stream) and footer (below status bar) regions via ctx.SetHeader/SetFooter. Single-instance per slot, reuses WidgetContent/WidgetStyle types and WidgetUpdateEvent for notifications. Includes thread-safe Runner storage, SDK methods, UI rendering with height distribution, and example extension.
This commit is contained in:
+70
-5
@@ -330,6 +330,48 @@ func widgetProviderForUI(k *kit.Kit) func(string) []ui.WidgetData {
|
||||
}
|
||||
}
|
||||
|
||||
// headerProviderForUI returns a function that converts the extension header
|
||||
// to a *ui.WidgetData for the TUI. Returns nil if extensions are disabled,
|
||||
// which is safe — the UI treats a nil GetHeader as "no header".
|
||||
func headerProviderForUI(k *kit.Kit) func() *ui.WidgetData {
|
||||
if !k.HasExtensions() {
|
||||
return nil
|
||||
}
|
||||
return func() *ui.WidgetData {
|
||||
config := k.GetExtensionHeader()
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
return &ui.WidgetData{
|
||||
Text: config.Content.Text,
|
||||
Markdown: config.Content.Markdown,
|
||||
BorderColor: config.Style.BorderColor,
|
||||
NoBorder: config.Style.NoBorder,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// footerProviderForUI returns a function that converts the extension footer
|
||||
// to a *ui.WidgetData for the TUI. Returns nil if extensions are disabled,
|
||||
// which is safe — the UI treats a nil GetFooter as "no footer".
|
||||
func footerProviderForUI(k *kit.Kit) func() *ui.WidgetData {
|
||||
if !k.HasExtensions() {
|
||||
return nil
|
||||
}
|
||||
return func() *ui.WidgetData {
|
||||
config := k.GetExtensionFooter()
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
return &ui.WidgetData{
|
||||
Text: config.Content.Text,
|
||||
Markdown: config.Content.Markdown,
|
||||
BorderColor: config.Style.BorderColor,
|
||||
NoBorder: config.Style.NoBorder,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runNormalMode(ctx context.Context) error {
|
||||
// Validate flag combinations
|
||||
if quietFlag && promptFlag == "" {
|
||||
@@ -464,6 +506,22 @@ func runNormalMode(ctx context.Context) error {
|
||||
kitInstance.RemoveExtensionWidget(id)
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
},
|
||||
SetHeader: func(config extensions.HeaderFooterConfig) {
|
||||
kitInstance.SetExtensionHeader(config)
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
},
|
||||
RemoveHeader: func() {
|
||||
kitInstance.RemoveExtensionHeader()
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
},
|
||||
SetFooter: func(config extensions.HeaderFooterConfig) {
|
||||
kitInstance.SetExtensionFooter(config)
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
},
|
||||
RemoveFooter: func() {
|
||||
kitInstance.RemoveExtensionFooter()
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
},
|
||||
PromptSelect: func(config extensions.PromptSelectConfig) extensions.PromptSelectResult {
|
||||
ch := make(chan app.PromptResponse, 1)
|
||||
appInstance.SendPromptRequest(app.PromptRequestEvent{
|
||||
@@ -537,9 +595,14 @@ func runNormalMode(ctx context.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Build extension UI providers once (shared between both modes).
|
||||
getWidgets := widgetProviderForUI(kitInstance)
|
||||
getHeader := headerProviderForUI(kitInstance)
|
||||
getFooter := footerProviderForUI(kitInstance)
|
||||
|
||||
// Check if running in non-interactive mode
|
||||
if promptFlag != "" {
|
||||
return runNonInteractiveModeApp(ctx, appInstance, cli, promptFlag, quietFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, widgetProviderForUI(kitInstance))
|
||||
return runNonInteractiveModeApp(ctx, appInstance, cli, promptFlag, quietFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter)
|
||||
}
|
||||
|
||||
// Quiet mode is not allowed in interactive mode
|
||||
@@ -547,7 +610,7 @@ func runNormalMode(ctx context.Context) error {
|
||||
return fmt.Errorf("--quiet flag can only be used with --prompt/-p")
|
||||
}
|
||||
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, widgetProviderForUI(kitInstance))
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter)
|
||||
}
|
||||
|
||||
// runNonInteractiveModeApp executes a single prompt via the app layer and exits,
|
||||
@@ -560,7 +623,7 @@ func runNormalMode(ctx context.Context) error {
|
||||
//
|
||||
// When --no-exit is set, after the prompt completes the interactive BubbleTea
|
||||
// TUI is started so the user can continue the conversation.
|
||||
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData) error {
|
||||
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData) error {
|
||||
if quiet {
|
||||
// Quiet mode: no intermediate display, just print final response.
|
||||
if err := appInstance.RunOnce(ctx, prompt); err != nil {
|
||||
@@ -586,7 +649,7 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui
|
||||
|
||||
// If --no-exit was requested, hand off to the interactive TUI.
|
||||
if noExit {
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets)
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -603,7 +666,7 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui
|
||||
// 4. Calls program.Run() which blocks until the user quits (Ctrl+C or /quit).
|
||||
//
|
||||
// SetupCLI is not used for interactive mode; the TUI (AppModel) handles its own rendering.
|
||||
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData) error {
|
||||
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData) error {
|
||||
// Determine terminal size; fall back gracefully.
|
||||
termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
if err != nil || termWidth == 0 {
|
||||
@@ -627,6 +690,8 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
|
||||
ContextPaths: contextPaths,
|
||||
SkillItems: skillItems,
|
||||
GetWidgets: getWidgets,
|
||||
GetHeader: getHeader,
|
||||
GetFooter: getFooter,
|
||||
})
|
||||
|
||||
// Print startup info to stdout before Bubble Tea takes over the screen.
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init demonstrates the custom header/footer system. The header shows
|
||||
// project context (branch, CWD) and the footer shows a running summary
|
||||
// of agent activity. Slash commands toggle them on/off.
|
||||
func Init(api ext.API) {
|
||||
var turnCount int
|
||||
var lastResponse string
|
||||
|
||||
// Show a custom header with project context when the session starts.
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
ctx.SetHeader(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{
|
||||
Text: fmt.Sprintf("Project: %s | Model: %s | %s",
|
||||
ctx.CWD, ctx.Model, time.Now().Format("Jan 2, 15:04")),
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#89b4fa",
|
||||
},
|
||||
})
|
||||
|
||||
ctx.SetFooter(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{
|
||||
Text: "Ready | 0 turns",
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#a6e3a1",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Update footer after each agent turn with activity summary.
|
||||
api.OnAgentEnd(func(ae ext.AgentEndEvent, ctx ext.Context) {
|
||||
turnCount++
|
||||
lastResponse = ae.Response
|
||||
if len(lastResponse) > 60 {
|
||||
lastResponse = lastResponse[:57] + "..."
|
||||
}
|
||||
|
||||
ctx.SetFooter(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{
|
||||
Text: fmt.Sprintf("Turns: %d | Last: %s | %s",
|
||||
turnCount, ae.StopReason, time.Now().Format("15:04:05")),
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#a6e3a1",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// /header-off — remove the custom header.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "header-off",
|
||||
Description: "Remove the custom header",
|
||||
Execute: func(_ string, ctx ext.Context) (string, error) {
|
||||
ctx.RemoveHeader()
|
||||
return "Header removed.", nil
|
||||
},
|
||||
})
|
||||
|
||||
// /header-on — restore the custom header.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "header-on",
|
||||
Description: "Restore the custom header",
|
||||
Execute: func(_ string, ctx ext.Context) (string, error) {
|
||||
ctx.SetHeader(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{
|
||||
Text: fmt.Sprintf("Project: %s | Model: %s | %s",
|
||||
ctx.CWD, ctx.Model, time.Now().Format("Jan 2, 15:04")),
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#89b4fa",
|
||||
},
|
||||
})
|
||||
return "Header restored.", nil
|
||||
},
|
||||
})
|
||||
|
||||
// /footer-off — remove the custom footer.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "footer-off",
|
||||
Description: "Remove the custom footer",
|
||||
Execute: func(_ string, ctx ext.Context) (string, error) {
|
||||
ctx.RemoveFooter()
|
||||
return "Footer removed.", nil
|
||||
},
|
||||
})
|
||||
|
||||
// /footer-on — restore the custom footer.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "footer-on",
|
||||
Description: "Restore the custom footer",
|
||||
Execute: func(_ string, ctx ext.Context) (string, error) {
|
||||
ctx.SetFooter(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{
|
||||
Text: fmt.Sprintf("Turns: %d | %s", turnCount, time.Now().Format("15:04:05")),
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#a6e3a1",
|
||||
},
|
||||
})
|
||||
return "Footer restored.", nil
|
||||
},
|
||||
})
|
||||
|
||||
// Clean up on shutdown.
|
||||
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
|
||||
ctx.RemoveHeader()
|
||||
ctx.RemoveFooter()
|
||||
})
|
||||
}
|
||||
@@ -83,6 +83,38 @@ type Context struct {
|
||||
// the ID does not exist.
|
||||
RemoveWidget func(id string)
|
||||
|
||||
// SetHeader places a custom header at the top of the TUI view, above
|
||||
// the stream region. Only one header can be active at a time; calling
|
||||
// SetHeader replaces any previous header. The header persists across
|
||||
// agent turns until explicitly removed.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// ctx.SetHeader(ext.HeaderFooterConfig{
|
||||
// Content: ext.WidgetContent{Text: "Project: my-app | Branch: main"},
|
||||
// Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
|
||||
// })
|
||||
SetHeader func(HeaderFooterConfig)
|
||||
|
||||
// RemoveHeader removes the custom header. No-op if no header is set.
|
||||
RemoveHeader func()
|
||||
|
||||
// SetFooter places a custom footer at the bottom of the TUI view,
|
||||
// below the status bar. Only one footer can be active at a time;
|
||||
// calling SetFooter replaces any previous footer. The footer persists
|
||||
// across agent turns until explicitly removed.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// ctx.SetFooter(ext.HeaderFooterConfig{
|
||||
// Content: ext.WidgetContent{Text: "Ready | 3 tasks remaining"},
|
||||
// Style: ext.WidgetStyle{BorderColor: "#a6e3a1"},
|
||||
// })
|
||||
SetFooter func(HeaderFooterConfig)
|
||||
|
||||
// RemoveFooter removes the custom footer. No-op if no footer is set.
|
||||
RemoveFooter func()
|
||||
|
||||
// PromptSelect shows a selection list to the user and blocks until
|
||||
// they pick an option or cancel (ESC). Returns a cancelled result in
|
||||
// non-interactive mode. Safe to call from event handlers and slash
|
||||
@@ -369,6 +401,22 @@ type PromptInputResult struct {
|
||||
Cancelled bool
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header/Footer types (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// HeaderFooterConfig describes a custom header or footer region that replaces
|
||||
// or augments the default TUI chrome. Extensions use ctx.SetHeader/SetFooter
|
||||
// to place one; only one header and one footer can be active at a time (the
|
||||
// latest call wins). Reuses WidgetContent and WidgetStyle for consistency.
|
||||
type HeaderFooterConfig struct {
|
||||
// Content describes what to render.
|
||||
Content WidgetContent
|
||||
|
||||
// Style configures the appearance.
|
||||
Style WidgetStyle
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ToolDef / CommandDef
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -15,6 +15,8 @@ type Runner struct {
|
||||
extensions []LoadedExtension
|
||||
ctx Context
|
||||
widgets map[string]WidgetConfig // keyed by widget ID
|
||||
header *HeaderFooterConfig // nil = no custom header
|
||||
footer *HeaderFooterConfig // nil = no custom footer
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
@@ -173,6 +175,64 @@ func (r *Runner) GetWidgets(placement WidgetPlacement) []WidgetConfig {
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header/Footer management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SetHeader places or replaces the custom header. Thread-safe.
|
||||
func (r *Runner) SetHeader(config HeaderFooterConfig) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.header = &config
|
||||
}
|
||||
|
||||
// RemoveHeader removes the custom header. No-op if none is set. Thread-safe.
|
||||
func (r *Runner) RemoveHeader() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.header = nil
|
||||
}
|
||||
|
||||
// GetHeader returns the current custom header, or nil if none is set.
|
||||
// Thread-safe.
|
||||
func (r *Runner) GetHeader() *HeaderFooterConfig {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if r.header == nil {
|
||||
return nil
|
||||
}
|
||||
// Return a copy to avoid races on the caller side.
|
||||
h := *r.header
|
||||
return &h
|
||||
}
|
||||
|
||||
// SetFooter places or replaces the custom footer. Thread-safe.
|
||||
func (r *Runner) SetFooter(config HeaderFooterConfig) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.footer = &config
|
||||
}
|
||||
|
||||
// RemoveFooter removes the custom footer. No-op if none is set. Thread-safe.
|
||||
func (r *Runner) RemoveFooter() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.footer = nil
|
||||
}
|
||||
|
||||
// GetFooter returns the current custom footer, or nil if none is set.
|
||||
// Thread-safe.
|
||||
func (r *Runner) GetFooter() *HeaderFooterConfig {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if r.footer == nil {
|
||||
return nil
|
||||
}
|
||||
// Return a copy to avoid races on the caller side.
|
||||
f := *r.footer
|
||||
return &f
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -34,6 +34,9 @@ func Symbols() interp.Exports {
|
||||
"WidgetAbove": reflect.ValueOf(WidgetAbove),
|
||||
"WidgetBelow": reflect.ValueOf(WidgetBelow),
|
||||
|
||||
// Header/Footer types
|
||||
"HeaderFooterConfig": reflect.ValueOf((*HeaderFooterConfig)(nil)),
|
||||
|
||||
// Prompt types
|
||||
"PromptSelectConfig": reflect.ValueOf((*PromptSelectConfig)(nil)),
|
||||
"PromptSelectResult": reflect.ValueOf((*PromptSelectResult)(nil)),
|
||||
|
||||
+83
-4
@@ -146,6 +146,16 @@ type AppModelOptions struct {
|
||||
// ("above" or "below"). Called during View() to render persistent
|
||||
// extension widgets. May be nil if no extensions are loaded.
|
||||
GetWidgets func(placement string) []WidgetData
|
||||
|
||||
// GetHeader returns the current custom header set by an extension, or
|
||||
// nil if no header is active. Called during View() to render a
|
||||
// persistent header above the stream region. May be nil.
|
||||
GetHeader func() *WidgetData
|
||||
|
||||
// GetFooter returns the current custom footer set by an extension, or
|
||||
// nil if no footer is active. Called during View() to render a
|
||||
// persistent footer below the status bar. May be nil.
|
||||
GetFooter func() *WidgetData
|
||||
}
|
||||
|
||||
// AppModel is the root Bubble Tea model for the interactive TUI. It owns the
|
||||
@@ -155,13 +165,17 @@ type AppModelOptions struct {
|
||||
//
|
||||
// Layout (stacked, no alt screen):
|
||||
//
|
||||
// ┌─ stream region (variable height) ─────────────────┐
|
||||
// ┌─ [custom header] (optional, from extension) ──────┐
|
||||
// ├─ stream region (variable height) ─────────────────┤
|
||||
// │ │
|
||||
// ├─ separator line (with optional queue count) ───────┤
|
||||
// │ [above widgets] │
|
||||
// │ queued How do I fix the build? │
|
||||
// │ queued Also check the tests │
|
||||
// ├─ input region (fixed height from textarea) ────────┤
|
||||
// │ [below widgets] │
|
||||
// │ Tokens: 23.4K (12%) | Cost: $0.00 provider·model │
|
||||
// ├─ [custom footer] (optional, from extension) ──────┤
|
||||
// └────────────────────────────────────────────────────┘
|
||||
//
|
||||
// The status bar is always present (1 line) to avoid layout shifts that
|
||||
@@ -240,6 +254,12 @@ type AppModel struct {
|
||||
// getWidgets returns extension widgets for a given placement. May be nil.
|
||||
getWidgets func(placement string) []WidgetData
|
||||
|
||||
// getHeader returns the current custom header. May be nil.
|
||||
getHeader func() *WidgetData
|
||||
|
||||
// getFooter returns the current custom footer. May be nil.
|
||||
getFooter func() *WidgetData
|
||||
|
||||
// prompt holds the state of an active interactive prompt overlay. Nil
|
||||
// when no prompt is active. Managed by updatePromptState().
|
||||
prompt *promptOverlay
|
||||
@@ -333,6 +353,8 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
|
||||
// Store extension commands for dispatch.
|
||||
m.extensionCommands = opts.ExtensionCommands
|
||||
m.getWidgets = opts.GetWidgets
|
||||
m.getHeader = opts.GetHeader
|
||||
m.getFooter = opts.GetFooter
|
||||
|
||||
// Store context/skills metadata and tool counts for startup display.
|
||||
m.contextPaths = opts.ContextPaths
|
||||
@@ -852,10 +874,17 @@ func (m *AppModel) View() tea.View {
|
||||
}
|
||||
statusBar := m.renderStatusBar()
|
||||
|
||||
// Build the stacked layout. Optional header/footer wrap the core layout.
|
||||
var parts []string
|
||||
|
||||
// Custom header (if set by extension) — above everything.
|
||||
if headerView := m.renderHeaderFooter(m.getHeader); headerView != "" {
|
||||
parts = append(parts, headerView)
|
||||
}
|
||||
|
||||
// Only include the stream region when it has content. When idle the
|
||||
// stream renders "" which JoinVertical would pad to a full-width blank
|
||||
// line, inflating the view unnecessarily.
|
||||
var parts []string
|
||||
if streamView != "" {
|
||||
parts = append(parts, streamView)
|
||||
}
|
||||
@@ -879,6 +908,11 @@ func (m *AppModel) View() tea.View {
|
||||
|
||||
parts = append(parts, statusBar)
|
||||
|
||||
// Custom footer (if set by extension) — below everything.
|
||||
if footerView := m.renderHeaderFooter(m.getFooter); footerView != "" {
|
||||
parts = append(parts, footerView)
|
||||
}
|
||||
|
||||
content := lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||
|
||||
return tea.NewView(content)
|
||||
@@ -1019,6 +1053,40 @@ func (m *AppModel) renderWidgetSlot(placement string) string {
|
||||
return strings.Join(blocks, "\n")
|
||||
}
|
||||
|
||||
// renderHeaderFooter renders a custom header or footer from an extension. The
|
||||
// getter function returns the current data (*WidgetData) or nil when inactive.
|
||||
// Returns "" when the getter is nil or returns nil. Uses the same rendering
|
||||
// pipeline as widgets for visual consistency.
|
||||
func (m *AppModel) renderHeaderFooter(getter func() *WidgetData) string {
|
||||
if getter == nil {
|
||||
return ""
|
||||
}
|
||||
data := getter()
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
theme := GetTheme()
|
||||
|
||||
var opts []renderingOption
|
||||
opts = append(opts, WithAlign(lipgloss.Left))
|
||||
|
||||
if data.NoBorder {
|
||||
opts = append(opts, WithNoBorder())
|
||||
} else {
|
||||
borderClr := theme.Accent
|
||||
if data.BorderColor != "" {
|
||||
borderClr = lipgloss.Color(data.BorderColor)
|
||||
}
|
||||
opts = append(opts, WithBorderColor(borderClr))
|
||||
}
|
||||
|
||||
// Compact padding like widgets.
|
||||
opts = append(opts, WithPaddingTop(0), WithPaddingBottom(0))
|
||||
|
||||
return renderContentBlock(data.Text, m.width, opts...)
|
||||
}
|
||||
|
||||
// renderQueuedMessages renders queued prompts as styled content blocks with a
|
||||
// "QUEUED" badge, anchored between the separator and input. Each message is
|
||||
// displayed in a bordered block matching the overall message styling.
|
||||
@@ -1392,13 +1460,15 @@ func (m *AppModel) flushStreamContent() tea.Cmd {
|
||||
//
|
||||
// Layout (line counts):
|
||||
//
|
||||
// stream region = total - separator(1) - widgets - queued(N*5) - input(measured) - widgets - statusBar(1)
|
||||
// header = measured dynamically (0 if not set)
|
||||
// stream region = total - header - separator(1) - widgets - queued(N*5) - input(measured) - widgets - statusBar(1) - footer
|
||||
// separator = 1 line
|
||||
// above widgets = measured dynamically
|
||||
// queued msgs = ~5 lines per message (padding + text + badge + padding)
|
||||
// input region = measured dynamically via lipgloss.Height()
|
||||
// below widgets = measured dynamically
|
||||
// status bar = 1 line (always present)
|
||||
// footer = measured dynamically (0 if not set)
|
||||
func (m *AppModel) distributeHeight() {
|
||||
const separatorLines = 1
|
||||
const statusBarLines = 1 // always-present status bar
|
||||
@@ -1427,7 +1497,16 @@ func (m *AppModel) distributeHeight() {
|
||||
widgetLines += lipgloss.Height(below)
|
||||
}
|
||||
|
||||
streamHeight := max(m.height-separatorLines-widgetLines-queuedLines-inputLines-statusBarLines, 0)
|
||||
// Measure header/footer heights.
|
||||
var headerFooterLines int
|
||||
if headerView := m.renderHeaderFooter(m.getHeader); headerView != "" {
|
||||
headerFooterLines += lipgloss.Height(headerView)
|
||||
}
|
||||
if footerView := m.renderHeaderFooter(m.getFooter); footerView != "" {
|
||||
headerFooterLines += lipgloss.Height(footerView)
|
||||
}
|
||||
|
||||
streamHeight := max(m.height-separatorLines-widgetLines-headerFooterLines-queuedLines-inputLines-statusBarLines, 0)
|
||||
|
||||
if m.stream != nil {
|
||||
m.stream.SetHeight(streamHeight)
|
||||
|
||||
@@ -169,6 +169,56 @@ func (m *Kit) GetExtensionWidgets(placement extensions.WidgetPlacement) []extens
|
||||
return m.extRunner.GetWidgets(placement)
|
||||
}
|
||||
|
||||
// SetExtensionHeader places or replaces the custom header from extensions.
|
||||
// Delegates to the extension runner. No-op if extensions are disabled.
|
||||
func (m *Kit) SetExtensionHeader(config extensions.HeaderFooterConfig) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.SetHeader(config)
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveExtensionHeader removes the custom extension header.
|
||||
// Delegates to the extension runner. No-op if extensions are disabled.
|
||||
func (m *Kit) RemoveExtensionHeader() {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.RemoveHeader()
|
||||
}
|
||||
}
|
||||
|
||||
// GetExtensionHeader returns the current custom header, or nil if none is set.
|
||||
// Returns nil if extensions are disabled.
|
||||
func (m *Kit) GetExtensionHeader() *extensions.HeaderFooterConfig {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return m.extRunner.GetHeader()
|
||||
}
|
||||
|
||||
// SetExtensionFooter places or replaces the custom footer from extensions.
|
||||
// Delegates to the extension runner. No-op if extensions are disabled.
|
||||
func (m *Kit) SetExtensionFooter(config extensions.HeaderFooterConfig) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.SetFooter(config)
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveExtensionFooter removes the custom extension footer.
|
||||
// Delegates to the extension runner. No-op if extensions are disabled.
|
||||
func (m *Kit) RemoveExtensionFooter() {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.RemoveFooter()
|
||||
}
|
||||
}
|
||||
|
||||
// GetExtensionFooter returns the current custom footer, or nil if none is set.
|
||||
// Returns nil if extensions are disabled.
|
||||
func (m *Kit) GetExtensionFooter() *extensions.HeaderFooterConfig {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return m.extRunner.GetFooter()
|
||||
}
|
||||
|
||||
// HasExtensions returns true if the extension runner is configured and active.
|
||||
func (m *Kit) HasExtensions() bool {
|
||||
return m.extRunner != nil
|
||||
|
||||
Reference in New Issue
Block a user