From cd8e2a765437f2c9e8bb6096299ed89b8719c8f3 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Tue, 14 Apr 2026 11:56:41 +0300 Subject: [PATCH] feat(extensions): expand inline bash in editor for interactive mode - Add editor interceptor via OnSessionStart so !{...} expansions appear in the user message block on screen - Restrict OnInput handler to non-interactive sources (CLI, script, queue) to avoid double expansion - Extract expand() helper and hoist regex to package level - Update doc comments and examples --- examples/extensions/inline-bash.go | 76 ++++++++++++++++++++++-------- 1 file changed, 57 insertions(+), 19 deletions(-) diff --git a/examples/extensions/inline-bash.go b/examples/extensions/inline-bash.go index 52e4c7f5..7e588ffd 100644 --- a/examples/extensions/inline-bash.go +++ b/examples/extensions/inline-bash.go @@ -10,13 +10,21 @@ import ( "kit/ext" ) +// re matches !{...} with non-greedy content. +var re = regexp.MustCompile(`!\{([^}]+)\}`) + // Init expands inline bash expressions in user prompts before they reach the -// LLM. Text like !{git branch --show-current} is replaced with the command's -// stdout. +// LLM. Text like !{git rev-parse --abbrev-ref HEAD} is replaced with the +// command's stdout. +// +// In interactive mode the expansion happens at submit time via an editor +// interceptor, so the expanded text is also visible in the user message +// block on screen. In non-interactive mode (CLI, script, queue) the +// expansion happens via OnInput transform. // // Examples: // -// "Fix the tests on !{git branch --show-current}" +// "Fix the tests on !{git rev-parse --abbrev-ref HEAD}" // → "Fix the tests on main" // // "The current directory is !{pwd}" @@ -24,29 +32,59 @@ import ( // // Usage: kit -e examples/extensions/inline-bash.go func Init(api ext.API) { - // Matches !{...} with non-greedy content. - re := regexp.MustCompile(`!\{([^}]+)\}`) + // ── Interactive mode: editor interceptor ────────────────────────── + // Intercept Enter / Ctrl+D so we can expand !{...} BEFORE the + // SubmitMsg is created. This ensures the expanded text appears in + // the user message block on screen as well as in the LLM prompt. + api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) { + if !ctx.Interactive { + return + } + ctx.SetEditor(ext.EditorConfig{ + HandleKey: func(key string, currentText string) ext.EditorKeyAction { + if (key == "enter" || key == "ctrl+d") && re.MatchString(currentText) { + expanded := expand(currentText) + // Clear the textarea asynchronously — calling + // SetEditorText synchronously from inside Update() + // would deadlock the BubbleTea event loop. + go ctx.SetEditorText("") + return ext.EditorKeyAction{ + Type: ext.EditorKeySubmit, + SubmitText: expanded, + } + } + return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough} + }, + }) + }) + // ── Non-interactive fallback: OnInput transform ────────────────── + // For CLI, script, and queue sources the editor interceptor is not + // active, so we fall back to OnInput which still rewrites the + // prompt text sent to the LLM. api.OnInput(func(ev ext.InputEvent, ctx ext.Context) *ext.InputResult { - if !re.MatchString(ev.Text) { + if ev.Source == "interactive" || !re.MatchString(ev.Text) { return nil } - expanded := re.ReplaceAllStringFunc(ev.Text, func(match string) string { - // Extract the command between !{ and }. - cmd := re.FindStringSubmatch(match)[1] - cmd = strings.TrimSpace(cmd) - - out, err := exec.Command("bash", "-c", cmd).Output() - if err != nil { - return match // keep original on error - } - return strings.TrimSpace(string(out)) - }) - return &ext.InputResult{ Action: "transform", - Text: expanded, + Text: expand(ev.Text), } }) } + +// expand replaces every !{cmd} in text with the command's stdout. +// On error the original !{cmd} token is preserved. +func expand(text string) string { + return re.ReplaceAllStringFunc(text, func(match string) string { + cmd := re.FindStringSubmatch(match)[1] + cmd = strings.TrimSpace(cmd) + + out, err := exec.Command("bash", "-c", cmd).Output() + if err != nil { + return match // keep original on error + } + return strings.TrimSpace(string(out)) + }) +}