mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +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",
|
Category: "System",
|
||||||
Aliases: []string{"/cp"},
|
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",
|
Name: "/export",
|
||||||
Description: "Export session (JSONL by default, or /export path.jsonl)",
|
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
|
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
|
// GetFileSuggestions returns file/directory suggestions matching the given
|
||||||
// prefix. It tries `git ls-files` first (fast, respects .gitignore), then
|
// prefix. It tries `git ls-files` first (fast, respects .gitignore), then
|
||||||
// falls back to a simple directory walk.
|
// 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.
|
// file path, the popup shows file/directory suggestions from the cwd.
|
||||||
fileMode bool // true when showing @file completions
|
fileMode bool // true when showing @file completions
|
||||||
filePrefix string // current text after @ being matched
|
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
|
fileSuggestions []FileSuggestion // backing storage for file entries
|
||||||
fileSynthCmds []commands.SlashCommand // synthetic commands.SlashCommands wrapping 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
|
// cwd is the working directory used for @file path resolution and
|
||||||
// autocomplete suggestions. Set by the parent via SetCwd.
|
// autocomplete suggestions. Set by the parent via SetCwd.
|
||||||
cwd string
|
cwd string
|
||||||
@@ -452,10 +458,17 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
} else {
|
} else {
|
||||||
s.showPopup = false
|
s.showPopup = false
|
||||||
s.fileMode = false
|
s.fileMode = false
|
||||||
|
s.fileEditMode = false
|
||||||
}
|
}
|
||||||
} else if len(lines) == 1 && strings.HasPrefix(lines[0], "/") {
|
} else if len(lines) == 1 && strings.HasPrefix(lines[0], "/") {
|
||||||
s.fileMode = false
|
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.
|
// Command name completion.
|
||||||
s.showPopup = true
|
s.showPopup = true
|
||||||
s.argMode = false
|
s.argMode = false
|
||||||
@@ -475,6 +488,7 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
s.showPopup = false
|
s.showPopup = false
|
||||||
s.argMode = false
|
s.argMode = false
|
||||||
s.fileMode = false
|
s.fileMode = false
|
||||||
|
s.fileEditMode = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s, cmd
|
return s, cmd
|
||||||
@@ -959,6 +973,7 @@ func (s *InputComponent) Clear() bool {
|
|||||||
s.showPopup = false
|
s.showPopup = false
|
||||||
s.argMode = false
|
s.argMode = false
|
||||||
s.fileMode = false
|
s.fileMode = false
|
||||||
|
s.fileEditMode = false
|
||||||
s.browsingHistory = false
|
s.browsingHistory = false
|
||||||
s.savedInput = ""
|
s.savedInput = ""
|
||||||
return hadContent
|
return hadContent
|
||||||
@@ -968,6 +983,11 @@ func (s *InputComponent) Clear() bool {
|
|||||||
// file or MCP resource suggestion. For directories, it keeps the popup open
|
// 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
|
// for further drilling. For files and resources, it closes the popup and adds
|
||||||
// a trailing space.
|
// 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) {
|
func (s *InputComponent) applyFileCompletion(idx int) {
|
||||||
if idx >= len(s.fileSuggestions) {
|
if idx >= len(s.fileSuggestions) {
|
||||||
return
|
return
|
||||||
@@ -986,7 +1006,17 @@ func (s *InputComponent) applyFileCompletion(idx int) {
|
|||||||
beforeAt := lastLine[:s.fileAtStartIdx]
|
beforeAt := lastLine[:s.fileAtStartIdx]
|
||||||
|
|
||||||
var replacement string
|
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.
|
// MCP resources use @mcp:server:uri format.
|
||||||
// Quote if the URI contains spaces.
|
// Quote if the URI contains spaces.
|
||||||
ref := "mcp:" + suggestion.MCPServerName + ":" + suggestion.MCPResourceURI
|
ref := "mcp:" + suggestion.MCPServerName + ":" + suggestion.MCPResourceURI
|
||||||
@@ -996,7 +1026,7 @@ func (s *InputComponent) applyFileCompletion(idx int) {
|
|||||||
replacement = "@" + ref
|
replacement = "@" + ref
|
||||||
}
|
}
|
||||||
replacement += " "
|
replacement += " "
|
||||||
} else {
|
default:
|
||||||
needsQuote := strings.Contains(suggestion.RelPath, " ")
|
needsQuote := strings.Contains(suggestion.RelPath, " ")
|
||||||
if needsQuote {
|
if needsQuote {
|
||||||
replacement = `@"` + suggestion.RelPath + `"`
|
replacement = `@"` + suggestion.RelPath + `"`
|
||||||
@@ -1022,9 +1052,61 @@ func (s *InputComponent) applyFileCompletion(idx int) {
|
|||||||
if suggestion.IsDir && !suggestion.IsMCPResource {
|
if suggestion.IsDir && !suggestion.IsMCPResource {
|
||||||
// Keep popup open — trigger a refresh for the new directory.
|
// Keep popup open — trigger a refresh for the new directory.
|
||||||
s.lastValue = "" // force re-evaluation on next update tick
|
s.lastValue = "" // force re-evaluation on next update tick
|
||||||
} else {
|
return
|
||||||
s.showPopup = false
|
}
|
||||||
s.fileMode = false
|
|
||||||
s.selected = 0
|
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"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -2411,6 +2412,16 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.layoutDirty = true
|
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:
|
case extReloadResultMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.printSystemMessage(fmt.Sprintf("Extension reload failed: %v", msg.err))
|
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)
|
return m.handleExportCommand(args)
|
||||||
case "/copy":
|
case "/copy":
|
||||||
return m.handleCopyCommand()
|
return m.handleCopyCommand()
|
||||||
|
case "/edit":
|
||||||
|
return m.handleEditCommand(args)
|
||||||
case "/share":
|
case "/share":
|
||||||
return m.handleShareCommand()
|
return m.handleShareCommand()
|
||||||
case "/import":
|
case "/import":
|
||||||
@@ -3702,6 +3715,7 @@ func (m *AppModel) printHelpMessage() {
|
|||||||
"- `/compact [instructions]`: Summarise older messages to free context space\n" +
|
"- `/compact [instructions]`: Summarise older messages to free context space\n" +
|
||||||
"- `/clear`: Clear message history\n" +
|
"- `/clear`: Clear message history\n" +
|
||||||
"- `/copy`: Copy the last message to the system clipboard\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" +
|
"- `/export [path]`: Export session as JSONL\n" +
|
||||||
"- `/import <path.jsonl>`: Import session from JSONL file\n" +
|
"- `/import <path.jsonl>`: Import session from JSONL file\n" +
|
||||||
"- `/reset-usage`: Reset usage statistics\n" +
|
"- `/reset-usage`: Reset usage statistics\n" +
|
||||||
@@ -4552,6 +4566,68 @@ func (m *AppModel) handleCopyCommand() tea.Cmd {
|
|||||||
return clipboard.CopyToClipboard(text)
|
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.
|
// handleExportCommand exports the current session to a file.
|
||||||
// Usage: /export — copies the JSONL file to cwd with a descriptive name.
|
// Usage: /export — copies the JSONL file to cwd with a descriptive name.
|
||||||
//
|
//
|
||||||
@@ -4962,6 +5038,14 @@ type externalEditorMsg struct {
|
|||||||
err error
|
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.
|
// shareResultMsg carries the result of an async gist upload.
|
||||||
type shareResultMsg struct {
|
type shareResultMsg struct {
|
||||||
err error
|
err error
|
||||||
|
|||||||
Reference in New Issue
Block a user