diff --git a/cmd/root.go b/cmd/root.go index e24ecf1d..647ad6d7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -305,6 +305,31 @@ func extensionCommandsForUI(k *kit.Kit) []ui.ExtensionCommand { return cmds } +// widgetProviderForUI returns a function that converts extension widgets to +// ui.WidgetData for the given placement. Returns nil if extensions are +// disabled, which is safe — the UI treats a nil GetWidgets as "no widgets". +func widgetProviderForUI(k *kit.Kit) func(string) []ui.WidgetData { + if !k.HasExtensions() { + return nil + } + return func(placement string) []ui.WidgetData { + configs := k.GetExtensionWidgets(extensions.WidgetPlacement(placement)) + if len(configs) == 0 { + return nil + } + widgets := make([]ui.WidgetData, len(configs)) + for i, c := range configs { + widgets[i] = ui.WidgetData{ + Text: c.Content.Text, + Markdown: c.Content.Markdown, + BorderColor: c.Style.BorderColor, + NoBorder: c.Style.NoBorder, + } + } + return widgets + } +} + func runNormalMode(ctx context.Context) error { // Validate flag combinations if quietFlag && promptFlag == "" { @@ -431,6 +456,14 @@ func runNormalMode(ctx context.Context) error { PrintError: func(text string) { appInstance.PrintFromExtension("error", text) }, PrintBlock: appInstance.PrintBlockFromExtension, SendMessage: func(text string) { appInstance.Run(text) }, + SetWidget: func(config extensions.WidgetConfig) { + kitInstance.SetExtensionWidget(config) + appInstance.NotifyWidgetUpdate() + }, + RemoveWidget: func(id string) { + kitInstance.RemoveExtensionWidget(id) + appInstance.NotifyWidgetUpdate() + }, }) kitInstance.EmitSessionStart() } @@ -459,7 +492,7 @@ func runNormalMode(ctx context.Context) error { // 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) + return runNonInteractiveModeApp(ctx, appInstance, cli, promptFlag, quietFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, widgetProviderForUI(kitInstance)) } // Quiet mode is not allowed in interactive mode @@ -467,7 +500,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) + return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, widgetProviderForUI(kitInstance)) } // runNonInteractiveModeApp executes a single prompt via the app layer and exits, @@ -480,7 +513,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) 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) error { if quiet { // Quiet mode: no intermediate display, just print final response. if err := appInstance.RunOnce(ctx, prompt); err != nil { @@ -506,7 +539,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) + return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets) } return nil @@ -523,7 +556,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) 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) error { // Determine terminal size; fall back gracefully. termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd())) if err != nil || termWidth == 0 { @@ -546,6 +579,7 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN ExtensionCommands: extCommands, ContextPaths: contextPaths, SkillItems: skillItems, + GetWidgets: getWidgets, }) // Print startup info to stdout before Bubble Tea takes over the screen. diff --git a/examples/extensions/widget-status.go b/examples/extensions/widget-status.go new file mode 100644 index 00000000..d8a241ca --- /dev/null +++ b/examples/extensions/widget-status.go @@ -0,0 +1,90 @@ +//go:build ignore + +package main + +import ( + "fmt" + "time" + + "kit/ext" +) + +// Init demonstrates the widget system by showing a persistent status +// widget above the input area. The widget updates on each agent turn +// to show a running count of tool calls and the last tool used. +func Init(api ext.API) { + var toolCallCount int + var lastToolName string + + // Show initial status widget when session starts. + api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) { + ctx.SetWidget(ext.WidgetConfig{ + ID: "widget-status:info", + Placement: ext.WidgetAbove, + Content: ext.WidgetContent{ + Text: fmt.Sprintf("Session started | CWD: %s | Model: %s", ctx.CWD, ctx.Model), + }, + Style: ext.WidgetStyle{ + BorderColor: "#89b4fa", + }, + }) + }) + + // Update the widget after each tool call with a running count. + api.OnToolResult(func(tr ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult { + toolCallCount++ + lastToolName = tr.ToolName + + status := "ok" + if tr.IsError { + status = "error" + } + + ctx.SetWidget(ext.WidgetConfig{ + ID: "widget-status:info", + Placement: ext.WidgetAbove, + Content: ext.WidgetContent{ + Text: fmt.Sprintf( + "Tools: %d calls | Last: %s (%s) | %s", + toolCallCount, lastToolName, status, + time.Now().Format("15:04:05"), + ), + }, + Style: ext.WidgetStyle{ + BorderColor: "#a6e3a1", + }, + }) + return nil + }) + + // "!widget-off" — removes the status widget. + // "!widget-on" — restores the status widget. + api.OnInput(func(ie ext.InputEvent, ctx ext.Context) *ext.InputResult { + switch ie.Text { + case "!widget-off": + ctx.RemoveWidget("widget-status:info") + ctx.PrintInfo("Status widget removed.") + return &ext.InputResult{Action: "handled"} + + case "!widget-on": + ctx.SetWidget(ext.WidgetConfig{ + ID: "widget-status:info", + Placement: ext.WidgetAbove, + Content: ext.WidgetContent{ + Text: fmt.Sprintf("Tools: %d calls | %s", toolCallCount, time.Now().Format("15:04:05")), + }, + Style: ext.WidgetStyle{ + BorderColor: "#a6e3a1", + }, + }) + ctx.PrintInfo("Status widget restored.") + return &ext.InputResult{Action: "handled"} + } + return nil + }) + + // Clean up widget on shutdown. + api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) { + ctx.RemoveWidget("widget-status:info") + }) +} diff --git a/internal/app/app.go b/internal/app/app.go index 44c3e4dd..6f99b2cc 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -491,6 +491,18 @@ func (a *App) PrintFromExtension(level, text string) { fmt.Println(text) } +// NotifyWidgetUpdate sends a WidgetUpdateEvent to the TUI so it re-renders +// extension widgets. Called from the extension context's SetWidget/RemoveWidget +// closures. In non-interactive mode this is a no-op (widgets are TUI-only). +func (a *App) NotifyWidgetUpdate() { + a.mu.Lock() + prog := a.program + a.mu.Unlock() + if prog != nil { + prog.Send(WidgetUpdateEvent{}) + } +} + // PrintBlockFromExtension outputs a custom styled block from an extension. func (a *App) PrintBlockFromExtension(opts extensions.PrintBlockOpts) { a.mu.Lock() diff --git a/internal/app/events.go b/internal/app/events.go index 7e200270..d5128484 100644 --- a/internal/app/events.go +++ b/internal/app/events.go @@ -113,6 +113,11 @@ type CompactErrorEvent struct { Err error } +// WidgetUpdateEvent is sent when an extension adds, updates, or removes a +// widget via ctx.SetWidget or ctx.RemoveWidget. The TUI re-reads widget state +// from its WidgetProvider on the next render cycle. +type WidgetUpdateEvent struct{} + // ExtensionPrintEvent is sent when an extension calls ctx.Print, ctx.PrintInfo, // ctx.PrintError, or ctx.PrintBlock. The TUI renders it via the appropriate // renderer and tea.Println (scrollback); the CLI handler uses diff --git a/internal/extensions/api.go b/internal/extensions/api.go index ac0d2708..33dd3902 100644 --- a/internal/extensions/api.go +++ b/internal/extensions/api.go @@ -63,6 +63,25 @@ type Context struct { // ctx.SendMessage("Subagent result:\n" + string(out)) // }() SendMessage func(string) + + // SetWidget places or updates a persistent widget in the TUI. Widgets + // remain visible across agent turns until explicitly removed. The + // widget is identified by WidgetConfig.ID; calling SetWidget with the + // same ID replaces the previous content. + // + // Example: + // + // ctx.SetWidget(ext.WidgetConfig{ + // ID: "my-status", + // Placement: ext.WidgetAbove, + // Content: ext.WidgetContent{Text: "Build: passing"}, + // Style: ext.WidgetStyle{BorderColor: "#a6e3a1"}, + // }) + SetWidget func(WidgetConfig) + + // RemoveWidget removes a previously placed widget by its ID. No-op if + // the ID does not exist. + RemoveWidget func(id string) } // PrintBlockOpts configures a custom styled block for PrintBlock. @@ -185,6 +204,67 @@ func (a *API) RegisterCommand(cmd CommandDef) { a.registerCmdFn(cmd) } +// --------------------------------------------------------------------------- +// Widget types (exposed to Yaegi — concrete structs, no interfaces) +// --------------------------------------------------------------------------- + +// WidgetPlacement determines where a widget appears in the TUI layout +// relative to the input area. +type WidgetPlacement string + +const ( + // WidgetAbove places the widget above the input area, between the + // separator and queued messages. + WidgetAbove WidgetPlacement = "above" + + // WidgetBelow places the widget below the input area, between the + // input and the status bar. + WidgetBelow WidgetPlacement = "below" +) + +// WidgetContent describes what to render in a widget slot. +type WidgetContent struct { + // Text is the content to display. + Text string + + // Markdown, when true, renders Text as styled markdown instead of + // plain text. + Markdown bool +} + +// WidgetStyle configures the visual appearance of a widget. +type WidgetStyle struct { + // BorderColor is a hex color (e.g. "#a6e3a1") for the left border. + // Empty uses the theme's default accent color. + BorderColor string + + // NoBorder disables the left border entirely. + NoBorder bool +} + +// WidgetConfig fully describes a widget for placement in the TUI. +// Extensions identify widgets by ID; calling SetWidget with the same ID +// replaces the previous widget. IDs should be descriptive to avoid +// collisions across extensions (e.g. "myext:token-counter"). +type WidgetConfig struct { + // ID uniquely identifies this widget. Must be non-empty. + ID string + + // Placement determines where the widget appears (above or below input). + Placement WidgetPlacement + + // Content describes what to render. + Content WidgetContent + + // Style configures the appearance. + Style WidgetStyle + + // Priority controls ordering within a placement slot. Lower values + // render first. Widgets with equal priority are ordered by insertion + // time. + Priority int +} + // --------------------------------------------------------------------------- // ToolDef / CommandDef // --------------------------------------------------------------------------- diff --git a/internal/extensions/runner.go b/internal/extensions/runner.go index 6541b892..33c43840 100644 --- a/internal/extensions/runner.go +++ b/internal/extensions/runner.go @@ -2,6 +2,7 @@ package extensions import ( "fmt" + "sort" "sync" "github.com/charmbracelet/log" @@ -13,6 +14,7 @@ import ( type Runner struct { extensions []LoadedExtension ctx Context + widgets map[string]WidgetConfig // keyed by widget ID mu sync.RWMutex } @@ -127,6 +129,50 @@ func (r *Runner) Extensions() []LoadedExtension { return r.extensions } +// --------------------------------------------------------------------------- +// Widget management +// --------------------------------------------------------------------------- + +// SetWidget places or updates a persistent widget. The widget is identified +// by config.ID; calling SetWidget with the same ID replaces the previous +// content. Thread-safe. +func (r *Runner) SetWidget(config WidgetConfig) { + r.mu.Lock() + defer r.mu.Unlock() + if r.widgets == nil { + r.widgets = make(map[string]WidgetConfig) + } + r.widgets[config.ID] = config +} + +// RemoveWidget removes a widget by ID. No-op if the ID does not exist. +// Thread-safe. +func (r *Runner) RemoveWidget(id string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.widgets, id) +} + +// GetWidgets returns all widgets matching the given placement, sorted by +// priority (ascending). Thread-safe. +func (r *Runner) GetWidgets(placement WidgetPlacement) []WidgetConfig { + r.mu.RLock() + defer r.mu.RUnlock() + var result []WidgetConfig + for _, w := range r.widgets { + if w.Placement == placement { + result = append(result, w) + } + } + sort.Slice(result, func(i, j int) bool { + if result[i].Priority != result[j].Priority { + return result[i].Priority < result[j].Priority + } + return result[i].ID < result[j].ID // stable tie-break + }) + return result +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/internal/extensions/symbols.go b/internal/extensions/symbols.go index 39ec1c2f..3732214e 100644 --- a/internal/extensions/symbols.go +++ b/internal/extensions/symbols.go @@ -26,6 +26,14 @@ func Symbols() interp.Exports { "CommandDef": reflect.ValueOf((*CommandDef)(nil)), "PrintBlockOpts": reflect.ValueOf((*PrintBlockOpts)(nil)), + // Widget types + "WidgetConfig": reflect.ValueOf((*WidgetConfig)(nil)), + "WidgetContent": reflect.ValueOf((*WidgetContent)(nil)), + "WidgetStyle": reflect.ValueOf((*WidgetStyle)(nil)), + "WidgetPlacement": reflect.ValueOf((*WidgetPlacement)(nil)), + "WidgetAbove": reflect.ValueOf(WidgetAbove), + "WidgetBelow": reflect.ValueOf(WidgetBelow), + // Event structs "ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)), "ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)), diff --git a/internal/ui/model.go b/internal/ui/model.go index a5533aba..be04a8af 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -69,6 +69,21 @@ type SkillItem struct { Source string // "project" or "user" (global). } +// WidgetData is the UI-layer representation of an extension widget. It +// decouples the UI package from the extensions package. The CLI layer +// converts extension WidgetConfig values to WidgetData for rendering. +type WidgetData struct { + // Text is the content to display. + Text string + // Markdown, when true, renders Text as styled markdown. + Markdown bool + // BorderColor is a hex color (e.g. "#a6e3a1") for the left border. + // Empty uses the theme's default accent color. + BorderColor string + // NoBorder disables the left border entirely. + NoBorder bool +} + // AppModelOptions holds configuration passed to NewAppModel. type AppModelOptions struct { // CompactMode selects the compact renderer for message formatting. @@ -117,6 +132,11 @@ type AppModelOptions struct { // ExtensionToolCount is the number of tools registered by extensions. ExtensionToolCount int + + // GetWidgets returns current extension widgets for a given placement + // ("above" or "below"). Called during View() to render persistent + // extension widgets. May be nil if no extensions are loaded. + GetWidgets func(placement string) []WidgetData } // AppModel is the root Bubble Tea model for the interactive TUI. It owns the @@ -208,6 +228,9 @@ type AppModel struct { mcpToolCount int extensionToolCount int + // getWidgets returns extension widgets for a given placement. May be nil. + getWidgets func(placement string) []WidgetData + // width and height track the terminal dimensions. width int height int @@ -287,6 +310,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel { // Store extension commands for dispatch. m.extensionCommands = opts.ExtensionCommands + m.getWidgets = opts.GetWidgets // Store context/skills metadata and tool counts for startup display. m.contextPaths = opts.ContextPaths @@ -692,6 +716,12 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state = stateInput cmds = append(cmds, m.printSystemMessage(fmt.Sprintf("Compaction failed: %v", msg.Err))) + case app.WidgetUpdateEvent: + // Extension widget changed — recalculate height distribution so the + // stream region accounts for widget space. View() will read the + // latest widget state on the next render. + m.distributeHeight() + case app.ExtensionPrintEvent: // Extension output — route through styled renderers when a level is set. switch msg.Level { @@ -746,11 +776,23 @@ func (m *AppModel) View() tea.View { } parts = append(parts, separator) + // Render "above" widgets between separator and queued messages. + if aboveView := m.renderWidgetSlot("above"); aboveView != "" { + parts = append(parts, aboveView) + } + if queuedView := m.renderQueuedMessages(); queuedView != "" { parts = append(parts, queuedView) } - parts = append(parts, inputView, statusBar) + parts = append(parts, inputView) + + // Render "below" widgets between input and status bar. + if belowView := m.renderWidgetSlot("below"); belowView != "" { + parts = append(parts, belowView) + } + + parts = append(parts, statusBar) content := lipgloss.JoinVertical(lipgloss.Left, parts...) @@ -854,6 +896,44 @@ func (m *AppModel) renderInput() string { return m.input.View().Content } +// renderWidgetSlot renders all extension widgets for the given placement +// ("above" or "below"). Returns "" if no widgets exist for that slot. +func (m *AppModel) renderWidgetSlot(placement string) string { + if m.getWidgets == nil { + return "" + } + widgets := m.getWidgets(placement) + if len(widgets) == 0 { + return "" + } + + theme := GetTheme() + var blocks []string + for _, w := range widgets { + content := w.Text + + var opts []renderingOption + opts = append(opts, WithAlign(lipgloss.Left)) + + if w.NoBorder { + opts = append(opts, WithNoBorder()) + } else { + borderClr := theme.Accent + if w.BorderColor != "" { + borderClr = lipgloss.Color(w.BorderColor) + } + opts = append(opts, WithBorderColor(borderClr)) + } + + // Use tighter padding for widgets (less vertical padding than + // full message blocks) so they feel compact and unobtrusive. + opts = append(opts, WithPaddingTop(0), WithPaddingBottom(0)) + + blocks = append(blocks, renderContentBlock(content, m.width, opts...)) + } + return strings.Join(blocks, "\n") +} + // 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. @@ -1208,15 +1288,17 @@ func (m *AppModel) flushStreamContent() tea.Cmd { } // distributeHeight recalculates child component heights after a window resize, -// queue change, or state transition, and propagates the computed stream height -// to the StreamComponent. +// queue change, widget update, or state transition, and propagates the computed +// stream height to the StreamComponent. // // Layout (line counts): // -// stream region = total - separator(1) - queued(N*5) - input(measured) - statusBar(1) +// stream region = total - separator(1) - widgets - queued(N*5) - input(measured) - widgets - statusBar(1) // 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) func (m *AppModel) distributeHeight() { const separatorLines = 1 @@ -1233,7 +1315,16 @@ func (m *AppModel) distributeHeight() { } } - streamHeight := max(m.height-separatorLines-queuedLines-inputLines-statusBarLines, 0) + // Measure widget heights. + var widgetLines int + if above := m.renderWidgetSlot("above"); above != "" { + widgetLines += lipgloss.Height(above) + } + if below := m.renderWidgetSlot("below"); below != "" { + widgetLines += lipgloss.Height(below) + } + + streamHeight := max(m.height-separatorLines-widgetLines-queuedLines-inputLines-statusBarLines, 0) if m.stream != nil { m.stream.SetHeight(streamHeight) diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index 84464167..fcc082e4 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -144,6 +144,31 @@ func (m *Kit) ExtensionCommands() []extensions.CommandDef { return m.extRunner.RegisteredCommands() } +// SetExtensionWidget places or updates a persistent extension widget. +// Delegates to the extension runner. No-op if extensions are disabled. +func (m *Kit) SetExtensionWidget(config extensions.WidgetConfig) { + if m.extRunner != nil { + m.extRunner.SetWidget(config) + } +} + +// RemoveExtensionWidget removes a previously placed extension widget by ID. +// Delegates to the extension runner. No-op if extensions are disabled. +func (m *Kit) RemoveExtensionWidget(id string) { + if m.extRunner != nil { + m.extRunner.RemoveWidget(id) + } +} + +// GetExtensionWidgets returns extension widgets matching the given placement. +// Returns nil if extensions are disabled or no widgets match. +func (m *Kit) GetExtensionWidgets(placement extensions.WidgetPlacement) []extensions.WidgetConfig { + if m.extRunner == nil { + return nil + } + return m.extRunner.GetWidgets(placement) +} + // HasExtensions returns true if the extension runner is configured and active. func (m *Kit) HasExtensions() bool { return m.extRunner != nil