Skip to content

Commit 9a2c555

Browse files
authored
Merge pull request #818 from sathiraumesh/implement-open-prompt-in-editor
Implement open prompt in system text editor via Ctrl+X
2 parents a2ea95c + ff4f1d7 commit 9a2c555

5 files changed

Lines changed: 223 additions & 0 deletions

File tree

cmd/cli/commands/run.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ func generateInteractiveWithReadline(cmd *cobra.Command, desktopClient *desktop.
132132
fmt.Fprintln(os.Stderr, " Ctrl + w Delete the word before the cursor")
133133
fmt.Fprintln(os.Stderr, "")
134134
fmt.Fprintln(os.Stderr, " Ctrl + l Clear the screen")
135+
fmt.Fprintln(os.Stderr, " Ctrl + x Open prompt in your default text editor")
135136
fmt.Fprintln(os.Stderr, " Ctrl + c Stop the model from responding")
136137
fmt.Fprintln(os.Stderr, " Ctrl + d Exit (/bye)")
137138
fmt.Fprintln(os.Stderr, "")

cmd/cli/readline/editor.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package readline
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"runtime"
9+
"strings"
10+
)
11+
12+
const (
13+
defaultEditor = "vi"
14+
defaultShell = "/bin/sh"
15+
windowsEditor = "notepad"
16+
windowsShell = "cmd"
17+
)
18+
19+
func openInEditor(fd uintptr, termios any, content string) (string, error) {
20+
if err := UnsetRawMode(fd, termios); err != nil {
21+
return content, err
22+
}
23+
24+
edited, err := runEditor(content)
25+
26+
if _, restoreErr := SetRawMode(fd); restoreErr != nil {
27+
return content, errors.Join(err, restoreErr)
28+
}
29+
30+
if err != nil {
31+
return content, err
32+
}
33+
34+
return edited, nil
35+
}
36+
37+
func platformize(linux, windows string) string {
38+
if runtime.GOOS == "windows" {
39+
return windows
40+
}
41+
return linux
42+
}
43+
44+
func defaultEnvShell() []string {
45+
shell := os.Getenv("SHELL")
46+
if shell == "" {
47+
shell = platformize(defaultShell, windowsShell)
48+
}
49+
flag := "-c"
50+
if shell == windowsShell {
51+
flag = "/C"
52+
}
53+
return []string{shell, flag}
54+
}
55+
56+
func resolveEditor() ([]string, bool) {
57+
editor := strings.TrimSpace(os.Getenv("EDITOR"))
58+
if editor == "" {
59+
editor = platformize(defaultEditor, windowsEditor)
60+
}
61+
62+
if !strings.Contains(editor, " ") {
63+
return []string{editor}, false
64+
}
65+
66+
if !strings.ContainsAny(editor, "\"'\\") {
67+
return strings.Split(editor, " "), false
68+
}
69+
70+
shell := defaultEnvShell()
71+
return append(shell, editor), true
72+
}
73+
74+
func buildEditorCmd(filePath string) *exec.Cmd {
75+
args, shell := resolveEditor()
76+
77+
if shell {
78+
// The editor string is the last element — append the file path to it
79+
safeFilePath := strings.ReplaceAll(filePath, "'", "'\\''")
80+
args[len(args)-1] = fmt.Sprintf("%s '%s'", args[len(args)-1], safeFilePath)
81+
} else {
82+
args = append(args, filePath)
83+
}
84+
85+
//nolint:gosec // $EDITOR is a user-controlled local env var, same trust model as git/kubectl
86+
cmd := exec.Command(args[0], args[1:]...)
87+
cmd.Stdin = os.Stdin
88+
cmd.Stdout = os.Stdout
89+
cmd.Stderr = os.Stderr
90+
return cmd
91+
}
92+
93+
func runEditor(content string) (string, error) {
94+
tmpFile, err := os.CreateTemp("", "docker-model-prompt-*.txt")
95+
if err != nil {
96+
return content, err
97+
}
98+
defer os.Remove(tmpFile.Name())
99+
100+
if _, err := tmpFile.WriteString(content); err != nil {
101+
tmpFile.Close()
102+
return content, err
103+
}
104+
tmpFile.Close()
105+
106+
cmd := buildEditorCmd(tmpFile.Name())
107+
if err := cmd.Run(); err != nil {
108+
return content, err
109+
}
110+
111+
edited, err := os.ReadFile(tmpFile.Name())
112+
if err != nil {
113+
return content, err
114+
}
115+
116+
return strings.TrimRight(string(edited), "\r\n"), nil
117+
}

cmd/cli/readline/editor_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
//go:build !windows
2+
3+
package readline
4+
5+
import (
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
)
10+
11+
func createMockEditor(t *testing.T, scriptBody string) string {
12+
t.Helper()
13+
editorScript := filepath.Join(t.TempDir(), "mock-editor.sh")
14+
if err := os.WriteFile(editorScript, []byte("#!/bin/sh\n"+scriptBody+"\n"), 0o755); err != nil {
15+
t.Fatalf("failed to create mock editor: %v", err)
16+
}
17+
t.Setenv("EDITOR", editorScript)
18+
return editorScript
19+
}
20+
21+
func TestRunEditor(t *testing.T) {
22+
tests := []struct {
23+
name string
24+
mockEditorScript string
25+
input string
26+
expected string
27+
}{
28+
{
29+
name: "modifies content",
30+
mockEditorScript: `printf " edited" >> "$1"`,
31+
input: "hello docker model prompt",
32+
expected: "hello docker model prompt edited",
33+
},
34+
{
35+
name: "empty content",
36+
mockEditorScript: `printf "new content" > "$1"`,
37+
input: "",
38+
expected: "new content",
39+
},
40+
{
41+
name: "strips trailing newline",
42+
mockEditorScript: `printf "edited\n" > "$1"`,
43+
input: "",
44+
expected: "edited",
45+
},
46+
{
47+
name: "strips trailing carriage return and newline",
48+
mockEditorScript: `printf "edited\r\n" > "$1"`,
49+
input: "",
50+
expected: "edited",
51+
},
52+
}
53+
54+
for _, tt := range tests {
55+
t.Run(tt.name, func(t *testing.T) {
56+
createMockEditor(t, tt.mockEditorScript)
57+
58+
result, err := runEditor(tt.input)
59+
if err != nil {
60+
t.Fatalf("runEditor failed: %v", err)
61+
}
62+
63+
if result != tt.expected {
64+
t.Errorf("expected %q, got %q", tt.expected, result)
65+
}
66+
})
67+
}
68+
}
69+
70+
func TestRunEditorReturnsOriginalContentOnFailure(t *testing.T) {
71+
t.Setenv("EDITOR", "non_exists_editor")
72+
73+
content := "docker model prompt hello"
74+
result, err := runEditor(content)
75+
if err == nil {
76+
t.Fatal("expected error from nonexistent editor")
77+
}
78+
79+
if result != content {
80+
t.Errorf("expected original content on failure, got %q", result)
81+
}
82+
}
83+
84+
func TestRunEditorWithEditorArgs(t *testing.T) {
85+
editorScript := createMockEditor(t, `printf "edited with args" > "$2"`)
86+
t.Setenv("EDITOR", editorScript+" --wait")
87+
88+
result, err := runEditor("original")
89+
if err != nil {
90+
t.Fatalf("runEditor failed: %v", err)
91+
}
92+
93+
if result != "edited with args" {
94+
t.Errorf("expected %q, got %q", "edited with args", result)
95+
}
96+
}

cmd/cli/readline/readline.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,14 @@ func (i *Instance) Readline() (string, error) {
209209
buf.ClearScreen()
210210
case CharCtrlW:
211211
buf.DeleteWord()
212+
case CharCtrlX:
213+
fd := os.Stdin.Fd()
214+
edited, err := openInEditor(fd, i.Terminal.termios, buf.String())
215+
if err != nil {
216+
fmt.Fprintf(os.Stderr, "error opening editor: %s\n", err)
217+
break
218+
}
219+
buf.Replace([]rune(edited))
212220
case CharCtrlZ:
213221
fd := os.Stdin.Fd()
214222
return handleCharCtrlZ(fd, i.Terminal.termios)

cmd/cli/readline/types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const (
2424
CharTranspose = 20
2525
CharCtrlU = 21
2626
CharCtrlW = 23
27+
CharCtrlX = 24
2728
CharCtrlY = 25
2829
CharCtrlZ = 26
2930
CharEsc = 27

0 commit comments

Comments
 (0)