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.- commit
- 8096aebd0c03f2f36c5ee75526fb4908189cc60b
- author
- patriceckhart · · 2026-05-19 16:44 UTC
- parent
- 4f008e8
- signature
- unsigned
1 file · +193 -0
M internal/agent/modes/interactive.go +193 -0
| @@ -1928,6 +1928,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1928 | 1928 | } | |
| 1929 | 1929 | } | |
| 1930 | 1930 | ||
| 1931 | + | // Tab-complete a path token in the editor when no popup is open. | |
| 1932 | + | // Recognises tokens that look like paths (start with ~, /, ./, ../ | |
| 1933 | + | // or contain a slash); shell-style completion expands ~, lists the | |
| 1934 | + | // parent dir, and completes the basename to the longest common | |
| 1935 | + | // prefix. Single match: full replace and trailing / for dirs. | |
| 1936 | + | // No match: no-op. Plain bare words (no slash, no tilde) fall | |
| 1937 | + | // through so Tab keeps its current no-op behaviour outside paths. | |
| 1938 | + | if k.Kind == tui.KeyTab && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) { | |
| 1939 | + | if i.tryPathTabComplete() { | |
| 1940 | + | return false | |
| 1941 | + | } | |
| 1942 | + | } | |
| 1943 | + | ||
| 1931 | 1944 | if i.handleInputHistoryKey(k) { | |
| 1932 | 1945 | return false | |
| 1933 | 1946 | } | |
| @@ -2399,6 +2412,186 @@ func buildStudyPrompt(arg, cwd string) string { | |||
| 2399 | 2412 | return "Read and understand everything in the directory " + display + "." | |
| 2400 | 2413 | } | |
| 2401 | 2414 | ||
| 2415 | + | // tryPathTabComplete looks at the editor's current value, finds the | |
| 2416 | + | // path-like token immediately before the cursor (in this codebase the | |
| 2417 | + | // cursor is always at the end of the buffer after a keystroke, so | |
| 2418 | + | // "before the cursor" is the trailing non-whitespace run), and rewrites | |
| 2419 | + | // it to its shell-style completion against the filesystem. | |
| 2420 | + | // | |
| 2421 | + | // Returns true when it consumed the Tab keystroke (token recognised, | |
| 2422 | + | // completion attempted — even if no candidates matched, the keystroke | |
| 2423 | + | // is still consumed so it doesn't insert a literal tab character). | |
| 2424 | + | // Returns false when the token doesn't look like a path; the caller | |
| 2425 | + | // then lets Tab fall through to its normal no-op. | |
| 2426 | + | // | |
| 2427 | + | // Recognised path shapes: | |
| 2428 | + | // - ~ or ~/foo expanded via os.UserHomeDir() | |
| 2429 | + | // - /abs/path or /abs/path/foo absolute | |
| 2430 | + | // - ./foo, ../foo, foo/bar relative to i.cfg.CWD | |
| 2431 | + | // | |
| 2432 | + | // A bare word like "hello" is not treated as a path so plain text | |
| 2433 | + | // keeps Tab as a literal no-op. | |
| 2434 | + | func (i *Interactive) tryPathTabComplete() bool { | |
| 2435 | + | val := i.ed.Value() | |
| 2436 | + | // Find the trailing run of non-whitespace. | |
| 2437 | + | start := len(val) | |
| 2438 | + | for start > 0 { | |
| 2439 | + | r := val[start-1] | |
| 2440 | + | if r == ' ' || r == '\t' || r == '\n' { | |
| 2441 | + | break | |
| 2442 | + | } | |
| 2443 | + | start-- | |
| 2444 | + | } | |
| 2445 | + | token := val[start:] | |
| 2446 | + | if token == "" { | |
| 2447 | + | return false | |
| 2448 | + | } | |
| 2449 | + | if !looksLikePathToken(token) { | |
| 2450 | + | return false | |
| 2451 | + | } | |
| 2452 | + | ||
| 2453 | + | // Resolve the absolute parent directory + base prefix to match. | |
| 2454 | + | parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, i.cfg.CWD) | |
| 2455 | + | if !ok { | |
| 2456 | + | return true | |
| 2457 | + | } | |
| 2458 | + | entries, err := os.ReadDir(parentAbs) | |
| 2459 | + | if err != nil { | |
| 2460 | + | return true | |
| 2461 | + | } | |
| 2462 | + | var names []string | |
| 2463 | + | var isDir []bool | |
| 2464 | + | for _, e := range entries { | |
| 2465 | + | name := e.Name() | |
| 2466 | + | if !strings.HasPrefix(name, basePrefix) { | |
| 2467 | + | continue | |
| 2468 | + | } | |
| 2469 | + | // Hide dotfiles unless the user explicitly typed a leading dot, | |
| 2470 | + | // mirroring bash's default behaviour. | |
| 2471 | + | if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") { | |
| 2472 | + | continue | |
| 2473 | + | } | |
| 2474 | + | names = append(names, name) | |
| 2475 | + | isDir = append(isDir, e.IsDir()) | |
| 2476 | + | } | |
| 2477 | + | if len(names) == 0 { | |
| 2478 | + | return true | |
| 2479 | + | } | |
| 2480 | + | ||
| 2481 | + | var completed string | |
| 2482 | + | var completedIsDir bool | |
| 2483 | + | if len(names) == 1 { | |
| 2484 | + | completed = names[0] | |
| 2485 | + | completedIsDir = isDir[0] | |
| 2486 | + | } else { | |
| 2487 | + | completed = longestCommonPrefix(names) | |
| 2488 | + | if completed == basePrefix { | |
| 2489 | + | // Already at the deepest unambiguous prefix; nothing to add. | |
| 2490 | + | return true | |
| 2491 | + | } | |
| 2492 | + | } | |
| 2493 | + | ||
| 2494 | + | // Build the replacement token in the same display form the user | |
| 2495 | + | // typed (preserve ~ vs absolute vs relative). | |
| 2496 | + | newToken := displayParent + completed | |
| 2497 | + | if len(names) == 1 && completedIsDir { | |
| 2498 | + | newToken += "/" | |
| 2499 | + | } | |
| 2500 | + | ||
| 2501 | + | i.ed.SetValue(val[:start] + newToken) | |
| 2502 | + | i.invalidate() | |
| 2503 | + | return true | |
| 2504 | + | } | |
| 2505 | + | ||
| 2506 | + | // looksLikePathToken reports whether tok is shaped like a filesystem | |
| 2507 | + | // path. Paths must either start with ~, /, ./, ../ or contain a /. | |
| 2508 | + | // Plain words are excluded so Tab on "hello" stays a no-op. | |
| 2509 | + | func looksLikePathToken(tok string) bool { | |
| 2510 | + | if tok == "" { | |
| 2511 | + | return false | |
| 2512 | + | } | |
| 2513 | + | if tok[0] == '~' || tok[0] == '/' { | |
| 2514 | + | return true | |
| 2515 | + | } | |
| 2516 | + | if strings.HasPrefix(tok, "./") || strings.HasPrefix(tok, "../") { | |
| 2517 | + | return true | |
| 2518 | + | } | |
| 2519 | + | return strings.Contains(tok, "/") | |
| 2520 | + | } | |
| 2521 | + | ||
| 2522 | + | // resolvePathTabToken splits tok into (absolute parent dir, basename | |
| 2523 | + | // prefix to match, display-form parent the user typed). ok is false | |
| 2524 | + | // when the parent dir can't be resolved (e.g. ~ with no $HOME). | |
| 2525 | + | func resolvePathTabToken(tok, cwd string) (parentAbs, basePrefix, displayParent string, ok bool) { | |
| 2526 | + | // Detect ~ expansion. | |
| 2527 | + | expanded := tok | |
| 2528 | + | homePrefix := "" | |
| 2529 | + | if tok == "~" { | |
| 2530 | + | home, err := os.UserHomeDir() | |
| 2531 | + | if err != nil || home == "" { | |
| 2532 | + | return "", "", "", false | |
| 2533 | + | } | |
| 2534 | + | // "~" alone: complete in $HOME. parent = home, base = "". | |
| 2535 | + | return home, "", "~/", true | |
| 2536 | + | } | |
| 2537 | + | if strings.HasPrefix(tok, "~/") { | |
| 2538 | + | home, err := os.UserHomeDir() | |
| 2539 | + | if err != nil || home == "" { | |
| 2540 | + | return "", "", "", false | |
| 2541 | + | } | |
| 2542 | + | expanded = home + tok[1:] | |
| 2543 | + | homePrefix = "~" | |
| 2544 | + | } | |
| 2545 | + | ||
| 2546 | + | dir, base := splitDirBase(expanded) | |
| 2547 | + | if !filepath.IsAbs(dir) { | |
| 2548 | + | dir = filepath.Join(cwd, dir) | |
| 2549 | + | } | |
| 2550 | + | ||
| 2551 | + | // Reconstruct the display form the user typed for the parent, | |
| 2552 | + | // keeping ~ when they used it. The base is dropped — the caller | |
| 2553 | + | // substitutes the completed name. | |
| 2554 | + | display := tok[:len(tok)-len(base)] | |
| 2555 | + | if homePrefix != "" && !strings.HasPrefix(display, "~") { | |
| 2556 | + | display = homePrefix + display[len(homePrefix):] | |
| 2557 | + | } | |
| 2558 | + | return dir, base, display, true | |
| 2559 | + | } | |
| 2560 | + | ||
| 2561 | + | // splitDirBase is like filepath.Split but preserves the trailing | |
| 2562 | + | // slash convention: "foo" => (".", "foo"); "foo/" => ("foo", ""); | |
| 2563 | + | // "a/b" => ("a/", "b"); "/" => ("/", ""). Returned dir always has | |
| 2564 | + | // the trailing separator when non-empty so callers can rebuild paths | |
| 2565 | + | // by concatenation. | |
| 2566 | + | func splitDirBase(p string) (dir, base string) { | |
| 2567 | + | if p == "" { | |
| 2568 | + | return ".", "" | |
| 2569 | + | } | |
| 2570 | + | i := strings.LastIndex(p, "/") | |
| 2571 | + | if i < 0 { | |
| 2572 | + | return ".", p | |
| 2573 | + | } | |
| 2574 | + | return p[:i+1], p[i+1:] | |
| 2575 | + | } | |
| 2576 | + | ||
| 2577 | + | func longestCommonPrefix(ss []string) string { | |
| 2578 | + | if len(ss) == 0 { | |
| 2579 | + | return "" | |
| 2580 | + | } | |
| 2581 | + | prefix := ss[0] | |
| 2582 | + | for _, s := range ss[1:] { | |
| 2583 | + | n := 0 | |
| 2584 | + | for n < len(prefix) && n < len(s) && prefix[n] == s[n] { | |
| 2585 | + | n++ | |
| 2586 | + | } | |
| 2587 | + | prefix = prefix[:n] | |
| 2588 | + | if prefix == "" { | |
| 2589 | + | return "" | |
| 2590 | + | } | |
| 2591 | + | } | |
| 2592 | + | return prefix | |
| 2593 | + | } | |
| 2594 | + | ||
| 2402 | 2595 | func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |
| 2403 | 2596 | parts := strings.Fields(cmd) | |
| 2404 | 2597 | switch parts[0] { | |
| @@ -1928,6 +1928,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 1928 | } | 1928 | } | ||
| 1929 | } | 1929 | } | ||
| 1930 | 1930 | ||||
| 1931 | + | // Tab-complete a path token in the editor when no popup is open. | |||
| 1932 | + | // Recognises tokens that look like paths (start with ~, /, ./, ../ | |||
| 1933 | + | // or contain a slash); shell-style completion expands ~, lists the | |||
| 1934 | + | // parent dir, and completes the basename to the longest common | |||
| 1935 | + | // prefix. Single match: full replace and trailing / for dirs. | |||
| 1936 | + | // No match: no-op. Plain bare words (no slash, no tilde) fall | |||
| 1937 | + | // through so Tab keeps its current no-op behaviour outside paths. | |||
| 1938 | + | if k.Kind == tui.KeyTab && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) { | |||
| 1939 | + | if i.tryPathTabComplete() { | |||
| 1940 | + | return false | |||
| 1941 | + | } | |||
| 1942 | + | } | |||
| 1943 | + | ||||
| 1931 | if i.handleInputHistoryKey(k) { | 1944 | if i.handleInputHistoryKey(k) { | ||
| 1932 | return false | 1945 | return false | ||
| 1933 | } | 1946 | } | ||
| @@ -2399,6 +2412,186 @@ func buildStudyPrompt(arg, cwd string) string { | |||||
| 2399 | return "Read and understand everything in the directory " + display + "." | 2412 | return "Read and understand everything in the directory " + display + "." | ||
| 2400 | } | 2413 | } | ||
| 2401 | 2414 | ||||
| 2415 | + | // tryPathTabComplete looks at the editor's current value, finds the | |||
| 2416 | + | // path-like token immediately before the cursor (in this codebase the | |||
| 2417 | + | // cursor is always at the end of the buffer after a keystroke, so | |||
| 2418 | + | // "before the cursor" is the trailing non-whitespace run), and rewrites | |||
| 2419 | + | // it to its shell-style completion against the filesystem. | |||
| 2420 | + | // | |||
| 2421 | + | // Returns true when it consumed the Tab keystroke (token recognised, | |||
| 2422 | + | // completion attempted — even if no candidates matched, the keystroke | |||
| 2423 | + | // is still consumed so it doesn't insert a literal tab character). | |||
| 2424 | + | // Returns false when the token doesn't look like a path; the caller | |||
| 2425 | + | // then lets Tab fall through to its normal no-op. | |||
| 2426 | + | // | |||
| 2427 | + | // Recognised path shapes: | |||
| 2428 | + | // - ~ or ~/foo expanded via os.UserHomeDir() | |||
| 2429 | + | // - /abs/path or /abs/path/foo absolute | |||
| 2430 | + | // - ./foo, ../foo, foo/bar relative to i.cfg.CWD | |||
| 2431 | + | // | |||
| 2432 | + | // A bare word like "hello" is not treated as a path so plain text | |||
| 2433 | + | // keeps Tab as a literal no-op. | |||
| 2434 | + | func (i *Interactive) tryPathTabComplete() bool { | |||
| 2435 | + | val := i.ed.Value() | |||
| 2436 | + | // Find the trailing run of non-whitespace. | |||
| 2437 | + | start := len(val) | |||
| 2438 | + | for start > 0 { | |||
| 2439 | + | r := val[start-1] | |||
| 2440 | + | if r == ' ' || r == '\t' || r == '\n' { | |||
| 2441 | + | break | |||
| 2442 | + | } | |||
| 2443 | + | start-- | |||
| 2444 | + | } | |||
| 2445 | + | token := val[start:] | |||
| 2446 | + | if token == "" { | |||
| 2447 | + | return false | |||
| 2448 | + | } | |||
| 2449 | + | if !looksLikePathToken(token) { | |||
| 2450 | + | return false | |||
| 2451 | + | } | |||
| 2452 | + | ||||
| 2453 | + | // Resolve the absolute parent directory + base prefix to match. | |||
| 2454 | + | parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, i.cfg.CWD) | |||
| 2455 | + | if !ok { | |||
| 2456 | + | return true | |||
| 2457 | + | } | |||
| 2458 | + | entries, err := os.ReadDir(parentAbs) | |||
| 2459 | + | if err != nil { | |||
| 2460 | + | return true | |||
| 2461 | + | } | |||
| 2462 | + | var names []string | |||
| 2463 | + | var isDir []bool | |||
| 2464 | + | for _, e := range entries { | |||
| 2465 | + | name := e.Name() | |||
| 2466 | + | if !strings.HasPrefix(name, basePrefix) { | |||
| 2467 | + | continue | |||
| 2468 | + | } | |||
| 2469 | + | // Hide dotfiles unless the user explicitly typed a leading dot, | |||
| 2470 | + | // mirroring bash's default behaviour. | |||
| 2471 | + | if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") { | |||
| 2472 | + | continue | |||
| 2473 | + | } | |||
| 2474 | + | names = append(names, name) | |||
| 2475 | + | isDir = append(isDir, e.IsDir()) | |||
| 2476 | + | } | |||
| 2477 | + | if len(names) == 0 { | |||
| 2478 | + | return true | |||
| 2479 | + | } | |||
| 2480 | + | ||||
| 2481 | + | var completed string | |||
| 2482 | + | var completedIsDir bool | |||
| 2483 | + | if len(names) == 1 { | |||
| 2484 | + | completed = names[0] | |||
| 2485 | + | completedIsDir = isDir[0] | |||
| 2486 | + | } else { | |||
| 2487 | + | completed = longestCommonPrefix(names) | |||
| 2488 | + | if completed == basePrefix { | |||
| 2489 | + | // Already at the deepest unambiguous prefix; nothing to add. | |||
| 2490 | + | return true | |||
| 2491 | + | } | |||
| 2492 | + | } | |||
| 2493 | + | ||||
| 2494 | + | // Build the replacement token in the same display form the user | |||
| 2495 | + | // typed (preserve ~ vs absolute vs relative). | |||
| 2496 | + | newToken := displayParent + completed | |||
| 2497 | + | if len(names) == 1 && completedIsDir { | |||
| 2498 | + | newToken += "/" | |||
| 2499 | + | } | |||
| 2500 | + | ||||
| 2501 | + | i.ed.SetValue(val[:start] + newToken) | |||
| 2502 | + | i.invalidate() | |||
| 2503 | + | return true | |||
| 2504 | + | } | |||
| 2505 | + | ||||
| 2506 | + | // looksLikePathToken reports whether tok is shaped like a filesystem | |||
| 2507 | + | // path. Paths must either start with ~, /, ./, ../ or contain a /. | |||
| 2508 | + | // Plain words are excluded so Tab on "hello" stays a no-op. | |||
| 2509 | + | func looksLikePathToken(tok string) bool { | |||
| 2510 | + | if tok == "" { | |||
| 2511 | + | return false | |||
| 2512 | + | } | |||
| 2513 | + | if tok[0] == '~' || tok[0] == '/' { | |||
| 2514 | + | return true | |||
| 2515 | + | } | |||
| 2516 | + | if strings.HasPrefix(tok, "./") || strings.HasPrefix(tok, "../") { | |||
| 2517 | + | return true | |||
| 2518 | + | } | |||
| 2519 | + | return strings.Contains(tok, "/") | |||
| 2520 | + | } | |||
| 2521 | + | ||||
| 2522 | + | // resolvePathTabToken splits tok into (absolute parent dir, basename | |||
| 2523 | + | // prefix to match, display-form parent the user typed). ok is false | |||
| 2524 | + | // when the parent dir can't be resolved (e.g. ~ with no $HOME). | |||
| 2525 | + | func resolvePathTabToken(tok, cwd string) (parentAbs, basePrefix, displayParent string, ok bool) { | |||
| 2526 | + | // Detect ~ expansion. | |||
| 2527 | + | expanded := tok | |||
| 2528 | + | homePrefix := "" | |||
| 2529 | + | if tok == "~" { | |||
| 2530 | + | home, err := os.UserHomeDir() | |||
| 2531 | + | if err != nil || home == "" { | |||
| 2532 | + | return "", "", "", false | |||
| 2533 | + | } | |||
| 2534 | + | // "~" alone: complete in $HOME. parent = home, base = "". | |||
| 2535 | + | return home, "", "~/", true | |||
| 2536 | + | } | |||
| 2537 | + | if strings.HasPrefix(tok, "~/") { | |||
| 2538 | + | home, err := os.UserHomeDir() | |||
| 2539 | + | if err != nil || home == "" { | |||
| 2540 | + | return "", "", "", false | |||
| 2541 | + | } | |||
| 2542 | + | expanded = home + tok[1:] | |||
| 2543 | + | homePrefix = "~" | |||
| 2544 | + | } | |||
| 2545 | + | ||||
| 2546 | + | dir, base := splitDirBase(expanded) | |||
| 2547 | + | if !filepath.IsAbs(dir) { | |||
| 2548 | + | dir = filepath.Join(cwd, dir) | |||
| 2549 | + | } | |||
| 2550 | + | ||||
| 2551 | + | // Reconstruct the display form the user typed for the parent, | |||
| 2552 | + | // keeping ~ when they used it. The base is dropped — the caller | |||
| 2553 | + | // substitutes the completed name. | |||
| 2554 | + | display := tok[:len(tok)-len(base)] | |||
| 2555 | + | if homePrefix != "" && !strings.HasPrefix(display, "~") { | |||
| 2556 | + | display = homePrefix + display[len(homePrefix):] | |||
| 2557 | + | } | |||
| 2558 | + | return dir, base, display, true | |||
| 2559 | + | } | |||
| 2560 | + | ||||
| 2561 | + | // splitDirBase is like filepath.Split but preserves the trailing | |||
| 2562 | + | // slash convention: "foo" => (".", "foo"); "foo/" => ("foo", ""); | |||
| 2563 | + | // "a/b" => ("a/", "b"); "/" => ("/", ""). Returned dir always has | |||
| 2564 | + | // the trailing separator when non-empty so callers can rebuild paths | |||
| 2565 | + | // by concatenation. | |||
| 2566 | + | func splitDirBase(p string) (dir, base string) { | |||
| 2567 | + | if p == "" { | |||
| 2568 | + | return ".", "" | |||
| 2569 | + | } | |||
| 2570 | + | i := strings.LastIndex(p, "/") | |||
| 2571 | + | if i < 0 { | |||
| 2572 | + | return ".", p | |||
| 2573 | + | } | |||
| 2574 | + | return p[:i+1], p[i+1:] | |||
| 2575 | + | } | |||
| 2576 | + | ||||
| 2577 | + | func longestCommonPrefix(ss []string) string { | |||
| 2578 | + | if len(ss) == 0 { | |||
| 2579 | + | return "" | |||
| 2580 | + | } | |||
| 2581 | + | prefix := ss[0] | |||
| 2582 | + | for _, s := range ss[1:] { | |||
| 2583 | + | n := 0 | |||
| 2584 | + | for n < len(prefix) && n < len(s) && prefix[n] == s[n] { | |||
| 2585 | + | n++ | |||
| 2586 | + | } | |||
| 2587 | + | prefix = prefix[:n] | |||
| 2588 | + | if prefix == "" { | |||
| 2589 | + | return "" | |||
| 2590 | + | } | |||
| 2591 | + | } | |||
| 2592 | + | return prefix | |||
| 2593 | + | } | |||
| 2594 | + | ||||
| 2402 | func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | 2595 | func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | ||
| 2403 | parts := strings.Fields(cmd) | 2596 | parts := strings.Fields(cmd) | ||
| 2404 | switch parts[0] { | 2597 | switch parts[0] { | ||