diff --git a/internal/ui/commands/commands.go b/internal/ui/commands/commands.go index c48534a7..61f2d873 100644 --- a/internal/ui/commands/commands.go +++ b/internal/ui/commands/commands.go @@ -167,6 +167,15 @@ var SlashCommands = []SlashCommand{ Category: "System", Aliases: []string{"/cp"}, }, + { + Name: "/edit", + Description: "Open a file in $EDITOR (fuzzy-find a path, then edit)", + Category: "System", + Aliases: []string{"/ed"}, + HasArgs: true, + // Note: no Complete callback — file fuzzy-finding is driven directly + // by InputComponent (mirroring the @file popup with directory drill). + }, { Name: "/export", Description: "Export session (JSONL by default, or /export path.jsonl)", diff --git a/internal/ui/file_suggestions.go b/internal/ui/file_suggestions.go index 5e834aef..27eb71d6 100644 --- a/internal/ui/file_suggestions.go +++ b/internal/ui/file_suggestions.go @@ -125,6 +125,33 @@ func ExtractAtPrefix(line string, cursorCol int) (hasAt bool, prefix string, sta return true, raw, atIdx } +// editTriggerPrefixes lists the command tokens (including trailing space) +// that activate the /edit fuzzy-file picker. Aliases come first so the +// longer alias "/edit " is matched before a hypothetical superset. +var editTriggerPrefixes = []string{"/edit ", "/ed "} + +// ExtractEditPrefix detects when the input value is a single-line /edit (or +// alias) invocation and returns the path-portion the user has typed so far. +// +// Returns: +// - cmdLen: byte offset where the path argument begins (i.e. length of +// the matched command token, including its trailing space) +// - pathPrefix: text the user has typed after the command token +// - ok: true when the value matches one of the /edit triggers +// +// Multi-line values never match — /edit only makes sense as a single line. +func ExtractEditPrefix(value string) (cmdLen int, pathPrefix string, ok bool) { + if strings.Contains(value, "\n") { + return 0, "", false + } + for _, p := range editTriggerPrefixes { + if strings.HasPrefix(value, p) { + return len(p), value[len(p):], true + } + } + return 0, "", false +} + // GetFileSuggestions returns file/directory suggestions matching the given // prefix. It tries `git ls-files` first (fast, respects .gitignore), then // falls back to a simple directory walk. diff --git a/internal/ui/input.go b/internal/ui/input.go index 38ab4b5c..2f6c982a 100644 --- a/internal/ui/input.go +++ b/internal/ui/input.go @@ -55,10 +55,16 @@ type InputComponent struct { // file path, the popup shows file/directory suggestions from the cwd. fileMode bool // true when showing @file completions filePrefix string // current text after @ being matched - fileAtStartIdx int // byte offset of @ in the textarea value + fileAtStartIdx int // byte offset of @ (or path start in /edit mode) in the textarea value fileSuggestions []FileSuggestion // backing storage for file entries fileSynthCmds []commands.SlashCommand // synthetic commands.SlashCommands wrapping file entries + // fileEditMode is true when fileMode was activated by the /edit slash + // command rather than an @ trigger. Selecting a file submits the line + // (running $EDITOR on it); selecting a directory drills further like @ + // does. MCP resources are excluded in this mode. + fileEditMode bool + // cwd is the working directory used for @file path resolution and // autocomplete suggestions. Set by the parent via SetCwd. cwd string @@ -452,10 +458,17 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { s.showPopup = false s.fileMode = false + s.fileEditMode = false } } else if len(lines) == 1 && strings.HasPrefix(lines[0], "/") { s.fileMode = false - if !strings.Contains(lines[0], " ") { + s.fileEditMode = false + if cmdLen, pathPrefix, isEdit := ExtractEditPrefix(lines[0]); isEdit { + // /edit fuzzy-file picker. Behaves like @ except + // MCP resources are excluded and selecting a file + // submits the line (running $EDITOR). + s.updateEditFilePopup(cmdLen, pathPrefix) + } else if !strings.Contains(lines[0], " ") { // Command name completion. s.showPopup = true s.argMode = false @@ -475,6 +488,7 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.showPopup = false s.argMode = false s.fileMode = false + s.fileEditMode = false } } return s, cmd @@ -959,6 +973,7 @@ func (s *InputComponent) Clear() bool { s.showPopup = false s.argMode = false s.fileMode = false + s.fileEditMode = false s.browsingHistory = false s.savedInput = "" return hadContent @@ -968,6 +983,11 @@ func (s *InputComponent) Clear() bool { // file or MCP resource suggestion. For directories, it keeps the popup open // for further drilling. For files and resources, it closes the popup and adds // a trailing space. +// +// When fileEditMode is active the same path-replacement happens against the +// /edit (or alias) command prefix instead of an @ trigger. Selecting a file +// also arms submitNext so the next tick runs $EDITOR on it; selecting a +// directory keeps the popup open for drill-down. func (s *InputComponent) applyFileCompletion(idx int) { if idx >= len(s.fileSuggestions) { return @@ -986,7 +1006,17 @@ func (s *InputComponent) applyFileCompletion(idx int) { beforeAt := lastLine[:s.fileAtStartIdx] var replacement string - if suggestion.IsMCPResource { + switch { + case s.fileEditMode: + // /edit path mode — no @ prefix; the path is the bare argument. + // MCP resources are excluded upstream, so only file/dir entries reach here. + needsQuote := strings.Contains(suggestion.RelPath, " ") + if needsQuote { + replacement = `"` + suggestion.RelPath + `"` + } else { + replacement = suggestion.RelPath + } + case suggestion.IsMCPResource: // MCP resources use @mcp:server:uri format. // Quote if the URI contains spaces. ref := "mcp:" + suggestion.MCPServerName + ":" + suggestion.MCPResourceURI @@ -996,7 +1026,7 @@ func (s *InputComponent) applyFileCompletion(idx int) { replacement = "@" + ref } replacement += " " - } else { + default: needsQuote := strings.Contains(suggestion.RelPath, " ") if needsQuote { replacement = `@"` + suggestion.RelPath + `"` @@ -1022,9 +1052,61 @@ func (s *InputComponent) applyFileCompletion(idx int) { if suggestion.IsDir && !suggestion.IsMCPResource { // Keep popup open — trigger a refresh for the new directory. s.lastValue = "" // force re-evaluation on next update tick - } else { - s.showPopup = false - s.fileMode = false - s.selected = 0 + return + } + + s.showPopup = false + s.fileMode = false + s.selected = 0 + + if s.fileEditMode { + // A file was selected via /edit — submit on the next tick so the + // popup dismisses cleanly before $EDITOR takes the terminal. + s.fileEditMode = false + s.submitNext = true } } + +// updateEditFilePopup queries the file-suggestion engine for the /edit path +// prefix and populates the popup state. cmdLen is the byte offset of the path +// argument within the current line (i.e. length of "/edit " or "/ed "). +// Directories are kept so the user can drill down; MCP resources are skipped. +func (s *InputComponent) updateEditFilePopup(cmdLen int, pathPrefix string) { + var suggestions []FileSuggestion + if s.cwd != "" { + suggestions = GetFileSuggestions(pathPrefix, s.cwd) + } + if len(suggestions) == 0 { + s.showPopup = false + s.fileMode = false + s.fileEditMode = false + return + } + + sort.Slice(suggestions, func(i, j int) bool { + return suggestions[i].Score > suggestions[j].Score + }) + if len(suggestions) > maxFileSuggestions { + suggestions = suggestions[:maxFileSuggestions] + } + + s.showPopup = true + s.fileMode = true + s.fileEditMode = true + s.argMode = false + s.filePrefix = pathPrefix + s.fileAtStartIdx = cmdLen + s.fileSuggestions = suggestions + s.fileSynthCmds = make([]commands.SlashCommand, len(suggestions)) + s.filtered = make([]FuzzyMatch, len(suggestions)) + for i, fs := range suggestions { + name := fs.RelPath + desc := "" + if fs.IsDir { + desc = "directory" + } + s.fileSynthCmds[i] = commands.SlashCommand{Name: name, Description: desc} + s.filtered[i] = FuzzyMatch{Command: &s.fileSynthCmds[i], Score: fs.Score} + } + s.selected = 0 +} diff --git a/internal/ui/model.go b/internal/ui/model.go index a5df0b5e..ce4d1f54 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strings" "time" @@ -2411,6 +2412,16 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.layoutDirty = true } + case editFileMsg: + // User returned from $EDITOR after `/edit `. The file was + // edited directly on disk — no textarea changes. Report the result. + if msg.err != nil { + m.printSystemMessage(fmt.Sprintf("Editor exited with error: %v", msg.err)) + } else { + m.printSystemMessage(fmt.Sprintf("Edited `%s`", msg.path)) + } + m.layoutDirty = true + case extReloadResultMsg: if msg.err != nil { m.printSystemMessage(fmt.Sprintf("Extension reload failed: %v", msg.err)) @@ -3277,6 +3288,8 @@ func (m *AppModel) handleSlashCommand(sc *commands.SlashCommand, args string) te return m.handleExportCommand(args) case "/copy": return m.handleCopyCommand() + case "/edit": + return m.handleEditCommand(args) case "/share": return m.handleShareCommand() case "/import": @@ -3702,6 +3715,7 @@ func (m *AppModel) printHelpMessage() { "- `/compact [instructions]`: Summarise older messages to free context space\n" + "- `/clear`: Clear message history\n" + "- `/copy`: Copy the last message to the system clipboard\n" + + "- `/edit [path]`: Open a file in `$EDITOR` (fuzzy-find from cwd)\n" + "- `/export [path]`: Export session as JSONL\n" + "- `/import `: Import session from JSONL file\n" + "- `/reset-usage`: Reset usage statistics\n" + @@ -4552,6 +4566,68 @@ func (m *AppModel) handleCopyCommand() tea.Cmd { return clipboard.CopyToClipboard(text) } +// handleEditCommand opens the supplied path in $EDITOR via tea.ExecProcess, +// pausing the TUI for the duration of the editor session. The path is +// resolved relative to cwd; ~/ and absolute paths are honoured. Non-existent +// paths are allowed — most editors will create the file on save. +// +// On exit an editFileMsg is emitted with the resolved path (or error) so the +// Update loop can report the result. The textarea is not touched — use +// Ctrl+X e if you want to round-trip a prompt through $EDITOR instead. +func (m *AppModel) handleEditCommand(args string) tea.Cmd { + path := strings.TrimSpace(args) + if path == "" { + m.printSystemMessage("Usage: `/edit ` — or type `/edit ` and pick a file from the popup.") + return nil + } + + // Strip optional surrounding double-quotes (the autocomplete inserts + // these when a path contains spaces). + if len(path) >= 2 && strings.HasPrefix(path, `"`) && strings.HasSuffix(path, `"`) { + path = path[1 : len(path)-1] + } + + // Resolve ~/, relative, and absolute paths against cwd. + resolved := path + if strings.HasPrefix(resolved, "~/") { + if home, err := os.UserHomeDir(); err == nil { + resolved = filepath.Join(home, resolved[2:]) + } + } + if !filepath.IsAbs(resolved) { + cwd, err := os.Getwd() + if err == nil { + resolved = filepath.Join(cwd, resolved) + } + } + resolved = filepath.Clean(resolved) + + // Reject paths that exist but are directories — $EDITOR semantics vary. + if info, err := os.Stat(resolved); err == nil && info.IsDir() { + m.printSystemMessage(fmt.Sprintf("`%s` is a directory, not a file.", resolved)) + return nil + } + + editorApp := os.Getenv("VISUAL") + if editorApp == "" { + editorApp = os.Getenv("EDITOR") + } + if editorApp == "" { + m.printSystemMessage("Set `$EDITOR` or `$VISUAL` to use `/edit`") + return nil + } + + editorCmd, cmdErr := editor.Command(editorApp, resolved) + if cmdErr != nil { + m.printSystemMessage(fmt.Sprintf("Failed to open editor: %v", cmdErr)) + return nil + } + + return tea.ExecProcess(editorCmd, func(err error) tea.Msg { + return editFileMsg{path: resolved, err: err} + }) +} + // handleExportCommand exports the current session to a file. // Usage: /export — copies the JSONL file to cwd with a descriptive name. // @@ -4962,6 +5038,14 @@ type externalEditorMsg struct { err error } +// editFileMsg is sent when the user returns from $EDITOR after invoking the +// /edit slash command on a specific file. Unlike externalEditorMsg, no text +// is read back — the user edited the file directly on disk. +type editFileMsg struct { + path string + err error +} + // shareResultMsg carries the result of an async gist upload. type shareResultMsg struct { err error