From 9e1df38836f709b70b4b8bbb465343f55876b855 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Mon, 2 Mar 2026 19:04:37 +0300 Subject: [PATCH] feat: add keyboard shortcuts, tool context, and ToolCallEvent source field - RegisterShortcut(ShortcutDef, handler) for global keyboard shortcuts that fire across all non-modal app states (after ctrl+c, before component dispatch). Handlers run in goroutines for safe blocking calls. - ToolContext with IsCancelled/OnProgress for rich tool execution; ExecuteWithContext on ToolDef takes priority over simple Execute. - Source field on ToolCallEvent (currently "llm", forward-compatible with future user-initiated tool calls). - Fix missing //go:build ignore on context-inject.go. - Update plan-mode.go to register ctrl+alt+p shortcut. --- cmd/root.go | 22 +++-- examples/extensions/context-inject.go | 2 + examples/extensions/plan-mode.go | 11 ++- internal/extensions/api.go | 53 +++++++++++- internal/extensions/loader.go | 3 + internal/extensions/runner.go | 59 +++++++++++-- internal/extensions/symbols.go | 2 + internal/extensions/wrapper.go | 35 +++++++- internal/extensions/wrapper_test.go | 117 +++++++++++++++++++++++++- internal/kitsetup/setup.go | 2 +- internal/ui/model.go | 26 ++++++ pkg/kit/kit.go | 24 ++++++ 12 files changed, 334 insertions(+), 22 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 10189350..2386db4a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -509,6 +509,16 @@ func beforeSessionSwitchProviderForUI(k *kit.Kit) func(string) (bool, string) { return k.EmitBeforeSessionSwitch } +// globalShortcutsProviderForUI returns a callback that queries the extension +// runner for registered keyboard shortcuts. Returns nil if extensions are +// disabled — the UI treats nil as "no shortcuts". +func globalShortcutsProviderForUI(k *kit.Kit) func() map[string]func() { + if !k.HasExtensions() { + return nil + } + return k.GetExtensionShortcuts +} + func runNormalMode(ctx context.Context) error { // Validate flag combinations if quietFlag && promptFlag == "" { @@ -862,10 +872,11 @@ func runNormalMode(ctx context.Context) error { getStatusBarEntries := statusBarProviderForUI(kitInstance) emitBeforeFork := beforeForkProviderForUI(kitInstance) emitBeforeSessionSwitch := beforeSessionSwitchProviderForUI(kitInstance) + getGlobalShortcuts := globalShortcutsProviderForUI(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) + 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) } // Quiet mode is not allowed in interactive mode @@ -873,7 +884,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) + 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) } // runNonInteractiveModeApp executes a single prompt via the app layer and exits, @@ -886,7 +897,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)) 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()) error { if jsonOutput { // JSON mode: no intermediate display, structured JSON output. result, err := appInstance.RunOnceResult(ctx, prompt) @@ -924,7 +935,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) + 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 nil @@ -1018,7 +1029,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)) 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()) error { // Determine terminal size; fall back gracefully. termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd())) if err != nil || termWidth == 0 { @@ -1050,6 +1061,7 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN GetStatusBarEntries: getStatusBarEntries, EmitBeforeFork: emitBeforeFork, EmitBeforeSessionSwitch: emitBeforeSessionSwitch, + GetGlobalShortcuts: getGlobalShortcuts, }) // Print startup info to stdout before Bubble Tea takes over the screen. diff --git a/examples/extensions/context-inject.go b/examples/extensions/context-inject.go index d82e4ffe..8b47873a 100644 --- a/examples/extensions/context-inject.go +++ b/examples/extensions/context-inject.go @@ -1,3 +1,5 @@ +//go:build ignore + // context-inject.go — Injects context from a local file into every LLM turn. // // Reads a context file (default: .kit/context.md) and prepends it as a system diff --git a/examples/extensions/plan-mode.go b/examples/extensions/plan-mode.go index fd8e2ef7..897df45e 100644 --- a/examples/extensions/plan-mode.go +++ b/examples/extensions/plan-mode.go @@ -34,10 +34,19 @@ func Init(api ext.API) { Default: "false", }) + // ctrl+alt+p — global shortcut to toggle plan mode. + api.RegisterShortcut(ext.ShortcutDef{ + Key: "ctrl+alt+p", + Description: "Toggle plan/explore mode", + }, func(ctx ext.Context) { + planActive = !planActive + applyMode(ctx, planActive, readOnlyTools) + }) + // /plan — toggle plan mode on or off. api.RegisterCommand(ext.CommandDef{ Name: "plan", - Description: "Toggle plan/explore mode (read-only tools)", + Description: "Toggle plan/explore mode (ctrl+alt+p)", Execute: func(args string, ctx ext.Context) (string, error) { planActive = !planActive applyMode(ctx, planActive, readOnlyTools) diff --git a/internal/extensions/api.go b/internal/extensions/api.go index 9980c0b0..8bacecfa 100644 --- a/internal/extensions/api.go +++ b/internal/extensions/api.go @@ -609,6 +609,7 @@ type API struct { onBeforeCompact func(func(BeforeCompactEvent, Context) *BeforeCompactResult) onCustomEvent func(name string, handler func(string)) registerOption func(OptionDef) + registerShortcutFn func(ShortcutDef, func(Context)) } // OnToolCall registers a handler that fires before a tool executes. @@ -727,6 +728,18 @@ func (a *API) RegisterOption(opt OptionDef) { a.registerOption(opt) } +// RegisterShortcut registers a global keyboard shortcut that fires across +// all app states except modal prompts/overlays. Use modifier combinations +// like "ctrl+p", "alt+t", or "f1" — avoid bare characters that conflict +// with text input. If multiple extensions register the same key, the last +// registration wins. The handler runs in a goroutine so it can call blocking +// APIs like PromptSelect without stalling the TUI event loop. +func (a *API) RegisterShortcut(def ShortcutDef, handler func(Context)) { + if a.registerShortcutFn != nil { + a.registerShortcutFn(def, handler) + } +} + // OnCustomEvent registers a handler for a custom inter-extension event. // The handler receives the data string published by EmitCustomEvent. // Multiple handlers can subscribe to the same event name; they execute @@ -1062,12 +1075,31 @@ type ToolInfo struct { // ToolDef / CommandDef // --------------------------------------------------------------------------- +// ToolContext provides runtime context to a tool's ExecuteWithContext handler. +// It allows tools to check for cancellation and report progress while running. +type ToolContext struct { + // IsCancelled returns true when the tool's execution has been cancelled + // (e.g. the user interrupted the agent or the request timed out). + // Long-running tools should poll this periodically and return early. + IsCancelled func() bool + // OnProgress sends a progress message that is displayed in the TUI + // while the tool is executing. Useful for long-running operations + // that want to show incremental status. + OnProgress func(text string) +} + // ToolDef describes a custom tool registered by an extension. type ToolDef struct { Name string Description string Parameters string // JSON Schema string - Execute func(input string) (string, error) + // Execute is the simple handler — receives JSON input, returns text result. + // Use this for tools that don't need cancellation or progress reporting. + Execute func(input string) (string, error) + // ExecuteWithContext is the rich handler — receives JSON input plus a + // ToolContext that provides cancellation checking and progress reporting. + // If both Execute and ExecuteWithContext are set, ExecuteWithContext wins. + ExecuteWithContext func(input string, tc ToolContext) (string, error) } // CommandDef describes a slash command registered by an extension. @@ -1081,6 +1113,21 @@ type CommandDef struct { Complete func(prefix string, ctx Context) []string } +// --------------------------------------------------------------------------- +// Keyboard shortcuts (exposed to Yaegi — concrete structs) +// --------------------------------------------------------------------------- + +// ShortcutDef describes a global keyboard shortcut registered by an extension. +// Shortcuts fire across all app states except modal prompts/overlays. +// Use modifier combinations (e.g., "ctrl+p", "alt+t", "f1") — avoid bare +// characters like "a" or "x" which conflict with text input. +type ShortcutDef struct { + // Key is the key binding (e.g., "ctrl+p", "alt+t", "f1", "ctrl+shift+s"). + Key string + // Description explains what the shortcut does (shown in /shortcuts help). + Description string +} + // --------------------------------------------------------------------------- // Extension options (exposed to Yaegi — concrete structs) // --------------------------------------------------------------------------- @@ -1250,6 +1297,10 @@ type ToolCallEvent struct { ToolName string ToolCallID string Input string // JSON-encoded tool parameters + // Source indicates who initiated the tool call. + // Currently always "llm" (all tool calls originate from the LLM agent loop). + // Future user-initiated tool features may set this to "user". + Source string } func (e ToolCallEvent) Type() EventType { return ToolCall } diff --git a/internal/extensions/loader.go b/internal/extensions/loader.go index 204cbeaf..fc3c2389 100644 --- a/internal/extensions/loader.go +++ b/internal/extensions/loader.go @@ -343,6 +343,9 @@ func loadSingleExtension(path string) (*LoadedExtension, error) { registerOption: func(opt OptionDef) { ext.Options = append(ext.Options, opt) }, + registerShortcutFn: func(def ShortcutDef, handler func(Context)) { + ext.Shortcuts = append(ext.Shortcuts, ShortcutEntry{Def: def, Handler: handler}) + }, } // Call Init — the extension registers its handlers, tools, commands. diff --git a/internal/extensions/runner.go b/internal/extensions/runner.go index 577d7e1f..d7d267d0 100644 --- a/internal/extensions/runner.go +++ b/internal/extensions/runner.go @@ -29,6 +29,12 @@ type Runner struct { mu sync.RWMutex } +// ShortcutEntry pairs a shortcut definition with its handler. +type ShortcutEntry struct { + Def ShortcutDef + Handler func(Context) +} + // LoadedExtension represents a single extension that has been discovered, // loaded, and initialised. It holds the registered handlers and any custom // tools, commands, or tool renderers the extension provided. @@ -40,6 +46,7 @@ type LoadedExtension struct { ToolRenderers []ToolRenderConfig CustomEventHandlers map[string][]func(string) // inter-extension event bus Options []OptionDef // registered configuration options + Shortcuts []ShortcutEntry // global keyboard shortcuts } // NewRunner creates a Runner from a set of loaded extensions. @@ -55,6 +62,13 @@ func (r *Runner) SetContext(ctx Context) { r.ctx = ctx } +// GetContext returns a snapshot of the current runtime context. Thread-safe. +func (r *Runner) GetContext() Context { + r.mu.RLock() + defer r.mu.RUnlock() + return r.ctx +} + // HasHandlers returns true if any loaded extension has at least one handler // registered for the given event type. func (r *Runner) HasHandlers(event EventType) bool { @@ -131,13 +145,6 @@ func (r *Runner) RegisteredCommands() []CommandDef { return cmds } -// GetContext returns the current runtime context. Thread-safe. -func (r *Runner) GetContext() Context { - r.mu.RLock() - defer r.mu.RUnlock() - return r.ctx -} - // Extensions returns the loaded extensions for inspection (e.g. CLI list). func (r *Runner) Extensions() []LoadedExtension { return r.extensions @@ -506,6 +513,44 @@ func (r *Runner) RegisteredOptions() []OptionDef { return opts } +// --------------------------------------------------------------------------- +// Keyboard shortcuts +// --------------------------------------------------------------------------- + +// GetShortcuts returns all registered keyboard shortcuts as a map of +// key binding → handler. If multiple extensions register the same key, +// the last registration wins. Thread-safe (reads extension list which is +// immutable after loading). +func (r *Runner) GetShortcuts() map[string]ShortcutEntry { + result := make(map[string]ShortcutEntry) + for i := range r.extensions { + for _, sc := range r.extensions[i].Shortcuts { + result[sc.Def.Key] = sc + } + } + if len(result) == 0 { + return nil + } + return result +} + +// RegisteredShortcuts returns all shortcut definitions from all loaded +// extensions. Used for help/listing commands. +func (r *Runner) RegisteredShortcuts() []ShortcutDef { + var defs []ShortcutDef + seen := make(map[string]bool) + // Iterate in reverse so last registration for a key wins. + for i := len(r.extensions) - 1; i >= 0; i-- { + for _, sc := range r.extensions[i].Shortcuts { + if !seen[sc.Def.Key] { + seen[sc.Def.Key] = true + defs = append(defs, sc.Def) + } + } + } + return defs +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/internal/extensions/symbols.go b/internal/extensions/symbols.go index ee061ff0..17896da4 100644 --- a/internal/extensions/symbols.go +++ b/internal/extensions/symbols.go @@ -23,6 +23,8 @@ func Symbols() interp.Exports { "API": reflect.ValueOf((*API)(nil)), "Context": reflect.ValueOf((*Context)(nil)), "ToolDef": reflect.ValueOf((*ToolDef)(nil)), + "ToolContext": reflect.ValueOf((*ToolContext)(nil)), + "ShortcutDef": reflect.ValueOf((*ShortcutDef)(nil)), "CommandDef": reflect.ValueOf((*CommandDef)(nil)), "PrintBlockOpts": reflect.ValueOf((*PrintBlockOpts)(nil)), diff --git a/internal/extensions/wrapper.go b/internal/extensions/wrapper.go index e581e97e..c16de0a8 100644 --- a/internal/extensions/wrapper.go +++ b/internal/extensions/wrapper.go @@ -29,10 +29,12 @@ func WrapToolsWithExtensions(tools []fantasy.AgentTool, runner *Runner) []fantas // ExtensionToolsAsFantasy converts ToolDef values registered by extensions // into fantasy.AgentTool implementations so the LLM can invoke them. -func ExtensionToolsAsFantasy(defs []ToolDef) []fantasy.AgentTool { +// The runner is optional; if provided, ToolContext.OnProgress routes +// progress messages through the runner's Print function. +func ExtensionToolsAsFantasy(defs []ToolDef, runner *Runner) []fantasy.AgentTool { tools := make([]fantasy.AgentTool, 0, len(defs)) for _, def := range defs { - tools = append(tools, &extensionTool{def: def}) + tools = append(tools, &extensionTool{def: def, runner: runner}) } return tools } @@ -66,6 +68,7 @@ func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.T ToolName: toolName, ToolCallID: call.ID, Input: call.Input, + Source: "llm", }) if r, ok := result.(ToolCallResult); ok && r.Block { reason := r.Reason @@ -117,6 +120,7 @@ func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.T type extensionTool struct { def ToolDef + runner *Runner // optional; enables ToolContext.OnProgress providerOptions fantasy.ProviderOptions } @@ -130,8 +134,31 @@ func (t *extensionTool) Info() fantasy.ToolInfo { func (t *extensionTool) ProviderOptions() fantasy.ProviderOptions { return t.providerOptions } func (t *extensionTool) SetProviderOptions(o fantasy.ProviderOptions) { t.providerOptions = o } -func (t *extensionTool) Run(_ context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) { - result, err := t.def.Execute(call.Input) +func (t *extensionTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) { + var result string + var err error + + if t.def.ExecuteWithContext != nil { + tc := ToolContext{ + IsCancelled: func() bool { + return ctx.Err() != nil + }, + OnProgress: func(text string) { + if t.runner != nil { + t.runner.mu.RLock() + printFn := t.runner.ctx.Print + t.runner.mu.RUnlock() + if printFn != nil { + printFn(text) + } + } + }, + } + result, err = t.def.ExecuteWithContext(call.Input, tc) + } else { + result, err = t.def.Execute(call.Input) + } + if err != nil { return fantasy.NewTextErrorResponse(err.Error()), err } diff --git a/internal/extensions/wrapper_test.go b/internal/extensions/wrapper_test.go index c3309f2d..9b1cf430 100644 --- a/internal/extensions/wrapper_test.go +++ b/internal/extensions/wrapper_test.go @@ -107,6 +107,22 @@ func TestWrappedTool_NormalExecution(t *testing.T) { } } +func TestWrappedTool_SourceField(t *testing.T) { + var gotSource string + r := makeRunner(makeHandlerExt("source.go", map[EventType][]HandlerFunc{ + ToolCall: {func(e Event, c Context) Result { + gotSource = e.(ToolCallEvent).Source + return nil + }}, + })) + + tools := WrapToolsWithExtensions([]fantasy.AgentTool{newMockTool("bash")}, r) + _, _ = tools[0].Run(context.Background(), fantasy.ToolCall{ID: "1", Input: "{}"}) + if gotSource != "llm" { + t.Errorf("expected Source='llm', got %q", gotSource) + } +} + func TestWrappedTool_BlockExecution(t *testing.T) { var toolRan bool r := makeRunner(makeHandlerExt("blocker.go", map[EventType][]HandlerFunc{ @@ -186,7 +202,7 @@ func TestExtensionToolsAsFantasy(t *testing.T) { }, } - tools := ExtensionToolsAsFantasy(defs) + tools := ExtensionToolsAsFantasy(defs, nil) if len(tools) != 1 { t.Fatalf("expected 1 tool, got %d", len(tools)) } @@ -216,7 +232,7 @@ func TestExtensionTool_Error(t *testing.T) { }, } - tools := ExtensionToolsAsFantasy(defs) + tools := ExtensionToolsAsFantasy(defs, nil) resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: "x"}) if err == nil { t.Error("expected error") @@ -226,9 +242,104 @@ func TestExtensionTool_Error(t *testing.T) { } } +func TestExtensionTool_ExecuteWithContext(t *testing.T) { + var gotCancelled bool + var gotProgress []string + + defs := []ToolDef{ + { + Name: "rich", + ExecuteWithContext: func(input string, tc ToolContext) (string, error) { + gotCancelled = tc.IsCancelled() + tc.OnProgress("step 1") + tc.OnProgress("step 2") + return "done: " + input, nil + }, + }, + } + + // Without runner, OnProgress is a no-op. + tools := ExtensionToolsAsFantasy(defs, nil) + resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: "test"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Content != "done: test" { + t.Errorf("expected 'done: test', got %q", resp.Content) + } + if gotCancelled { + t.Error("expected IsCancelled=false for non-cancelled context") + } + + // With runner, OnProgress routes through Print. + runner := NewRunner(nil) + runner.SetContext(Context{ + Print: func(text string) { gotProgress = append(gotProgress, text) }, + }) + defs2 := []ToolDef{ + { + Name: "rich2", + ExecuteWithContext: func(input string, tc ToolContext) (string, error) { + tc.OnProgress("hello") + return "ok", nil + }, + }, + } + tools2 := ExtensionToolsAsFantasy(defs2, runner) + _, err = tools2[0].Run(context.Background(), fantasy.ToolCall{Input: ""}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(gotProgress) != 1 || gotProgress[0] != "hello" { + t.Errorf("expected [hello], got %v", gotProgress) + } +} + +func TestExtensionTool_ExecuteWithContextPriority(t *testing.T) { + // When both Execute and ExecuteWithContext are set, ExecuteWithContext wins. + defs := []ToolDef{ + { + Name: "both", + Execute: func(input string) (string, error) { return "simple", nil }, + ExecuteWithContext: func(input string, tc ToolContext) (string, error) { + return "rich", nil + }, + }, + } + tools := ExtensionToolsAsFantasy(defs, nil) + resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: ""}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Content != "rich" { + t.Errorf("expected 'rich' (ExecuteWithContext), got %q", resp.Content) + } +} + +func TestExtensionTool_CancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + var sawCancelled bool + defs := []ToolDef{ + { + Name: "checkcancel", + ExecuteWithContext: func(input string, tc ToolContext) (string, error) { + sawCancelled = tc.IsCancelled() + return "ok", nil + }, + }, + } + tools := ExtensionToolsAsFantasy(defs, nil) + _, _ = tools[0].Run(ctx, fantasy.ToolCall{Input: ""}) + if !sawCancelled { + t.Error("expected IsCancelled=true for cancelled context") + } +} + func TestExtensionTool_ProviderOptions(t *testing.T) { defs := []ToolDef{{Name: "test", Execute: func(string) (string, error) { return "", nil }}} - tools := ExtensionToolsAsFantasy(defs) + tools := ExtensionToolsAsFantasy(defs, nil) // Initially nil. opts := tools[0].ProviderOptions() diff --git a/internal/kitsetup/setup.go b/internal/kitsetup/setup.go index deb0066c..b0a1040c 100644 --- a/internal/kitsetup/setup.go +++ b/internal/kitsetup/setup.go @@ -186,7 +186,7 @@ func loadExtensions() (*extensions.Runner, extensionCreationOpts, error) { return extensions.WrapToolsWithExtensions(tools, runner) } - extTools := extensions.ExtensionToolsAsFantasy(runner.RegisteredTools()) + extTools := extensions.ExtensionToolsAsFantasy(runner.RegisteredTools(), runner) return runner, extensionCreationOpts{ toolWrapper: wrapper, diff --git a/internal/ui/model.go b/internal/ui/model.go index d27f9b24..25e0cc0d 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -283,6 +283,12 @@ type AppModelOptions struct { // to a new session branch (e.g. /new, /clear). Returns (cancelled, // reason). May be nil if no extensions are loaded. EmitBeforeSessionSwitch func(reason string) (bool, string) + + // GetGlobalShortcuts, if non-nil, returns extension-registered global + // keyboard shortcuts. Keys are binding strings (e.g., "ctrl+p"). + // 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() } // AppModel is the root Bubble Tea model for the interactive TUI. It owns the @@ -404,6 +410,10 @@ type AppModel struct { // Returns (cancelled, reason). May be nil if no extensions are loaded. emitBeforeSessionSwitch func(reason string) (bool, string) + // getGlobalShortcuts returns extension-registered keyboard shortcuts. + // May be nil if no extensions are loaded. + getGlobalShortcuts func() map[string]func() + // prompt holds the state of an active interactive prompt overlay. Nil // when no prompt is active. Managed by updatePromptState(). prompt *promptOverlay @@ -521,6 +531,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel { m.getStatusBarEntries = opts.GetStatusBarEntries m.emitBeforeFork = opts.EmitBeforeFork m.emitBeforeSessionSwitch = opts.EmitBeforeSessionSwitch + m.getGlobalShortcuts = opts.GetGlobalShortcuts // Store context/skills metadata and tool counts for startup display. m.contextPaths = opts.ContextPaths @@ -757,6 +768,21 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } + // Check extension-registered global keyboard shortcuts. These fire + // in all app states except modal prompts/overlays (which return early + // above). Matched shortcuts are consumed — the key does not propagate + // to child components. + if m.getGlobalShortcuts != nil { + if shortcuts := m.getGlobalShortcuts(); shortcuts != nil { + if handler, ok := shortcuts[msg.String()]; ok { + // Run in goroutine so blocking extension calls + // (PromptSelect, etc.) don't stall the event loop. + go handler() + return m, tea.Batch(cmds...) + } + } + } + // Route to tree selector when active. if m.state == stateTreeSelector && m.treeSelector != nil { updated, cmd := m.treeSelector.Update(msg) diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index 4298f779..98e65d69 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -403,6 +403,30 @@ func (m *Kit) GetExtensionStatusEntries() []extensions.StatusBarEntry { return m.extRunner.GetStatusEntries() } +// GetExtensionShortcuts returns a map of key bindings to handler functions +// from all loaded extensions. Returns nil if no shortcuts are registered or +// extensions are disabled. Handlers are closures that capture the runner's +// current context, so they can call Print/SetStatus/etc. +func (m *Kit) GetExtensionShortcuts() map[string]func() { + if m.extRunner == nil { + return nil + } + entries := m.extRunner.GetShortcuts() + if entries == nil { + return nil + } + result := make(map[string]func(), len(entries)) + for key, entry := range entries { + h := entry.Handler + r := m.extRunner + result[key] = func() { + ctx := r.GetContext() + h(ctx) + } + } + return result +} + // GetExtensionToolInfos returns information about all tools available to the // agent, including enabled/disabled status from SetActiveTools. Each tool is // categorized by source: "core", "mcp", or "extension".