From 77da29d673bd2d01a80849821db06f0e6444010d Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 26 Nov 2025 10:34:47 -0700 Subject: [PATCH] fix(editor): fix opening `$EDITOR` when it contains arguments (#1481) --- internal/tui/components/chat/editor/editor.go | 13 ++--- internal/tui/util/shell.go | 58 +++++++++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) create mode 100644 internal/tui/util/shell.go diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index de1a98b34595613594e83063cc12add4ba820c84..4b719f6b674176c79f42e96013c670182f0d282d 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -6,7 +6,6 @@ import ( "math/rand" "net/http" "os" - "os/exec" "path/filepath" "runtime" "slices" @@ -96,7 +95,7 @@ type OpenEditorMsg struct { func (m *editorCmp) openEditor(value string) tea.Cmd { editor := os.Getenv("EDITOR") if editor == "" { - // Use platform-appropriate default editor + // Use platform-appropriate default editor. if runtime.GOOS == "windows" { editor = "notepad" } else { @@ -112,11 +111,11 @@ func (m *editorCmp) openEditor(value string) tea.Cmd { if _, err := tmpfile.WriteString(value); err != nil { return util.ReportError(err) } - c := exec.CommandContext(context.TODO(), editor, tmpfile.Name()) - c.Stdin = os.Stdin - c.Stdout = os.Stdout - c.Stderr = os.Stderr - return tea.ExecProcess(c, func(err error) tea.Msg { + + // Build the full shell command with the file argument. + cmdStr := fmt.Sprintf("%s %s", editor, tmpfile.Name()) + + return util.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg { if err != nil { return util.ReportError(err) } diff --git a/internal/tui/util/shell.go b/internal/tui/util/shell.go new file mode 100644 index 0000000000000000000000000000000000000000..422831649d043c24265ebaafaa58bcc041214557 --- /dev/null +++ b/internal/tui/util/shell.go @@ -0,0 +1,58 @@ +package util + +import ( + "context" + "io" + "strings" + + tea "charm.land/bubbletea/v2" + "mvdan.cc/sh/v3/interp" + "mvdan.cc/sh/v3/syntax" +) + +// shellCommand wraps a shell interpreter to implement tea.ExecCommand. +type shellCommand struct { + ctx context.Context + file *syntax.File + stdin io.Reader + stdout io.Writer + stderr io.Writer +} + +func (s *shellCommand) SetStdin(r io.Reader) { + s.stdin = r +} + +func (s *shellCommand) SetStdout(w io.Writer) { + s.stdout = w +} + +func (s *shellCommand) SetStderr(w io.Writer) { + s.stderr = w +} + +func (s *shellCommand) Run() error { + runner, err := interp.New( + interp.StdIO(s.stdin, s.stdout, s.stderr), + ) + if err != nil { + return err + } + return runner.Run(s.ctx, s.file) +} + +// ExecShell executes a shell command string using tea.Exec. +// The command is parsed and executed via mvdan.cc/sh/v3/interp, allowing +// proper handling of shell syntax like quotes and arguments. +func ExecShell(ctx context.Context, cmdStr string, callback tea.ExecCallback) tea.Cmd { + parsed, err := syntax.NewParser().Parse(strings.NewReader(cmdStr), "") + if err != nil { + return ReportError(err) + } + + cmd := &shellCommand{ + ctx: ctx, + file: parsed, + } + return tea.Exec(cmd, callback) +}