feat(ui): add /edit slash command with fuzzy file picker

- New /edit (alias /ed) opens $EDITOR on a chosen file via tea.ExecProcess
- Typing '/edit ' activates a fuzzy file popup mirroring the @ trigger:
  reuses GetFileSuggestions (git ls-files), supports directory drill-down,
  excludes MCP resources
- Selecting a file auto-submits and runs $EDITOR ($VISUAL preferred);
  on exit prints 'Edited <path>'
- Manual paths supported (~/, relative, absolute); non-existent paths
  pass through so the editor can create them; directories are rejected
- /help updated with the new command
This commit is contained in:
Ed Zynda
2026-06-07 17:10:34 +03:00
parent 06bf6d087a
commit 00eab47218
4 changed files with 210 additions and 8 deletions
+9
View File
@@ -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)",
+27
View File
@@ -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.
+90 -8
View File
@@ -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
}
+84
View File
@@ -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 <path>`. 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 <path.jsonl>`: 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 <path>` — 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