diff --git a/go.mod b/go.mod index 8b76008d..0e1c972b 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 7524f2f4..15c14845 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/ui/input.go b/internal/ui/input.go index 08782dc6..0f86ab26 100644 --- a/internal/ui/input.go +++ b/internal/ui/input.go @@ -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" } diff --git a/internal/ui/model.go b/internal/ui/model.go index 9c10e589..20727373 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -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