mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
feat: add TUI suspend, custom message rendering, and extension hot-reload
- ctx.SuspendTUI(callback): releases terminal for interactive subprocesses (vim, shell, htop), automatically restores TUI when callback returns. Uses BubbleTea v2 ReleaseTerminal/RestoreTerminal. - api.RegisterMessageRenderer(config) + ctx.RenderMessage(name, content): named render functions for branded/styled extension output. Renderers receive content and terminal width, return ANSI-styled strings. - ctx.ReloadExtensions(): hot-reloads all extensions from disk. Emits SessionShutdown to old extensions, reloads source, emits SessionStart to new. Event handlers, commands, renderers, shortcuts update immediately. TUI command list refreshes via WidgetUpdateEvent. Extension tools are NOT updated (baked into agent at creation, documented limitation). New example extensions: interactive-shell.go, branded-output.go, dev-reload.go
This commit is contained in:
+34
-5
@@ -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.
|
||||
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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 <file> — opens $EDITOR (or vi) to edit a file
|
||||
// /shell — drops into an interactive shell session
|
||||
// /run <cmd> — 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 <file>")
|
||||
}
|
||||
|
||||
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 <command>")
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
+125
-24
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user