mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
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:
+39
-5
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user