diff --git a/cmd/root.go b/cmd/root.go index 2386db4a..411106e4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -807,6 +807,31 @@ func runNormalMode(ctx context.Context) error { Complete: func(req extensions.CompleteRequest) (extensions.CompleteResponse, error) { return kitInstance.ExecuteCompletion(context.Background(), req) }, + SuspendTUI: func(callback func()) error { + return appInstance.SuspendTUI(callback) + }, + RenderMessage: func(rendererName, content string) { + renderer := kitInstance.GetExtensionMessageRenderer(rendererName) + if renderer == nil || renderer.Render == nil { + appInstance.PrintFromExtension("", content) + return + } + w, _, _ := term.GetSize(int(os.Stdout.Fd())) + if w == 0 { + w = 80 + } + rendered := renderer.Render(content, w) + appInstance.PrintFromExtension("", rendered) + }, + ReloadExtensions: func() error { + err := kitInstance.ReloadExtensions() + if err != nil { + return err + } + // Notify TUI that widgets/status/commands may have changed. + appInstance.NotifyWidgetUpdate() + return nil + }, GetAllTools: func() []extensions.ToolInfo { return kitInstance.GetExtensionToolInfos() }, @@ -873,10 +898,13 @@ func runNormalMode(ctx context.Context) error { emitBeforeFork := beforeForkProviderForUI(kitInstance) emitBeforeSessionSwitch := beforeSessionSwitchProviderForUI(kitInstance) getGlobalShortcuts := globalShortcutsProviderForUI(kitInstance) + getExtensionCommands := func() []ui.ExtensionCommand { + return extensionCommandsForUI(kitInstance) + } // Check if running in non-interactive mode if promptFlag != "" { - return runNonInteractiveModeApp(ctx, appInstance, cli, promptFlag, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts) + return runNonInteractiveModeApp(ctx, appInstance, cli, promptFlag, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands) } // Quiet mode is not allowed in interactive mode @@ -884,7 +912,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, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts) + return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands) } // runNonInteractiveModeApp executes a single prompt via the app layer and exits, @@ -897,7 +925,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, jsonOutput, 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, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func()) error { +func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, jsonOutput, 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, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand) error { if jsonOutput { // JSON mode: no intermediate display, structured JSON output. result, err := appInstance.RunOnceResult(ctx, prompt) @@ -935,7 +963,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, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts) + return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands) } return nil @@ -1029,7 +1057,7 @@ func writeJSONError(err error) { // 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, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func()) 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, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand) error { // Determine terminal size; fall back gracefully. termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd())) if err != nil || termWidth == 0 { @@ -1062,6 +1090,7 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN EmitBeforeFork: emitBeforeFork, EmitBeforeSessionSwitch: emitBeforeSessionSwitch, GetGlobalShortcuts: getGlobalShortcuts, + GetExtensionCommands: getExtensionCommands, }) // Print startup info to stdout before Bubble Tea takes over the screen. diff --git a/examples/extensions/branded-output.go b/examples/extensions/branded-output.go new file mode 100644 index 00000000..42f1e42e --- /dev/null +++ b/examples/extensions/branded-output.go @@ -0,0 +1,76 @@ +//go:build ignore + +// branded-output.go — Custom Message Rendering example extension for Kit. +// +// Demonstrates api.RegisterMessageRenderer() and ctx.RenderMessage() which +// let extensions define reusable visual styles for output. Each renderer has +// a name and a render function that receives content and terminal width. +// +// This extension registers three renderers: +// "success" — green-bordered block for success messages +// "warning" — yellow-bordered block for warnings +// "metric" — compact key=value display for metrics +// +// Commands: +// /demo-render — shows all three renderers in action + +package main + +import ( + "fmt" + "strings" + "time" + + ext "kit/ext" +) + +func Init(api ext.API) { + // Register a "success" renderer — green-accented block. + api.RegisterMessageRenderer(ext.MessageRendererConfig{ + Name: "success", + Render: func(content string, width int) string { + maxW := width - 6 + if maxW < 20 { + maxW = 20 + } + bar := strings.Repeat("─", maxW) + return fmt.Sprintf(" \033[32m┌%s┐\033[0m\n \033[32m│\033[0m \033[1;32m%s\033[0m\n \033[32m└%s┘\033[0m", + bar, content, bar) + }, + }) + + // Register a "warning" renderer — yellow-accented block. + api.RegisterMessageRenderer(ext.MessageRendererConfig{ + Name: "warning", + Render: func(content string, width int) string { + maxW := width - 6 + if maxW < 20 { + maxW = 20 + } + bar := strings.Repeat("─", maxW) + return fmt.Sprintf(" \033[33m┌%s┐\033[0m\n \033[33m│\033[0m \033[1;33m%s\033[0m\n \033[33m└%s┘\033[0m", + bar, content, bar) + }, + }) + + // Register a "metric" renderer — compact label: value format. + api.RegisterMessageRenderer(ext.MessageRendererConfig{ + Name: "metric", + Render: func(content string, width int) string { + return fmt.Sprintf(" \033[36m▸\033[0m %s", content) + }, + }) + + api.RegisterCommand(ext.CommandDef{ + Name: "demo-render", + Description: "Demonstrate custom message renderers", + Execute: func(args string, ctx ext.Context) (string, error) { + ctx.RenderMessage("success", "All 42 tests passed in 3.2s") + ctx.RenderMessage("warning", "3 deprecation warnings detected") + ctx.RenderMessage("metric", fmt.Sprintf("build_time=%.1fs tests=42 coverage=87%% timestamp=%s", + 3.2, time.Now().Format("15:04:05"))) + + return "Rendered three message styles.", nil + }, + }) +} diff --git a/examples/extensions/dev-reload.go b/examples/extensions/dev-reload.go new file mode 100644 index 00000000..5797dce0 --- /dev/null +++ b/examples/extensions/dev-reload.go @@ -0,0 +1,56 @@ +//go:build ignore + +// dev-reload.go — Extension Hot-Reload example extension for Kit. +// +// Demonstrates ctx.ReloadExtensions() which hot-reloads all extensions +// from disk without restarting Kit. This is invaluable during extension +// development: edit your extension source, then type /reload to pick up +// changes immediately. +// +// Event handlers, slash commands, tool renderers, message renderers, and +// keyboard shortcuts update immediately. Extension-defined tools are NOT +// updated (they are baked into the agent at creation time and require a +// restart). +// +// Commands: +// /reload — hot-reload all extensions from disk + +package main + +import ( + "fmt" + "time" + + ext "kit/ext" +) + +var loadedAt string + +func Init(api ext.API) { + loadedAt = time.Now().Format("15:04:05") + + api.RegisterCommand(ext.CommandDef{ + Name: "reload", + Description: "Hot-reload all extensions from disk", + Execute: func(args string, ctx ext.Context) (string, error) { + ctx.Print("Reloading extensions...") + err := ctx.ReloadExtensions() + if err != nil { + return "", fmt.Errorf("reload failed: %w", err) + } + return "Extensions reloaded successfully.", nil + }, + }) + + api.RegisterCommand(ext.CommandDef{ + Name: "load-time", + Description: "Show when this extension was loaded", + Execute: func(args string, ctx ext.Context) (string, error) { + return fmt.Sprintf("This extension was loaded at %s", loadedAt), nil + }, + }) + + api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) { + ctx.Print(fmt.Sprintf("[dev-reload] Extension loaded at %s", loadedAt)) + }) +} diff --git a/examples/extensions/interactive-shell.go b/examples/extensions/interactive-shell.go new file mode 100644 index 00000000..25bdab9f --- /dev/null +++ b/examples/extensions/interactive-shell.go @@ -0,0 +1,123 @@ +//go:build ignore + +// interactive-shell.go — TUI Suspend example extension for Kit. +// +// Demonstrates ctx.SuspendTUI() which temporarily releases the terminal +// from the TUI so interactive subprocesses can run with full terminal +// control. The TUI is automatically restored when the callback returns. +// +// Commands: +// /edit — opens $EDITOR (or vi) to edit a file +// /shell — drops into an interactive shell session +// /run — runs a command with full terminal I/O (no TUI capture) + +package main + +import ( + "fmt" + "os" + "os/exec" + "strings" + + ext "kit/ext" +) + +func Init(api ext.API) { + api.RegisterCommand(ext.CommandDef{ + Name: "edit", + Description: "Open $EDITOR to edit a file (TUI suspends)", + Execute: func(args string, ctx ext.Context) (string, error) { + file := strings.TrimSpace(args) + if file == "" { + return "", fmt.Errorf("usage: /edit ") + } + + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vi" + } + + ctx.Print(fmt.Sprintf("Opening %s in %s...", file, editor)) + + err := ctx.SuspendTUI(func() { + cmd := exec.Command(editor, file) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Run() + }) + if err != nil { + return "", fmt.Errorf("editor session failed: %w", err) + } + + return fmt.Sprintf("Finished editing %s", file), nil + }, + Complete: func(prefix string, ctx ext.Context) []string { + // Suggest files in the current directory. + entries, err := os.ReadDir(".") + if err != nil { + return nil + } + var results []string + for _, e := range entries { + name := e.Name() + if strings.HasPrefix(name, prefix) { + results = append(results, name) + } + } + return results + }, + }) + + api.RegisterCommand(ext.CommandDef{ + Name: "shell", + Description: "Drop into an interactive shell (TUI suspends)", + Execute: func(args string, ctx ext.Context) (string, error) { + shell := os.Getenv("SHELL") + if shell == "" { + shell = "/bin/sh" + } + + ctx.Print(fmt.Sprintf("Starting %s... (type 'exit' to return to Kit)", shell)) + + err := ctx.SuspendTUI(func() { + cmd := exec.Command(shell) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Run() + }) + if err != nil { + return "", fmt.Errorf("shell session failed: %w", err) + } + + return "Shell session ended, TUI restored.", nil + }, + }) + + api.RegisterCommand(ext.CommandDef{ + Name: "run", + Description: "Run a command with full terminal I/O (TUI suspends)", + Execute: func(args string, ctx ext.Context) (string, error) { + cmdStr := strings.TrimSpace(args) + if cmdStr == "" { + return "", fmt.Errorf("usage: /run ") + } + + ctx.Print(fmt.Sprintf("Running: %s", cmdStr)) + + err := ctx.SuspendTUI(func() { + cmd := exec.Command("sh", "-c", cmdStr) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Run() + }) + if err != nil { + return "", fmt.Errorf("command failed: %w", err) + } + + return "Command finished, TUI restored.", nil + }, + }) +} diff --git a/internal/app/app.go b/internal/app/app.go index 6eda0d30..361d0fc4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -627,6 +627,32 @@ func (a *App) SendOverlayRequest(evt OverlayRequestEvent) { } } +// SuspendTUI temporarily releases the terminal from the TUI, runs the +// callback (which may spawn interactive subprocesses), and then restores +// the TUI. In non-interactive mode (no program registered) the callback +// runs directly with no terminal state changes. +// +// Safe to call from any goroutine (extension command handlers run in +// goroutines). Blocks until the callback returns. +func (a *App) SuspendTUI(callback func()) error { + a.mu.Lock() + prog := a.program + a.mu.Unlock() + if prog == nil { + // Non-interactive: just run the callback directly. + callback() + return nil + } + if err := prog.ReleaseTerminal(); err != nil { + return fmt.Errorf("release terminal: %w", err) + } + callback() + if err := prog.RestoreTerminal(); err != nil { + return fmt.Errorf("restore terminal: %w", err) + } + return nil +} + // PrintBlockFromExtension outputs a custom styled block from an extension. func (a *App) PrintBlockFromExtension(opts extensions.PrintBlockOpts) { a.mu.Lock() diff --git a/internal/extensions/api.go b/internal/extensions/api.go index 8bacecfa..e1931e92 100644 --- a/internal/extensions/api.go +++ b/internal/extensions/api.go @@ -434,6 +434,63 @@ type Context struct { // }, // }) Complete func(CompleteRequest) (CompleteResponse, error) + + // SuspendTUI temporarily releases the terminal from the TUI, runs the + // provided callback (which may spawn interactive processes like vim or + // htop), and then restores the TUI. In non-interactive mode the + // callback runs directly with no terminal changes. + // + // The callback has full access to stdin/stdout/stderr while the TUI is + // suspended. Return from the callback to restore the TUI. + // + // Example — launch $EDITOR: + // + // err := ctx.SuspendTUI(func() { + // editor := os.Getenv("EDITOR") + // if editor == "" { editor = "vim" } + // cmd := exec.Command(editor, "file.go") + // cmd.Stdin = os.Stdin + // cmd.Stdout = os.Stdout + // cmd.Stderr = os.Stderr + // cmd.Run() + // }) + SuspendTUI func(callback func()) error + + // RenderMessage outputs text using a named message renderer registered + // by an extension via api.RegisterMessageRenderer(). If no renderer + // with the given name exists, the content is printed as plain text. + // + // This allows extensions to define reusable visual styles (borders, + // colors, formatting) for specific message categories and invoke them + // by name at runtime. + // + // Example: + // + // ctx.RenderMessage("build-status", "All 42 tests passed.") + RenderMessage func(rendererName string, content string) + + // ReloadExtensions hot-reloads all extensions from disk. Existing + // extensions receive a SessionShutdown event, then new code is loaded + // and receives a SessionStart event. Event handlers, commands, + // renderers, and shortcuts update immediately; extension-defined tools + // are NOT updated (they are baked into the agent at creation time). + // + // After calling ReloadExtensions the calling extension's code has been + // replaced; the caller should return promptly. + // + // Example: + // + // api.RegisterCommand(ext.CommandDef{ + // Name: "reload", + // Description: "Hot-reload all extensions", + // Execute: func(args string, ctx ext.Context) (string, error) { + // if err := ctx.ReloadExtensions(); err != nil { + // return "", err + // } + // return "Extensions reloaded", nil + // }, + // }) + ReloadExtensions func() error } // --------------------------------------------------------------------------- @@ -586,30 +643,31 @@ type PrintBlockOpts struct { // register typed event handlers, custom tools, and slash commands. type API struct { // Event-specific registration functions (wired by the loader). - onToolCall func(func(ToolCallEvent, Context) *ToolCallResult) - onToolExecStart func(func(ToolExecutionStartEvent, Context)) - onToolExecEnd func(func(ToolExecutionEndEvent, Context)) - onToolResult func(func(ToolResultEvent, Context) *ToolResultResult) - onInput func(func(InputEvent, Context) *InputResult) - onBeforeAgentStart func(func(BeforeAgentStartEvent, Context) *BeforeAgentStartResult) - onAgentStart func(func(AgentStartEvent, Context)) - onAgentEnd func(func(AgentEndEvent, Context)) - onMessageStart func(func(MessageStartEvent, Context)) - onMessageUpdate func(func(MessageUpdateEvent, Context)) - onMessageEnd func(func(MessageEndEvent, Context)) - onSessionStart func(func(SessionStartEvent, Context)) - onSessionShutdown func(func(SessionShutdownEvent, Context)) - registerToolFn func(ToolDef) - registerCmdFn func(CommandDef) - registerToolRendererFn func(ToolRenderConfig) - onModelChange func(func(ModelChangeEvent, Context)) - onContextPrepare func(func(ContextPrepareEvent, Context) *ContextPrepareResult) - onBeforeFork func(func(BeforeForkEvent, Context) *BeforeForkResult) - onBeforeSessionSwitch func(func(BeforeSessionSwitchEvent, Context) *BeforeSessionSwitchResult) - onBeforeCompact func(func(BeforeCompactEvent, Context) *BeforeCompactResult) - onCustomEvent func(name string, handler func(string)) - registerOption func(OptionDef) - registerShortcutFn func(ShortcutDef, func(Context)) + onToolCall func(func(ToolCallEvent, Context) *ToolCallResult) + onToolExecStart func(func(ToolExecutionStartEvent, Context)) + onToolExecEnd func(func(ToolExecutionEndEvent, Context)) + onToolResult func(func(ToolResultEvent, Context) *ToolResultResult) + onInput func(func(InputEvent, Context) *InputResult) + onBeforeAgentStart func(func(BeforeAgentStartEvent, Context) *BeforeAgentStartResult) + onAgentStart func(func(AgentStartEvent, Context)) + onAgentEnd func(func(AgentEndEvent, Context)) + onMessageStart func(func(MessageStartEvent, Context)) + onMessageUpdate func(func(MessageUpdateEvent, Context)) + onMessageEnd func(func(MessageEndEvent, Context)) + onSessionStart func(func(SessionStartEvent, Context)) + onSessionShutdown func(func(SessionShutdownEvent, Context)) + registerToolFn func(ToolDef) + registerCmdFn func(CommandDef) + registerToolRendererFn func(ToolRenderConfig) + onModelChange func(func(ModelChangeEvent, Context)) + onContextPrepare func(func(ContextPrepareEvent, Context) *ContextPrepareResult) + onBeforeFork func(func(BeforeForkEvent, Context) *BeforeForkResult) + onBeforeSessionSwitch func(func(BeforeSessionSwitchEvent, Context) *BeforeSessionSwitchResult) + onBeforeCompact func(func(BeforeCompactEvent, Context) *BeforeCompactResult) + onCustomEvent func(name string, handler func(string)) + registerOption func(OptionDef) + registerShortcutFn func(ShortcutDef, func(Context)) + registerMessageRendererFn func(MessageRendererConfig) } // OnToolCall registers a handler that fires before a tool executes. @@ -777,6 +835,17 @@ func (a *API) RegisterToolRenderer(config ToolRenderConfig) { a.registerToolRendererFn(config) } +// RegisterMessageRenderer registers a named message renderer that extensions +// can invoke via ctx.RenderMessage(name, content). Use this to define +// reusable visual styles for branded output, progress reports, or custom +// notification formats. If multiple extensions register the same name, the +// last one wins. +func (a *API) RegisterMessageRenderer(config MessageRendererConfig) { + if a.registerMessageRendererFn != nil { + a.registerMessageRendererFn(config) + } +} + // --------------------------------------------------------------------------- // Widget types (exposed to Yaegi — concrete structs, no interfaces) // --------------------------------------------------------------------------- @@ -1128,6 +1197,38 @@ type ShortcutDef struct { Description string } +// --------------------------------------------------------------------------- +// Custom message rendering (exposed to Yaegi — concrete structs) +// --------------------------------------------------------------------------- + +// MessageRendererConfig provides a named rendering function that extensions +// can invoke via ctx.RenderMessage(name, content). Unlike tool renderers +// (which hook into the automatic tool result display), message renderers are +// invoked explicitly by extension code for branded status updates, progress +// reports, or any custom visual output. +// +// Example: +// +// api.RegisterMessageRenderer(ext.MessageRendererConfig{ +// Name: "build-status", +// Render: func(content string, width int) string { +// border := strings.Repeat("─", width-4) +// return "╭" + border + "╮\n│ " + content + "\n╰" + border + "╯" +// }, +// }) +type MessageRendererConfig struct { + // Name uniquely identifies this renderer. Used by ctx.RenderMessage + // to look it up at call time. Should be namespaced to avoid collisions + // (e.g. "myext:build-status"). + Name string + + // Render produces the styled output string from raw content. Receives + // the content and the terminal width in columns. Return the final + // ANSI-styled string to print; it will be emitted via tea.Println + // (or plain stdout in non-interactive mode). + Render func(content string, width int) string +} + // --------------------------------------------------------------------------- // Extension options (exposed to Yaegi — concrete structs) // --------------------------------------------------------------------------- diff --git a/internal/extensions/loader.go b/internal/extensions/loader.go index fc3c2389..1c36f361 100644 --- a/internal/extensions/loader.go +++ b/internal/extensions/loader.go @@ -334,6 +334,9 @@ func loadSingleExtension(path string) (*LoadedExtension, error) { registerToolRendererFn: func(config ToolRenderConfig) { ext.ToolRenderers = append(ext.ToolRenderers, config) }, + registerMessageRendererFn: func(config MessageRendererConfig) { + ext.MessageRenderers = append(ext.MessageRenderers, config) + }, onCustomEvent: func(name string, handler func(string)) { if ext.CustomEventHandlers == nil { ext.CustomEventHandlers = make(map[string][]func(string)) diff --git a/internal/extensions/runner.go b/internal/extensions/runner.go index d7d267d0..7ee4c3c9 100644 --- a/internal/extensions/runner.go +++ b/internal/extensions/runner.go @@ -44,6 +44,7 @@ type LoadedExtension struct { Tools []ToolDef Commands []CommandDef ToolRenderers []ToolRenderConfig + MessageRenderers []MessageRendererConfig // named message renderers CustomEventHandlers map[string][]func(string) // inter-extension event bus Options []OptionDef // registered configuration options Shortcuts []ShortcutEntry // global keyboard shortcuts @@ -369,6 +370,51 @@ func (r *Runner) GetToolRenderer(toolName string) *ToolRenderConfig { return nil } +// --------------------------------------------------------------------------- +// Message renderer management +// --------------------------------------------------------------------------- + +// GetMessageRenderer returns the named message renderer, or nil if no +// extension registered a renderer with that name. If multiple extensions +// register the same name, the last one (by load order) wins. +func (r *Runner) GetMessageRenderer(name string) *MessageRendererConfig { + for i := len(r.extensions) - 1; i >= 0; i-- { + for j := len(r.extensions[i].MessageRenderers) - 1; j >= 0; j-- { + if r.extensions[i].MessageRenderers[j].Name == name { + config := r.extensions[i].MessageRenderers[j] + return &config + } + } + } + return nil +} + +// --------------------------------------------------------------------------- +// Hot-reload +// --------------------------------------------------------------------------- + +// Reload replaces the loaded extensions with a fresh set and clears all +// dynamic state (widgets, status, header/footer, editor, visibility, +// disabled tools, custom event subscriptions). Option overrides are +// preserved across reloads since they represent user intent. +// +// The caller is responsible for emitting SessionShutdown before calling +// Reload and SessionStart after. +func (r *Runner) Reload(exts []LoadedExtension) { + r.mu.Lock() + defer r.mu.Unlock() + r.extensions = exts + r.widgets = nil + r.statusEntries = nil + r.header = nil + r.footer = nil + r.customEditor = nil + r.uiVisibility = nil + r.disabledTools = nil + r.customEventSubs = nil + // optionOverrides are intentionally preserved. +} + // --------------------------------------------------------------------------- // Inter-extension event bus // --------------------------------------------------------------------------- diff --git a/internal/extensions/symbols.go b/internal/extensions/symbols.go index 17896da4..21b5ceaf 100644 --- a/internal/extensions/symbols.go +++ b/internal/extensions/symbols.go @@ -77,6 +77,9 @@ func Symbols() interp.Exports { // Tool renderer types "ToolRenderConfig": reflect.ValueOf((*ToolRenderConfig)(nil)), + // Message renderer types + "MessageRendererConfig": reflect.ValueOf((*MessageRendererConfig)(nil)), + // Editor interceptor types "EditorKeyActionType": reflect.ValueOf((*EditorKeyActionType)(nil)), "EditorKeyPassthrough": reflect.ValueOf(EditorKeyPassthrough), diff --git a/internal/ui/model.go b/internal/ui/model.go index 25e0cc0d..48f584d1 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -289,6 +289,11 @@ type AppModelOptions struct { // Handlers are called in a goroutine to avoid blocking the TUI event // loop. May be nil if no extensions are loaded. GetGlobalShortcuts func() map[string]func() + + // GetExtensionCommands, if non-nil, returns the current extension + // commands. Called on WidgetUpdateEvent to refresh the command list + // after an extension hot-reload. May be nil if no extensions loaded. + GetExtensionCommands func() []ExtensionCommand } // AppModel is the root Bubble Tea model for the interactive TUI. It owns the @@ -414,6 +419,10 @@ type AppModel struct { // May be nil if no extensions are loaded. getGlobalShortcuts func() map[string]func() + // getExtensionCommands returns the current extension commands. Used + // to refresh the command list after an extension hot-reload. May be nil. + getExtensionCommands func() []ExtensionCommand + // prompt holds the state of an active interactive prompt overlay. Nil // when no prompt is active. Managed by updatePromptState(). prompt *promptOverlay @@ -532,6 +541,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel { m.emitBeforeFork = opts.EmitBeforeFork m.emitBeforeSessionSwitch = opts.EmitBeforeSessionSwitch m.getGlobalShortcuts = opts.GetGlobalShortcuts + m.getExtensionCommands = opts.GetExtensionCommands // Store context/skills metadata and tool counts for startup display. m.contextPaths = opts.ContextPaths @@ -1053,6 +1063,31 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // latest widget state on the next render. m.distributeHeight() + // Refresh extension commands (e.g. after hot-reload). The callback + // returns the current set from the runner which may have changed. + if m.getExtensionCommands != nil { + newCmds := m.getExtensionCommands() + m.extensionCommands = newCmds + if ic, ok := m.input.(*InputComponent); ok { + // Remove old extension commands and add fresh ones. + var builtins []SlashCommand + for _, sc := range ic.commands { + if sc.Category != "Extensions" { + builtins = append(builtins, sc) + } + } + for _, ec := range newCmds { + builtins = append(builtins, SlashCommand{ + Name: ec.Name, + Description: ec.Description, + Category: "Extensions", + Complete: ec.Complete, + }) + } + ic.commands = builtins + } + } + case app.EditorTextSetEvent: // Extension wants to pre-fill the input editor with text. if ic, ok := m.input.(*InputComponent); ok { diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index 98e65d69..ace15894 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -570,6 +570,48 @@ func (m *Kit) EmitExtensionCustomEvent(name, data string) { } } +// GetExtensionMessageRenderer returns the named message renderer, or nil +// if no extension registered a renderer with that name. +func (m *Kit) GetExtensionMessageRenderer(name string) *extensions.MessageRendererConfig { + if m.extRunner == nil { + return nil + } + return m.extRunner.GetMessageRenderer(name) +} + +// ReloadExtensions hot-reloads all extensions from disk. Event handlers, +// commands, renderers, and shortcuts update immediately. Extension-defined +// tools are NOT updated (they are baked into the agent at creation time). +func (m *Kit) ReloadExtensions() error { + if m.extRunner == nil { + return fmt.Errorf("no extensions loaded") + } + + // Emit shutdown to old extensions. + if m.extRunner.HasHandlers(extensions.SessionShutdown) { + _, _ = m.extRunner.Emit(extensions.SessionShutdownEvent{}) + } + + // Re-load from disk. + extraPaths := viper.GetStringSlice("extension") + loaded, err := extensions.LoadExtensions(extraPaths) + if err != nil { + return fmt.Errorf("reloading extensions: %w", err) + } + + // Swap extensions on the runner (clears dynamic state). + m.extRunner.Reload(loaded) + + // Re-set context and emit SessionStart. + ctx := m.extRunner.GetContext() + m.extRunner.SetContext(ctx) + if m.extRunner.HasHandlers(extensions.SessionStart) { + _, _ = m.extRunner.Emit(extensions.SessionStartEvent{SessionID: ctx.SessionID}) + } + + return nil +} + // ExecuteCompletion makes a standalone LLM completion call for extensions. // When req.Model is empty the current agent model is reused (no provider // creation overhead). When req.Model is set a temporary provider is created,