feat(editor): shell-style tab-completion for path tokens

Typing a path-like token and pressing Tab in the editor now completes
it against the filesystem, the way bash / zsh do. No popup, no UI -
the token is rewritten in place.

Recognised shapes:
  ~ or ~/foo       - expanded via os.UserHomeDir(); the displayed
                     token keeps its ~ form after completion
  /abs/path        - absolute
  ./foo, ../foo    - relative to cwd
  foo/bar          - any token containing a slash, relative to cwd

Bare words ('hello', 'fix') without a slash or tilde are still no-ops
on Tab so plain text isn't disturbed.

Behaviour matches a typical shell:
  - one match: full replace, trailing / appended for directories so
    the next Tab can dive in
  - multiple matches: completes to the longest common prefix; if the
    prefix is already what was typed, Tab is a no-op (no second-tab
    'show options' list yet)
  - dotfiles hidden unless the user typed a leading dot

Tab inside the slash-command and @-file popups still does what it did
before; the new path completion only kicks in when neither popup is
active.
This commit is contained in:
patriceckhart 2026-05-19 18:44:01 +02:00
parent 4f008e8871
commit 8096aebd0c

View file

@ -1928,6 +1928,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
}
}
// Tab-complete a path token in the editor when no popup is open.
// Recognises tokens that look like paths (start with ~, /, ./, ../
// or contain a slash); shell-style completion expands ~, lists the
// parent dir, and completes the basename to the longest common
// prefix. Single match: full replace and trailing / for dirs.
// No match: no-op. Plain bare words (no slash, no tilde) fall
// through so Tab keeps its current no-op behaviour outside paths.
if k.Kind == tui.KeyTab && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
if i.tryPathTabComplete() {
return false
}
}
if i.handleInputHistoryKey(k) {
return false
}
@ -2399,6 +2412,186 @@ func buildStudyPrompt(arg, cwd string) string {
return "Read and understand everything in the directory " + display + "."
}
// tryPathTabComplete looks at the editor's current value, finds the
// path-like token immediately before the cursor (in this codebase the
// cursor is always at the end of the buffer after a keystroke, so
// "before the cursor" is the trailing non-whitespace run), and rewrites
// it to its shell-style completion against the filesystem.
//
// Returns true when it consumed the Tab keystroke (token recognised,
// completion attempted — even if no candidates matched, the keystroke
// is still consumed so it doesn't insert a literal tab character).
// Returns false when the token doesn't look like a path; the caller
// then lets Tab fall through to its normal no-op.
//
// Recognised path shapes:
// - ~ or ~/foo expanded via os.UserHomeDir()
// - /abs/path or /abs/path/foo absolute
// - ./foo, ../foo, foo/bar relative to i.cfg.CWD
//
// A bare word like "hello" is not treated as a path so plain text
// keeps Tab as a literal no-op.
func (i *Interactive) tryPathTabComplete() bool {
val := i.ed.Value()
// Find the trailing run of non-whitespace.
start := len(val)
for start > 0 {
r := val[start-1]
if r == ' ' || r == '\t' || r == '\n' {
break
}
start--
}
token := val[start:]
if token == "" {
return false
}
if !looksLikePathToken(token) {
return false
}
// Resolve the absolute parent directory + base prefix to match.
parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, i.cfg.CWD)
if !ok {
return true
}
entries, err := os.ReadDir(parentAbs)
if err != nil {
return true
}
var names []string
var isDir []bool
for _, e := range entries {
name := e.Name()
if !strings.HasPrefix(name, basePrefix) {
continue
}
// Hide dotfiles unless the user explicitly typed a leading dot,
// mirroring bash's default behaviour.
if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") {
continue
}
names = append(names, name)
isDir = append(isDir, e.IsDir())
}
if len(names) == 0 {
return true
}
var completed string
var completedIsDir bool
if len(names) == 1 {
completed = names[0]
completedIsDir = isDir[0]
} else {
completed = longestCommonPrefix(names)
if completed == basePrefix {
// Already at the deepest unambiguous prefix; nothing to add.
return true
}
}
// Build the replacement token in the same display form the user
// typed (preserve ~ vs absolute vs relative).
newToken := displayParent + completed
if len(names) == 1 && completedIsDir {
newToken += "/"
}
i.ed.SetValue(val[:start] + newToken)
i.invalidate()
return true
}
// looksLikePathToken reports whether tok is shaped like a filesystem
// path. Paths must either start with ~, /, ./, ../ or contain a /.
// Plain words are excluded so Tab on "hello" stays a no-op.
func looksLikePathToken(tok string) bool {
if tok == "" {
return false
}
if tok[0] == '~' || tok[0] == '/' {
return true
}
if strings.HasPrefix(tok, "./") || strings.HasPrefix(tok, "../") {
return true
}
return strings.Contains(tok, "/")
}
// resolvePathTabToken splits tok into (absolute parent dir, basename
// prefix to match, display-form parent the user typed). ok is false
// when the parent dir can't be resolved (e.g. ~ with no $HOME).
func resolvePathTabToken(tok, cwd string) (parentAbs, basePrefix, displayParent string, ok bool) {
// Detect ~ expansion.
expanded := tok
homePrefix := ""
if tok == "~" {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return "", "", "", false
}
// "~" alone: complete in $HOME. parent = home, base = "".
return home, "", "~/", true
}
if strings.HasPrefix(tok, "~/") {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return "", "", "", false
}
expanded = home + tok[1:]
homePrefix = "~"
}
dir, base := splitDirBase(expanded)
if !filepath.IsAbs(dir) {
dir = filepath.Join(cwd, dir)
}
// Reconstruct the display form the user typed for the parent,
// keeping ~ when they used it. The base is dropped — the caller
// substitutes the completed name.
display := tok[:len(tok)-len(base)]
if homePrefix != "" && !strings.HasPrefix(display, "~") {
display = homePrefix + display[len(homePrefix):]
}
return dir, base, display, true
}
// splitDirBase is like filepath.Split but preserves the trailing
// slash convention: "foo" => (".", "foo"); "foo/" => ("foo", "");
// "a/b" => ("a/", "b"); "/" => ("/", ""). Returned dir always has
// the trailing separator when non-empty so callers can rebuild paths
// by concatenation.
func splitDirBase(p string) (dir, base string) {
if p == "" {
return ".", ""
}
i := strings.LastIndex(p, "/")
if i < 0 {
return ".", p
}
return p[:i+1], p[i+1:]
}
func longestCommonPrefix(ss []string) string {
if len(ss) == 0 {
return ""
}
prefix := ss[0]
for _, s := range ss[1:] {
n := 0
for n < len(prefix) && n < len(s) && prefix[n] == s[n] {
n++
}
prefix = prefix[:n]
if prefix == "" {
return ""
}
}
return prefix
}
func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
parts := strings.Fields(cmd)
switch parts[0] {