Detailed changes
@@ -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
+}
@@ -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
@@ -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,
@@ -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() {