detached 8096aeb

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) {
19281928 }
19291929 }
19301930
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+
19311944 if i.handleInputHistoryKey(k) {
19321945 return false
19331946 }
@@ -2399,6 +2412,186 @@ func buildStudyPrompt(arg, cwd string) string {
23992412 return "Read and understand everything in the directory " + display + "."
24002413 }
24012414
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+
24022595 func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
24032596 parts := strings.Fields(cmd)
24042597 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 false1945 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] {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close