mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
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:
@@ -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)",
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user