feat: add extension widget slots for persistent TUI content

Add a declarative widget system that lets extensions place persistent
content above or below the input area. Widgets survive across agent
turns and are updated via ctx.SetWidget/ctx.RemoveWidget from any
event handler.

All types are concrete structs (Yaegi-safe, no interfaces cross the
interpreter boundary). Widget state lives on the Runner with mutex
protection, and WidgetUpdateEvent triggers BubbleTea re-renders.
This commit is contained in:
Ed Zynda
2026-02-28 12:16:20 +03:00
parent 1309c4bd12
commit 3009b5530b
9 changed files with 401 additions and 10 deletions
+39 -5
View File
@@ -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.
+90
View File
@@ -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")
})
}
+12
View File
@@ -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()
+5
View File
@@ -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
+80
View File
@@ -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
// ---------------------------------------------------------------------------
+46
View File
@@ -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
// ---------------------------------------------------------------------------
+8
View File
@@ -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)),
+96 -5
View File
@@ -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)
+25
View File
@@ -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