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
This commit is contained in:
Ed Zynda
2026-02-28 17:46:41 +03:00
parent 0de0040e63
commit 864230bd0a
7 changed files with 551 additions and 37 deletions
+76 -23
View File
@@ -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.
+136
View File
@@ -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
}
+109
View File
@@ -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)
// ---------------------------------------------------------------------------
+41 -6
View File
@@ -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
// ---------------------------------------------------------------------------
+9
View File
@@ -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)),
+155 -8
View File
@@ -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
// --------------------------------------------------------------------------
+25
View File
@@ -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