From 2891a65fa2203a2f1dc0dbe59d104f9f6319809e Mon Sep 17 00:00:00 2001 From: Amolith Date: Fri, 21 Nov 2025 08:50:18 -0700 Subject: [PATCH] fix(editor): execute EDITOR with interp Current Crush gives the following error when I try to open my `EDITOR` with `ctrl+o`: ``` Error reported error="exec: \"zed --wait\": executable file not found in $PATH" source=path/to/internal/tui/util/util.go:27 ``` `exec.CommandContext` expect the first argument to be the executable path, not include flags or other arguments or variables. This commit introduces `ExecShell` in `internal/tui/util/util.go`, which uses `mvdan.cc/sh/v3/interp` to parse and execute the EDITOR value as a full line of shell. Assisted-by: Claude Sonnet 4.5 via Crush --- internal/tui/components/chat/editor/editor.go | 13 +++-- internal/tui/util/util.go | 53 +++++++++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) 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/util.go b/internal/tui/util/util.go index 297a9d36fa47170cae787c82419a17b51fc13b05..713e55aaf9c51b432e82152d1e58e1c00b759a30 100644 --- a/internal/tui/util/util.go +++ b/internal/tui/util/util.go @@ -1,10 +1,15 @@ package util import ( + "context" + "io" "log/slog" + "strings" "time" tea "charm.land/bubbletea/v2" + "mvdan.cc/sh/v3/interp" + "mvdan.cc/sh/v3/syntax" ) type Cursor interface { @@ -62,3 +67,51 @@ type ( } ClearStatusMsg struct{} ) + +// 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) +}