diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index ad11137..f7b1378 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -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] {