Skip to content

Commit 24c1c12

Browse files
authored
Merge pull request #1057 from dgageot/editor
Implement ctrl-G to open prompt in editor
2 parents 3a8cd2f + d121078 commit 24c1c12

File tree

3 files changed

+124
-4
lines changed

3 files changed

+124
-4
lines changed

pkg/tui/components/editor/editor.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ type Editor interface {
3838
layout.Focusable
3939
SetWorking(working bool) tea.Cmd
4040
AcceptSuggestion() bool
41+
// Value returns the current editor content
42+
Value() string
43+
// SetValue updates the editor content
44+
SetValue(content string)
4145
}
4246

4347
// editor implements [Editor]
@@ -497,6 +501,19 @@ func (e *editor) SetWorking(working bool) tea.Cmd {
497501
return nil
498502
}
499503

504+
// Value returns the current editor content
505+
func (e *editor) Value() string {
506+
return e.textarea.Value()
507+
}
508+
509+
// SetValue updates the editor content and moves cursor to end
510+
func (e *editor) SetValue(content string) {
511+
e.textarea.SetValue(content)
512+
e.textarea.MoveToEnd()
513+
e.userTyped = content != ""
514+
e.refreshSuggestion()
515+
}
516+
500517
// tryAddFileRef checks if word is a valid @filepath and adds it to fileRefs.
501518
// Called when cursor leaves a word to detect manually-typed file references.
502519
func (e *editor) tryAddFileRef(word string) {

pkg/tui/page/chat/chat.go

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,11 @@ type chatPage struct {
8888

8989
// KeyMap defines key bindings for the chat page
9090
type KeyMap struct {
91-
Tab key.Binding
92-
Cancel key.Binding
93-
ShiftNewline key.Binding
94-
CtrlJ key.Binding
91+
Tab key.Binding
92+
Cancel key.Binding
93+
ShiftNewline key.Binding
94+
CtrlJ key.Binding
95+
ExternalEditor key.Binding
9596
}
9697

9798
// defaultKeyMap returns the default key bindings
@@ -110,6 +111,10 @@ func defaultKeyMap() KeyMap {
110111
key.WithKeys("shift+enter", "ctrl+j"),
111112
key.WithHelp("shift+enter / ctrl+j", "newline"),
112113
),
114+
ExternalEditor: key.NewBinding(
115+
key.WithKeys("ctrl+g"),
116+
key.WithHelp("ctrl+g", "edit in $EDITOR"),
117+
),
113118
}
114119
}
115120

@@ -215,6 +220,10 @@ func (p *chatPage) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
215220
// Cancel current message processing if active
216221
cmd := p.cancelStream(true)
217222
return p, cmd
223+
case key.Matches(msg, p.keyMap.ExternalEditor):
224+
// Open external editor with current editor content
225+
cmd := p.openExternalEditor()
226+
return p, cmd
218227
}
219228

220229
// Route other keys to focused component
@@ -263,6 +272,7 @@ func (p *chatPage) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
263272
slog.Debug(msg.Content)
264273
cmd := p.processMessage(msg)
265274
return p, cmd
275+
266276
case messages.StreamCancelledMsg:
267277
model, cmd := p.messages.Update(msg)
268278
p.messages = model.(messages.Model)
@@ -536,6 +546,7 @@ func (p *chatPage) Bindings() []key.Binding {
536546
p.keyMap.Cancel,
537547
// show newline hints in the global footer
538548
p.keyMap.ShiftNewline,
549+
p.keyMap.ExternalEditor,
539550
}
540551

541552
if p.focusedPanel == PanelChat {

pkg/tui/page/chat/editor.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package chat
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"runtime"
8+
"strings"
9+
10+
tea "charm.land/bubbletea/v2"
11+
12+
"github.com/docker/cagent/pkg/tui/components/notification"
13+
"github.com/docker/cagent/pkg/tui/core"
14+
)
15+
16+
// openExternalEditor opens the current editor content in an external editor.
17+
// It suspends the TUI, runs the editor, and returns the result.
18+
func (p *chatPage) openExternalEditor() tea.Cmd {
19+
content := p.editor.Value()
20+
21+
// Create a temporary file with the current content
22+
tmpFile, err := os.CreateTemp("", "cagent-*.md")
23+
if err != nil {
24+
return core.CmdHandler(notification.ShowMsg{
25+
Text: fmt.Sprintf("Failed to create temp file: %v", err),
26+
Type: notification.TypeError,
27+
})
28+
}
29+
tmpPath := tmpFile.Name()
30+
31+
if _, err := tmpFile.WriteString(content); err != nil {
32+
tmpFile.Close()
33+
os.Remove(tmpPath)
34+
return core.CmdHandler(notification.ShowMsg{
35+
Text: fmt.Sprintf("Failed to write temp file: %v", err),
36+
Type: notification.TypeError,
37+
})
38+
}
39+
tmpFile.Close()
40+
41+
// Get the editor command (VISUAL, EDITOR, or platform default)
42+
editorCmd := os.Getenv("VISUAL")
43+
if editorCmd == "" {
44+
editorCmd = os.Getenv("EDITOR")
45+
}
46+
if editorCmd == "" {
47+
if runtime.GOOS == "windows" {
48+
editorCmd = "notepad"
49+
} else {
50+
editorCmd = "vim"
51+
}
52+
}
53+
54+
// Parse editor command (may include arguments like "code --wait")
55+
parts := strings.Fields(editorCmd)
56+
args := append(parts[1:], tmpPath)
57+
cmd := exec.Command(parts[0], args...)
58+
59+
// Use tea.ExecProcess to properly suspend the TUI and run the editor
60+
return tea.ExecProcess(cmd, func(err error) tea.Msg {
61+
if err != nil {
62+
os.Remove(tmpPath)
63+
return core.CmdHandler(notification.ShowMsg{
64+
Type: notification.TypeError,
65+
Text: fmt.Sprintf("Editor error: %v", err),
66+
})
67+
}
68+
69+
updatedContent, readErr := os.ReadFile(tmpPath)
70+
os.Remove(tmpPath)
71+
72+
if readErr != nil {
73+
return core.CmdHandler(notification.ShowMsg{
74+
Text: fmt.Sprintf("Failed to read edited file: %v", readErr),
75+
Type: notification.TypeError,
76+
})
77+
}
78+
79+
// Trim trailing newline that editors often add
80+
content := strings.TrimSuffix(string(updatedContent), "\n")
81+
82+
// If content is empty, just clear the editor
83+
if strings.TrimSpace(content) == "" {
84+
p.editor.SetValue("")
85+
return nil
86+
}
87+
88+
// Clear the editor and automatically submit the content
89+
p.editor.SetValue(content)
90+
return nil
91+
})
92+
}

0 commit comments

Comments
 (0)