From 864230bd0a2ed335187a6e7973cf3832cd29b052 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Sat, 28 Feb 2026 17:46:41 +0300 Subject: [PATCH] feat: add editor interceptor system for extensions Extensions can now intercept key events and wrap the editor's rendered output via ctx.SetEditor/ctx.ResetEditor, enabling vim-like modal editing, custom key bindings, and visual decorators. Key fixes during development: - Yaegi requires closure wrappers for struct function fields (bare function references return zero values across the interpreter boundary) - SetEditor/ResetEditor use async NotifyWidgetUpdate to avoid deadlocking BubbleTea's event loop when called from HandleKey callbacks - distributeHeight now uses renderInput() to account for interceptor Render wrapper in height calculations --- cmd/root.go | 99 ++++++++++--- examples/extensions/custom-editor-demo.go | 136 ++++++++++++++++++ internal/extensions/api.go | 109 +++++++++++++++ internal/extensions/runner.go | 47 ++++++- internal/extensions/symbols.go | 9 ++ internal/ui/model.go | 163 ++++++++++++++++++++-- pkg/kit/kit.go | 25 ++++ 7 files changed, 551 insertions(+), 37 deletions(-) create mode 100644 examples/extensions/custom-editor-demo.go diff --git a/cmd/root.go b/cmd/root.go index b1003652..e780716a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -375,6 +375,45 @@ func toolRendererProviderForUI(k *kit.Kit) func(string) *ui.ToolRendererData { } } +// editorInterceptorProviderForUI returns a function that converts the +// extension editor interceptor to a *ui.EditorInterceptor for the TUI. +// Returns nil if extensions are disabled, which is safe — the UI treats a +// nil GetEditorInterceptor as "no interceptor". +func editorInterceptorProviderForUI(k *kit.Kit) func() *ui.EditorInterceptor { + if !k.HasExtensions() { + return nil + } + return func() *ui.EditorInterceptor { + config := k.GetExtensionEditor() + if config == nil { + return nil + } + var handleKey func(string, string) ui.EditorKeyAction + if config.HandleKey != nil { + extHandleKey := config.HandleKey + handleKey = func(key, text string) ui.EditorKeyAction { + r := extHandleKey(key, text) + return ui.EditorKeyAction{ + Type: ui.EditorKeyActionType(r.Type), + RemappedKey: r.RemappedKey, + SubmitText: r.SubmitText, + } + } + } + var render func(int, string) string + if config.Render != nil { + extRender := config.Render + render = func(width int, defaultContent string) string { + return extRender(width, defaultContent) + } + } + return &ui.EditorInterceptor{ + HandleKey: handleKey, + Render: render, + } + } +} + // footerProviderForUI returns a function that converts the extension footer // to a *ui.WidgetData for the TUI. Returns nil if extensions are disabled, // which is safe — the UI treats a nil GetFooter as "no footer". @@ -593,6 +632,18 @@ func runNormalMode(ctx context.Context) error { } return extensions.PromptInputResult{Value: resp.Value} }, + SetEditor: func(config extensions.EditorConfig) { + kitInstance.SetExtensionEditor(config) + // Use a goroutine for NotifyWidgetUpdate because this may be + // called from within an editor HandleKey callback, which runs + // synchronously inside BubbleTea's Update(). Calling prog.Send() + // directly from Update() deadlocks the event loop. + go appInstance.NotifyWidgetUpdate() + }, + ResetEditor: func() { + kitInstance.ResetExtensionEditor() + go appInstance.NotifyWidgetUpdate() + }, ShowOverlay: func(config extensions.OverlayConfig) extensions.OverlayResult { ch := make(chan app.OverlayResponse, 1) appInstance.SendOverlayRequest(app.OverlayRequestEvent{ @@ -647,10 +698,11 @@ func runNormalMode(ctx context.Context) error { getHeader := headerProviderForUI(kitInstance) getFooter := footerProviderForUI(kitInstance) getToolRenderer := toolRendererProviderForUI(kitInstance) + getEditorInterceptor := editorInterceptorProviderForUI(kitInstance) // Check if running in non-interactive mode if promptFlag != "" { - return runNonInteractiveModeApp(ctx, appInstance, cli, promptFlag, quietFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer) + return runNonInteractiveModeApp(ctx, appInstance, cli, promptFlag, quietFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor) } // Quiet mode is not allowed in interactive mode @@ -658,7 +710,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) + return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor) } // runNonInteractiveModeApp executes a single prompt via the app layer and exits, @@ -671,7 +723,7 @@ func runNormalMode(ctx context.Context) error { // // When --no-exit is set, after the prompt completes the interactive BubbleTea // TUI is started so the user can continue the conversation. -func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData) error { +func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor) error { if quiet { // Quiet mode: no intermediate display, just print final response. if err := appInstance.RunOnce(ctx, prompt); err != nil { @@ -697,7 +749,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) + return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor) } return nil @@ -714,7 +766,7 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui // 4. Calls program.Run() which blocks until the user quits (Ctrl+C or /quit). // // SetupCLI is not used for interactive mode; the TUI (AppModel) handles its own rendering. -func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData) 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) error { // Determine terminal size; fall back gracefully. termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd())) if err != nil || termWidth == 0 { @@ -723,24 +775,25 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN } appModel := ui.NewAppModel(appInstance, ui.AppModelOptions{ - CompactMode: viper.GetBool("compact"), - ModelName: modelName, - ProviderName: providerName, - LoadingMessage: loadingMessage, - Width: termWidth, - Height: termHeight, - ServerNames: serverNames, - ToolNames: toolNames, - MCPToolCount: mcpToolCount, - ExtensionToolCount: extensionToolCount, - UsageTracker: usageTracker, - ExtensionCommands: extCommands, - ContextPaths: contextPaths, - SkillItems: skillItems, - GetWidgets: getWidgets, - GetHeader: getHeader, - GetFooter: getFooter, - GetToolRenderer: getToolRenderer, + CompactMode: viper.GetBool("compact"), + ModelName: modelName, + ProviderName: providerName, + LoadingMessage: loadingMessage, + Width: termWidth, + Height: termHeight, + ServerNames: serverNames, + ToolNames: toolNames, + MCPToolCount: mcpToolCount, + ExtensionToolCount: extensionToolCount, + UsageTracker: usageTracker, + ExtensionCommands: extCommands, + ContextPaths: contextPaths, + SkillItems: skillItems, + GetWidgets: getWidgets, + GetHeader: getHeader, + GetFooter: getFooter, + GetToolRenderer: getToolRenderer, + GetEditorInterceptor: getEditorInterceptor, }) // Print startup info to stdout before Bubble Tea takes over the screen. diff --git a/examples/extensions/custom-editor-demo.go b/examples/extensions/custom-editor-demo.go new file mode 100644 index 00000000..3a72cc56 --- /dev/null +++ b/examples/extensions/custom-editor-demo.go @@ -0,0 +1,136 @@ +//go:build ignore + +package main + +import ( + "fmt" + "strings" + + "kit/ext" +) + +// normalMode tracks whether the vim-like normal mode is active. +// When false, all keys pass through to the default editor (insert mode). +var normalMode bool + +// savedCtx holds the extension context for use in the HandleKey callback. +var savedCtx ext.Context + +// Init demonstrates the editor interceptor system. Extensions can intercept +// key events before they reach the built-in editor and wrap the editor's +// rendered output. This example implements a simple vim-like modal editor +// with normal/insert mode switching. +// +// Slash commands: +// - /vim — toggle vim mode (normal ↔ insert) +// - /vim-info — show current editor mode +func Init(api ext.API) { + // /vim — toggle vim-like normal/insert mode. + api.RegisterCommand(ext.CommandDef{ + Name: "vim", + Description: "Toggle vim-like normal/insert mode", + Execute: func(args string, ctx ext.Context) (string, error) { + savedCtx = ctx + if normalMode { + // Switch to insert mode (remove interceptor). + normalMode = false + ctx.ResetEditor() + return "Switched to INSERT mode (default editor).", nil + } + // Switch to normal mode (install interceptor). + normalMode = true + ctx.SetEditor(ext.EditorConfig{ + HandleKey: func(key string, currentText string) ext.EditorKeyAction { + return handleVimKey(key, currentText) + }, + Render: func(width int, defaultContent string) string { + return renderVimMode(width, defaultContent) + }, + }) + return "Switched to NORMAL mode. Press 'i' to insert, 'h/j/k/l' to navigate.", nil + }, + }) + + // /vim-info — show the current editor mode. + api.RegisterCommand(ext.CommandDef{ + Name: "vim-info", + Description: "Show current vim mode", + Execute: func(args string, ctx ext.Context) (string, error) { + if normalMode { + return "Current mode: NORMAL (vim interceptor active)", nil + } + return "Current mode: INSERT (default editor)", nil + }, + }) +} + +// handleVimKey processes keys in vim normal mode. +func handleVimKey(key string, currentText string) ext.EditorKeyAction { + switch key { + // Navigation: remap hjkl to arrow keys. + case "h": + return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "left"} + case "j": + return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "down"} + case "k": + return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "up"} + case "l": + return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "right"} + + // Mode switching. + case "i": + // Enter insert mode. + normalMode = false + if savedCtx.ResetEditor != nil { + savedCtx.ResetEditor() + } + return ext.EditorKeyAction{Type: ext.EditorKeyConsumed} + + // Editing shortcuts. + case "x": + // Delete character under cursor (remap to delete key). + return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "delete"} + case "0": + // Jump to beginning of line. + return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "home"} + case "$": + // Jump to end of line. + return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "end"} + + // Submission. + case "enter": + // In normal mode, Enter submits the current text. + if strings.TrimSpace(currentText) != "" { + return ext.EditorKeyAction{Type: ext.EditorKeySubmit} + } + return ext.EditorKeyAction{Type: ext.EditorKeyConsumed} + + // Block most printable keys in normal mode. + default: + // Let control sequences and special keys through (e.g., ctrl+c, esc). + if len(key) > 1 && key != "space" { + return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough} + } + // Consume single printable characters — don't insert in normal mode. + return ext.EditorKeyAction{Type: ext.EditorKeyConsumed} + } +} + +// renderVimMode wraps the default editor rendering with a mode indicator. +func renderVimMode(width int, defaultContent string) string { + mode := "-- NORMAL --" + if !normalMode { + mode = "-- INSERT --" + } + + // Build a mode indicator line. + indicator := fmt.Sprintf(" %s", mode) + + // Pad to fill width. + padding := width - len(indicator) + if padding > 0 { + indicator += strings.Repeat(" ", padding) + } + + return indicator + "\n" + defaultContent +} diff --git a/internal/extensions/api.go b/internal/extensions/api.go index 35204c17..6054ee3f 100644 --- a/internal/extensions/api.go +++ b/internal/extensions/api.go @@ -178,6 +178,34 @@ type Context struct { // fmt.Println("Selected:", result.Action) // } ShowOverlay func(OverlayConfig) OverlayResult + + // SetEditor installs an editor interceptor that wraps the built-in + // input editor. The interceptor can intercept keys (remap, consume, + // submit) and modify the rendered output. Only one interceptor is + // active at a time; calling SetEditor replaces any previous interceptor. + // + // Example — vim-like normal mode: + // + // ctx.SetEditor(ext.EditorConfig{ + // HandleKey: func(key, text string) ext.EditorKeyAction { + // switch key { + // case "h": + // return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "left"} + // case "i": + // ctx.ResetEditor() + // return ext.EditorKeyAction{Type: ext.EditorKeyConsumed} + // } + // return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough} + // }, + // Render: func(width int, content string) string { + // return "[NORMAL]\n" + content + // }, + // }) + SetEditor func(EditorConfig) + + // ResetEditor removes the active editor interceptor and restores the + // default built-in editor behavior. No-op if no interceptor is set. + ResetEditor func() } // PrintBlockOpts configures a custom styled block for PrintBlock. @@ -617,6 +645,87 @@ type ToolRenderConfig struct { RenderBody func(toolResult string, isError bool, width int) string } +// --------------------------------------------------------------------------- +// Editor interceptor types (exposed to Yaegi — concrete structs) +// --------------------------------------------------------------------------- + +// EditorKeyActionType defines the outcome of an editor key interception. +type EditorKeyActionType string + +const ( + // EditorKeyPassthrough lets the built-in editor handle the key normally. + EditorKeyPassthrough EditorKeyActionType = "passthrough" + + // EditorKeyConsumed means the extension handled the key. The editor + // should re-render but not process the key further. + EditorKeyConsumed EditorKeyActionType = "consumed" + + // EditorKeyRemap transforms the key into a different key before passing + // it to the built-in editor. Use RemappedKey to specify the target + // (e.g., "left", "right", "up", "down", "backspace", "delete", "enter", + // "tab", "home", "end", or a single character like "a"). + EditorKeyRemap EditorKeyActionType = "remap" + + // EditorKeySubmit forces immediate text submission. The SubmitText field + // specifies the text to submit (empty = use editor's current text). + EditorKeySubmit EditorKeyActionType = "submit" +) + +// EditorKeyAction is returned by an editor interceptor's HandleKey function +// to indicate how a key press should be handled. +type EditorKeyAction struct { + // Type determines the action taken. + Type EditorKeyActionType + + // RemappedKey is the target key name for EditorKeyRemap. Must be a + // recognized key name (e.g., "left", "right", "up", "down", "backspace", + // "delete", "enter", "tab", "home", "end", "esc", "space", or a single + // printable character). + RemappedKey string + + // SubmitText is the text to submit for EditorKeySubmit. If empty, the + // editor's current content is submitted instead. + SubmitText string +} + +// EditorConfig defines an editor interceptor/decorator that wraps the built-in +// input editor. Extensions can intercept key events (remap, consume, or force +// submit) and/or modify the rendered output (add mode indicators, apply visual +// effects). +// +// This follows Pi's extension editor pattern (modal editor, rainbow editor) +// but uses concrete function fields instead of interfaces for Yaegi safety. +// +// IMPORTANT (Yaegi limitation): Function fields MUST be set using anonymous +// function literals (closures), NOT bare function references. Yaegi does not +// correctly propagate return values from named function references assigned to +// struct fields. Wrap any named function in a closure: +// +// // WRONG — Yaegi returns zero values: +// ctx.SetEditor(ext.EditorConfig{HandleKey: myHandler, Render: myRender}) +// +// // CORRECT — closure wrapper works: +// ctx.SetEditor(ext.EditorConfig{ +// HandleKey: func(k string, t string) ext.EditorKeyAction { return myHandler(k, t) }, +// Render: func(w int, c string) string { return myRender(w, c) }, +// }) +type EditorConfig struct { + // HandleKey intercepts key presses before they reach the built-in editor. + // It receives the key name (e.g., "a", "enter", "ctrl+c", "backspace") + // and the editor's current text content. Return an EditorKeyAction to + // control how the key is handled. + // + // If nil, all keys pass through to the built-in editor unchanged. + HandleKey func(key string, currentText string) EditorKeyAction + + // Render wraps the built-in editor's rendered output. It receives the + // available width and the default-rendered content (including title, + // textarea, popup, and help text). Return the modified content to display. + // + // If nil, the default rendering is used unchanged. + Render func(width int, defaultContent string) string +} + // --------------------------------------------------------------------------- // Typed events (all concrete structs — safe for Yaegi) // --------------------------------------------------------------------------- diff --git a/internal/extensions/runner.go b/internal/extensions/runner.go index 7ea37285..e0de1a2e 100644 --- a/internal/extensions/runner.go +++ b/internal/extensions/runner.go @@ -12,12 +12,13 @@ import ( // sequentially, mirroring Pi's ExtensionRunner. Handlers execute in extension // load order; for cancellable events the first blocking result wins. type Runner struct { - extensions []LoadedExtension - ctx Context - widgets map[string]WidgetConfig // keyed by widget ID - header *HeaderFooterConfig // nil = no custom header - footer *HeaderFooterConfig // nil = no custom footer - mu sync.RWMutex + extensions []LoadedExtension + ctx Context + widgets map[string]WidgetConfig // keyed by widget ID + header *HeaderFooterConfig // nil = no custom header + footer *HeaderFooterConfig // nil = no custom footer + customEditor *EditorConfig // nil = no custom editor interceptor + mu sync.RWMutex } // LoadedExtension represents a single extension that has been discovered, @@ -234,6 +235,40 @@ func (r *Runner) GetFooter() *HeaderFooterConfig { return &f } +// --------------------------------------------------------------------------- +// Editor interceptor management +// --------------------------------------------------------------------------- + +// SetEditor installs an editor interceptor that wraps the built-in input +// editor. Only one interceptor is active at a time; calling SetEditor replaces +// any previous interceptor. Thread-safe. +func (r *Runner) SetEditor(config EditorConfig) { + r.mu.Lock() + defer r.mu.Unlock() + r.customEditor = &config +} + +// ResetEditor removes the active editor interceptor and restores the default +// built-in editor behavior. No-op if no interceptor is set. Thread-safe. +func (r *Runner) ResetEditor() { + r.mu.Lock() + defer r.mu.Unlock() + r.customEditor = nil +} + +// GetEditor returns the current editor interceptor, or nil if none is set. +// Thread-safe. Returns a shallow copy — function fields are reference types +// so the copy is safe. +func (r *Runner) GetEditor() *EditorConfig { + r.mu.RLock() + defer r.mu.RUnlock() + if r.customEditor == nil { + return nil + } + e := *r.customEditor + return &e +} + // --------------------------------------------------------------------------- // Tool renderer management // --------------------------------------------------------------------------- diff --git a/internal/extensions/symbols.go b/internal/extensions/symbols.go index 1c0a2bb9..92430fef 100644 --- a/internal/extensions/symbols.go +++ b/internal/extensions/symbols.go @@ -49,6 +49,15 @@ func Symbols() interp.Exports { // Tool renderer types "ToolRenderConfig": reflect.ValueOf((*ToolRenderConfig)(nil)), + // Editor interceptor types + "EditorKeyActionType": reflect.ValueOf((*EditorKeyActionType)(nil)), + "EditorKeyPassthrough": reflect.ValueOf(EditorKeyPassthrough), + "EditorKeyConsumed": reflect.ValueOf(EditorKeyConsumed), + "EditorKeyRemap": reflect.ValueOf(EditorKeyRemap), + "EditorKeySubmit": reflect.ValueOf(EditorKeySubmit), + "EditorKeyAction": reflect.ValueOf((*EditorKeyAction)(nil)), + "EditorConfig": reflect.ValueOf((*EditorConfig)(nil)), + // Prompt types "PromptSelectConfig": reflect.ValueOf((*PromptSelectConfig)(nil)), "PromptSelectResult": reflect.ValueOf((*PromptSelectResult)(nil)), diff --git a/internal/ui/model.go b/internal/ui/model.go index c949e961..e348f74f 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -113,6 +113,45 @@ type ToolRendererData struct { RenderBody func(toolResult string, isError bool, width int) string } +// --------------------------------------------------------------------------- +// Editor interceptor types (UI-layer, decoupled from extensions package) +// --------------------------------------------------------------------------- + +// EditorKeyActionType defines the outcome of an editor key interception. +// Mirrors extensions.EditorKeyActionType for package decoupling. +type EditorKeyActionType string + +const ( + // EditorKeyPassthrough lets the built-in editor handle the key normally. + EditorKeyPassthrough EditorKeyActionType = "passthrough" + // EditorKeyConsumed means the extension handled the key. + EditorKeyConsumed EditorKeyActionType = "consumed" + // EditorKeyRemap transforms the key into a different key. + EditorKeyRemap EditorKeyActionType = "remap" + // EditorKeySubmit forces immediate text submission. + EditorKeySubmit EditorKeyActionType = "submit" +) + +// EditorKeyAction is the UI-layer equivalent of extensions.EditorKeyAction. +type EditorKeyAction struct { + // Type determines the action taken. + Type EditorKeyActionType + // RemappedKey is the target key name for EditorKeyRemap. + RemappedKey string + // SubmitText is the text to submit for EditorKeySubmit. + SubmitText string +} + +// EditorInterceptor is the UI-layer representation of an extension editor +// interceptor. It decouples the UI package from the extensions package. +// The CLI layer converts the extension EditorConfig to this type. +type EditorInterceptor struct { + // HandleKey intercepts key presses before the built-in editor. + HandleKey func(key string, currentText string) EditorKeyAction + // Render wraps the built-in editor's rendered output. + Render func(width int, defaultContent string) string +} + // WidgetData is the UI-layer representation of an extension widget. It // decouples the UI package from the extensions package. The CLI layer // converts extension WidgetConfig values to WidgetData for rendering. @@ -197,6 +236,12 @@ type AppModelOptions struct { // Called during tool result rendering to check for custom formatting. // May be nil if no extensions are loaded. GetToolRenderer func(toolName string) *ToolRendererData + + // GetEditorInterceptor returns the current editor interceptor set by + // an extension, or nil if none is active. Called during Update() to + // intercept key events and during View() to wrap input rendering. + // May be nil if no extensions are loaded. + GetEditorInterceptor func() *EditorInterceptor } // AppModel is the root Bubble Tea model for the interactive TUI. It owns the @@ -301,6 +346,9 @@ type AppModel struct { // getFooter returns the current custom footer. May be nil. getFooter func() *WidgetData + // getEditorInterceptor returns the current editor interceptor. May be nil. + getEditorInterceptor func() *EditorInterceptor + // prompt holds the state of an active interactive prompt overlay. Nil // when no prompt is active. Managed by updatePromptState(). prompt *promptOverlay @@ -413,6 +461,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel { m.getWidgets = opts.GetWidgets m.getHeader = opts.GetHeader m.getFooter = opts.GetFooter + m.getEditorInterceptor = opts.GetEditorInterceptor // Store context/skills metadata and tool counts for startup display. m.contextPaths = opts.ContextPaths @@ -650,11 +699,52 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // In other states pass ESC through to children below. } - // Route key events to the focused child. + // Route key events to the focused child. Check for editor + // interceptor first — it can consume, remap, or force-submit keys. if m.input != nil { - updated, cmd := m.input.Update(msg) - m.input, _ = updated.(inputComponentIface) - cmds = append(cmds, cmd) + var intercepted bool + if m.getEditorInterceptor != nil { + if interceptor := m.getEditorInterceptor(); interceptor != nil && interceptor.HandleKey != nil { + var currentText string + if ic, ok := m.input.(*InputComponent); ok { + currentText = ic.textarea.Value() + } + action := interceptor.HandleKey(msg.String(), currentText) + switch action.Type { + case EditorKeyConsumed: + intercepted = true + case EditorKeyRemap: + if remapped, ok := remapKey(action.RemappedKey); ok { + updated, cmd := m.input.Update(remapped) + m.input, _ = updated.(inputComponentIface) + cmds = append(cmds, cmd) + intercepted = true + } + // If remap target is unrecognized, fall through to normal handling. + case EditorKeySubmit: + text := action.SubmitText + if text == "" { + if ic, ok := m.input.(*InputComponent); ok { + text = strings.TrimSpace(ic.textarea.Value()) + ic.textarea.SetValue("") + ic.textarea.CursorEnd() + } + } + if text != "" { + cmds = append(cmds, func() tea.Msg { + return submitMsg{Text: text} + }) + } + intercepted = true + } + // EditorKeyPassthrough falls through to normal input handling. + } + } + if !intercepted { + updated, cmd := m.input.Update(msg) + m.input, _ = updated.(inputComponentIface) + cmds = append(cmds, cmd) + } } // ── Cancel timer expired ───────────────────────────────────────────────── @@ -1105,12 +1195,20 @@ func (m *AppModel) renderSeparator() string { return lineStyle.Render(repeatRune('─', m.width)) } -// renderInput returns the input region content. +// renderInput returns the input region content. If an editor interceptor +// is active and provides a Render function, the default content is passed +// through it for wrapping/modification. func (m *AppModel) renderInput() string { if m.input == nil { return "" } - return m.input.View().Content + content := m.input.View().Content + if m.getEditorInterceptor != nil { + if interceptor := m.getEditorInterceptor(); interceptor != nil && interceptor.Render != nil { + content = interceptor.Render(m.width, content) + } + } + return content } // renderWidgetSlot renders all extension widgets for the given placement @@ -1575,13 +1673,15 @@ func (m *AppModel) distributeHeight() { // Measure the actual rendered input (or prompt overlay) height so we // don't rely on a fragile constant that drifts when styling changes. + // Use renderInput() which includes the editor interceptor's Render + // wrapper so the measured height matches what View() actually renders. inputLines := 9 // fallback: title(1)+margin(1)+nl(1)+textarea(3)+nl(1)+margin(1)+help(1) if m.state == statePrompt && m.prompt != nil { if rendered := m.prompt.Render(); rendered != "" { inputLines = lipgloss.Height(rendered) } - } else if m.input != nil { - if rendered := m.input.View().Content; rendered != "" { + } else { + if rendered := m.renderInput(); rendered != "" { inputLines = lipgloss.Height(rendered) } } @@ -1623,6 +1723,53 @@ func repeatRune(r rune, n int) string { return string(runes) } +// -------------------------------------------------------------------------- +// Editor key remapping +// -------------------------------------------------------------------------- + +// remapKey converts a key name string to a tea.KeyPressMsg for editor key +// remapping. Returns the KeyPressMsg and true if the key name is recognized, +// or a zero value and false if unknown. +func remapKey(name string) (tea.KeyPressMsg, bool) { + switch name { + case "up": + return tea.KeyPressMsg{Code: tea.KeyUp}, true + case "down": + return tea.KeyPressMsg{Code: tea.KeyDown}, true + case "left": + return tea.KeyPressMsg{Code: tea.KeyLeft}, true + case "right": + return tea.KeyPressMsg{Code: tea.KeyRight}, true + case "backspace": + return tea.KeyPressMsg{Code: tea.KeyBackspace}, true + case "delete": + return tea.KeyPressMsg{Code: tea.KeyDelete}, true + case "enter": + return tea.KeyPressMsg{Code: tea.KeyEnter}, true + case "tab": + return tea.KeyPressMsg{Code: tea.KeyTab}, true + case "esc", "escape": + return tea.KeyPressMsg{Code: tea.KeyEscape}, true + case "home": + return tea.KeyPressMsg{Code: tea.KeyHome}, true + case "end": + return tea.KeyPressMsg{Code: tea.KeyEnd}, true + case "pgup", "pageup": + return tea.KeyPressMsg{Code: tea.KeyPgUp}, true + case "pgdown", "pagedown": + return tea.KeyPressMsg{Code: tea.KeyPgDown}, true + case "space": + return tea.KeyPressMsg{Code: ' ', Text: " "}, true + default: + // Single printable character. + runes := []rune(name) + if len(runes) == 1 { + return tea.KeyPressMsg{Code: runes[0], Text: name}, true + } + return tea.KeyPressMsg{}, false + } +} + // -------------------------------------------------------------------------- // Tree session command handlers // -------------------------------------------------------------------------- diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index 310fd314..b600a45c 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -229,6 +229,31 @@ func (m *Kit) GetExtensionToolRenderer(toolName string) *extensions.ToolRenderCo return m.extRunner.GetToolRenderer(toolName) } +// SetExtensionEditor installs an editor interceptor from extensions. +// Delegates to the extension runner. No-op if extensions are disabled. +func (m *Kit) SetExtensionEditor(config extensions.EditorConfig) { + if m.extRunner != nil { + m.extRunner.SetEditor(config) + } +} + +// ResetExtensionEditor removes the active editor interceptor from extensions. +// Delegates to the extension runner. No-op if extensions are disabled. +func (m *Kit) ResetExtensionEditor() { + if m.extRunner != nil { + m.extRunner.ResetEditor() + } +} + +// GetExtensionEditor returns the current editor interceptor, or nil if none +// is set. Returns nil if extensions are disabled. +func (m *Kit) GetExtensionEditor() *extensions.EditorConfig { + if m.extRunner == nil { + return nil + } + return m.extRunner.GetEditor() +} + // HasExtensions returns true if the extension runner is configured and active. func (m *Kit) HasExtensions() bool { return m.extRunner != nil