mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
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:
+76
-23
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user