feat(ui): open external $EDITOR via ctrl+x e chord

- Add ctrl+x e leader key chord to open $VISUAL/$EDITOR in a temp file
  pre-populated with the current input text
- On save & quit, replace the input textarea with the edited content
- On error exit (e.g. :cq in vim), silently preserve original input
- Use charmbracelet/x/editor for editor command construction
- Use tea.ExecProcess to suspend/resume the TUI around the editor
- Update input hint text at all width breakpoints to show the shortcut
- Add ctrl+x e to /help output

Closes #5
This commit is contained in:
Ed Zynda
2026-04-14 12:39:29 +03:00
parent f57e045c69
commit 9d1b8a102e
4 changed files with 69 additions and 3 deletions
+1
View File
@@ -15,6 +15,7 @@ require (
github.com/charmbracelet/log v1.0.0
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266
github.com/charmbracelet/ultraviolet v0.0.0-20260414011438-8c69ec811b1e
github.com/charmbracelet/x/editor v0.2.0
github.com/clipperhouse/displaywidth v0.11.0
github.com/clipperhouse/uax29/v2 v2.7.0
github.com/coder/acp-go-sdk v0.6.3
+2
View File
@@ -94,6 +94,8 @@ github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMx
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
github.com/charmbracelet/x/editor v0.2.0 h1:7XLUKtaRaB8jN7bWU2p2UChiySyaAuIfYiIRg8gGWwk=
github.com/charmbracelet/x/editor v0.2.0/go.mod h1:p3oQ28TSL3YPd+GKJ1fHWcp+7bVGpedHpXmo0D6t1dY=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260413165052-6921c759c913 h1:6F/6bu5nBLjodsvaU5xAszTaxtHrDU5UiJarpMPZj48=
+5 -3
View File
@@ -521,12 +521,14 @@ func (s *InputComponent) View() tea.View {
} else {
hint = "^X s steer"
}
} else if availableHintWidth >= 80 {
hint = "enter submit • ctrl+j / shift+enter new line • ctrl+x e editor • ctrl+v paste image"
} else if availableHintWidth >= 67 {
hint = "enter submit • ctrl+j / shift+enter new line • ctrl+v paste image"
hint = "enter submit • ctrl+j new line • ctrl+x e editor • ctrl+v image"
} else if availableHintWidth >= 40 {
hint = "↵ submit • ctrl+j newline • ctrl+v image"
hint = "↵ submit • ctrl+j newline • ^X e editor"
} else if availableHintWidth >= 20 {
hint = "↵ submit • ctrl+j"
hint = "↵ submit • ^X e editor"
} else {
hint = "↵ submit"
}
+61
View File
@@ -12,6 +12,7 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/editor"
"github.com/spf13/viper"
"github.com/mark3labs/kit/internal/app"
@@ -1333,6 +1334,45 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
}
case "e":
// Ctrl+X e → open $EDITOR to compose/edit the prompt.
editorApp := os.Getenv("VISUAL")
if editorApp == "" {
editorApp = os.Getenv("EDITOR")
}
if editorApp == "" {
m.printSystemMessage("Set `$EDITOR` or `$VISUAL` to use external editor")
} else {
var currentText string
if ic, ok := m.input.(*InputComponent); ok {
currentText = ic.textarea.Value()
}
tmpFile, err := os.CreateTemp("", "kit_prompt_*.md")
if err == nil {
if currentText != "" {
_, _ = tmpFile.WriteString(currentText)
}
_ = tmpFile.Close()
editorCmd, cmdErr := editor.Command(editorApp, tmpFile.Name())
if cmdErr != nil {
_ = os.Remove(tmpFile.Name())
m.printSystemMessage(fmt.Sprintf("Failed to open editor: %v", cmdErr))
} else {
cmds = append(cmds, tea.ExecProcess(editorCmd, func(err error) tea.Msg {
if err != nil {
_ = os.Remove(tmpFile.Name())
return externalEditorMsg{err: err}
}
content, readErr := os.ReadFile(tmpFile.Name())
_ = os.Remove(tmpFile.Name())
if readErr != nil {
return externalEditorMsg{err: readErr}
}
return externalEditorMsg{text: string(content)}
}))
}
}
}
}
// Chord consumed — don't propagate to children.
return m, tea.Batch(cmds...)
@@ -1973,6 +2013,19 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.printSystemMessage(msg.output)
}
case externalEditorMsg:
// User returned from $EDITOR. Replace input textarea content with
// whatever they saved in the temp file. On error (e.g. :cq in vim)
// the original input is silently preserved.
if msg.err == nil {
if ic, ok := m.input.(*InputComponent); ok {
ic.textarea.SetValue(msg.text)
// Move cursor to the end of the inserted text.
ic.textarea.CursorEnd()
}
m.layoutDirty = true
}
case extReloadResultMsg:
if msg.err != nil {
m.printSystemMessage(fmt.Sprintf("Extension reload failed: %v", msg.err))
@@ -2967,6 +3020,7 @@ func (m *AppModel) printHelpMessage() {
"- `Ctrl+C`: Exit at any time\n" +
"- `ESC` (x2): Cancel ongoing LLM generation\n" +
"- `Ctrl+X s`: Steer — redirect the agent mid-turn (injected between tool calls)\n" +
"- `Ctrl+X e`: Open `$EDITOR` to compose/edit your prompt\n" +
"- `Enter` (while working): Queue message for after the agent finishes\n\n" +
"You can also just type your message to chat with the AI assistant."
m.printSystemMessage(help)
@@ -4054,6 +4108,13 @@ func cancelTimerCmd() tea.Cmd {
// Interactive prompt support
// --------------------------------------------------------------------------
// externalEditorMsg is sent when the user returns from $EDITOR after
// composing a prompt via the Ctrl+X e chord.
type externalEditorMsg struct {
text string
err error
}
// shareResultMsg carries the result of an async gist upload.
type shareResultMsg struct {
err error