fix(editor): execute EDITOR with interp

Amolith created

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

Change summary

internal/tui/components/chat/editor/editor.go | 13 ++--
internal/tui/util/util.go                     | 53 +++++++++++++++++++++
2 files changed, 59 insertions(+), 7 deletions(-)

Detailed changes

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)
 		}

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)
+}