feat(editor): add ctrl+. to open cwd

Amolith created

Assisted-by: Claude Sonnet 4.5 via Crush

Change summary

internal/shell/parser.go                      | 45 +++++++++++++
internal/tui/components/chat/editor/editor.go | 68 ++++++++++++--------
internal/tui/components/chat/editor/keys.go   | 14 +++-
internal/tui/page/chat/chat.go                |  4 +
4 files changed, 99 insertions(+), 32 deletions(-)

Detailed changes

internal/shell/parser.go 🔗

@@ -0,0 +1,45 @@
+package shell
+
+import (
+	"fmt"
+	"strings"
+
+	"mvdan.cc/sh/v3/syntax"
+)
+
+// ParseCommand parses a command string into the command name and arguments.
+// It handles quoted arguments and other shell syntax.
+func ParseCommand(command string) (string, []string, error) {
+	parsed, err := syntax.NewParser().Parse(strings.NewReader(command), "")
+	if err != nil {
+		return "", nil, fmt.Errorf("failed to parse command: %w", err)
+	}
+
+	var cmdName string
+	var cmdArgs []string
+
+	if len(parsed.Stmts) > 0 && parsed.Stmts[0].Cmd != nil {
+		if callExpr, ok := parsed.Stmts[0].Cmd.(*syntax.CallExpr); ok && len(callExpr.Args) > 0 {
+			for i, arg := range callExpr.Args {
+				var argStr string
+				for _, part := range arg.Parts {
+					if lit, ok := part.(*syntax.Lit); ok {
+						argStr += lit.Value
+					}
+				}
+				if i == 0 {
+					cmdName = argStr
+				} else {
+					cmdArgs = append(cmdArgs, argStr)
+				}
+			}
+		}
+	}
+
+	// Fallback if parsing produced no command name (e.g. empty string or comment only, though unlikely with valid input)
+	if cmdName == "" {
+		return command, nil, nil
+	}
+
+	return cmdName, cmdArgs, nil
+}

internal/tui/components/chat/editor/editor.go 🔗

@@ -21,6 +21,7 @@ import (
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/shell"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/components/completions"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
@@ -30,7 +31,6 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
-	"mvdan.cc/sh/v3/syntax"
 )
 
 type Editor interface {
@@ -116,34 +116,9 @@ func (m *editorCmp) openEditor(value string) tea.Cmd {
 
 	// Parse the EDITOR parts to separate the executable from its arguments.
 	// This properly handles EDITORs with args like `zed --wait`.
-	parsed, err := syntax.NewParser().Parse(strings.NewReader(editor), "")
+	cmdName, cmdArgs, err := shell.ParseCommand(editor)
 	if err != nil {
-		return util.ReportError(fmt.Errorf("failed to parse editor command: %w", err))
-	}
-
-	var cmdName string
-	var cmdArgs []string
-	if len(parsed.Stmts) > 0 && parsed.Stmts[0].Cmd != nil {
-		if callExpr, ok := parsed.Stmts[0].Cmd.(*syntax.CallExpr); ok && len(callExpr.Args) > 0 {
-			for i, arg := range callExpr.Args {
-				var argStr string
-				for _, part := range arg.Parts {
-					if lit, ok := part.(*syntax.Lit); ok {
-						argStr += lit.Value
-					}
-				}
-				if i == 0 {
-					cmdName = argStr
-				} else {
-					cmdArgs = append(cmdArgs, argStr)
-				}
-			}
-		}
-	}
-
-	// Fallback if parsing borked
-	if cmdName == "" {
-		cmdName = editor
+		return util.ReportError(err)
 	}
 
 	cmdArgs = append(cmdArgs, tmpfile.Name())
@@ -169,6 +144,37 @@ func (m *editorCmp) openEditor(value string) tea.Cmd {
 	})
 }
 
+func (m *editorCmp) openWorkingDir() tea.Cmd {
+	editor := os.Getenv("EDITOR")
+	if editor == "" {
+		// Use platform-appropriate default editor
+		if runtime.GOOS == "windows" {
+			editor = "notepad"
+		} else {
+			editor = "nvim"
+		}
+	}
+
+	// Parse the EDITOR parts to separate the executable from its arguments.
+	// This properly handles EDITORs with args like `zed --wait`.
+	cmdName, cmdArgs, err := shell.ParseCommand(editor)
+	if err != nil {
+		return util.ReportError(err)
+	}
+
+	cmdArgs = append(cmdArgs, ".")
+	c := exec.CommandContext(context.TODO(), cmdName, cmdArgs...)
+	c.Stdin = os.Stdin
+	c.Stdout = os.Stdout
+	c.Stderr = os.Stderr
+	return tea.ExecProcess(c, func(err error) tea.Msg {
+		if err != nil {
+			return util.ReportError(err)
+		}
+		return nil
+	})
+}
+
 func (m *editorCmp) Init() tea.Cmd {
 	return nil
 }
@@ -343,6 +349,12 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			}
 			return m, m.openEditor(m.textarea.Value())
 		}
+		if key.Matches(msg, m.keyMap.OpenWorkingDir) {
+			if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) {
+				return m, util.ReportWarn("Agent is working, please wait...")
+			}
+			return m, m.openWorkingDir()
+		}
 		if key.Matches(msg, DeleteKeyMaps.Escape) {
 			m.deleteMode = false
 			return m, nil

internal/tui/components/chat/editor/keys.go 🔗

@@ -5,10 +5,11 @@ import (
 )
 
 type EditorKeyMap struct {
-	AddFile     key.Binding
-	SendMessage key.Binding
-	OpenEditor  key.Binding
-	Newline     key.Binding
+	AddFile        key.Binding
+	SendMessage    key.Binding
+	OpenEditor     key.Binding
+	OpenWorkingDir key.Binding
+	Newline        key.Binding
 }
 
 func DefaultEditorKeyMap() EditorKeyMap {
@@ -25,6 +26,10 @@ func DefaultEditorKeyMap() EditorKeyMap {
 			key.WithKeys("ctrl+o"),
 			key.WithHelp("ctrl+o", "open editor"),
 		),
+		OpenWorkingDir: key.NewBinding(
+			key.WithKeys("ctrl+."),
+			key.WithHelp("ctrl+.", "open cwd"),
+		),
 		Newline: key.NewBinding(
 			key.WithKeys("shift+enter", "ctrl+j"),
 			// "ctrl+j" is a common keybinding for newline in many editors. If
@@ -41,6 +46,7 @@ func (k EditorKeyMap) KeyBindings() []key.Binding {
 		k.AddFile,
 		k.SendMessage,
 		k.OpenEditor,
+		k.OpenWorkingDir,
 		k.Newline,
 		AttachmentsKeyMaps.AttachmentDeleteMode,
 		AttachmentsKeyMaps.DeleteAllAttachments,

internal/tui/page/chat/chat.go 🔗

@@ -1054,6 +1054,10 @@ func (p *chatPage) Help() help.KeyMap {
 						key.WithKeys("ctrl+o"),
 						key.WithHelp("ctrl+o", "open editor"),
 					),
+					key.NewBinding(
+						key.WithKeys("ctrl+."),
+						key.WithHelp("ctrl+.", "open cwd"),
+					),
 				})
 
 			if p.editor.HasAttachments() {