detached 7d78d75

Merge pull request #200 from Oscar-Williams/feat/path-completion-choices

feat(tui): show path choices on repeated tab
commit
7d78d75e3092be7a1b221fcb317bfaaa699070b4
author
Patric Eckhart · · 2026-09-23 15:17 UTC
committer
GitHub · · 2026-09-23 15:17 UTC
merge of
ed7e2b4 + c17f06b · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.3.89
3 files · +366 -67
M packages/agent/modes/interactive.go +155 -67
@@ -562,6 +562,7 @@ type Interactive struct {
562562 pendingFork bool
563563 suggest *slashSuggester
564564 fileSuggest *fileSuggester
565+ pathSuggest *pathChoicePopup
565566 spin *spinner
566567
567568 // parkedTurn is the 1-based turn number the viewport is currently
@@ -717,6 +718,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
717718 extPanel: newExtPanelDialog(),
718719 suggest: newSlashSuggester(),
719720 fileSuggest: newFileSuggester(),
721+ pathSuggest: newPathChoicePopup(),
720722 spin: newSpinner(cfg.Theme),
721723 inputHistoryIndex: -1,
722724 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
@@ -1458,6 +1460,8 @@ func (i *Interactive) redraw() {
14581460 suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)
14591461 } else if mainInputFocused && i.fileSuggest.Active(currentInput) {
14601462 suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, cols)
1463+ } else if mainInputFocused && i.pathSuggest.Active(currentInput) {
1464+ suggest = i.pathSuggest.Render(i.cfg.Theme, cols)
14611465 }
14621466
14631467 // Detect overlay close (any dialog or slash/file suggestion popup
@@ -2618,7 +2622,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
26182622 // notes should dismiss on Esc before we even consider the
26192623 // turn. Without these guards, a casual Esc press after
26202624 // running /help on a busy turn rips the turn away.
2621- if i.suggest.Active(i.ed.Value()) || i.fileSuggest.Active(i.ed.Value()) {
2625+ if i.suggest.Active(i.ed.Value()) || i.fileSuggest.Active(i.ed.Value()) || i.pathSuggest.Active(i.ed.Value()) {
26222626 break
26232627 }
26242628 i.mu.Lock()
@@ -2694,7 +2698,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
26942698 // At the editor's top edge, plain Up can browse input history when
26952699 // history browsing is safe/active; otherwise it falls back to chat
26962700 // scrolling, preserving the old single-line scroll behavior.
2697- if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
2701+ if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) && !i.pathSuggest.Active(i.ed.Value()) {
26982702 if i.ed.MoveVertical(-1) {
26992703 i.invalidate()
27002704 return false
@@ -2706,7 +2710,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
27062710 return false
27072711 }
27082712 case tui.KeyDown:
2709- if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
2713+ if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) && !i.pathSuggest.Active(i.ed.Value()) {
27102714 if i.ed.MoveVertical(+1) {
27112715 i.invalidate()
27122716 return false
@@ -2823,6 +2827,30 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
28232827 }
28242828 }
28252829
2830+ // Repeated-Tab path choices use the same transient popup interaction as
2831+ // the slash and @ pickers. Any ordinary editing key invalidates the
2832+ // snapshot and falls through so the editor can update the token.
2833+ if i.pathSuggest.Active(i.ed.Value()) {
2834+ switch k.Kind {
2835+ case tui.KeyUp:
2836+ i.pathSuggest.Up()
2837+ return false
2838+ case tui.KeyDown:
2839+ i.pathSuggest.Down()
2840+ return false
2841+ case tui.KeyTab, tui.KeyEnter:
2842+ i.pathSuggest.Select(i.ed)
2843+ i.invalidate()
2844+ return false
2845+ case tui.KeyEsc:
2846+ i.pathSuggest.Reset()
2847+ i.invalidate()
2848+ return false
2849+ default:
2850+ i.pathSuggest.Reset()
2851+ }
2852+ }
2853+
28262854 // Tab-complete a path token in the editor when no popup is open.
28272855 // Recognises tokens that look like paths (start with ~, /, ./, ../
28282856 // or contain a slash); shell-style completion expands ~, lists the
@@ -2859,6 +2887,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
28592887 i.inputHistoryIndex = -1
28602888 i.suggest.Reset()
28612889 i.fileSuggest.Reset()
2890+ i.pathSuggest.Reset()
28622891
28632892 if cmd, ok := shellEscapeCommand(text); ok {
28642893 i.startShellEscape(ctx, cmd)
@@ -4410,64 +4439,68 @@ func buildStudyPrompt(arg, cwd string) string {
44104439 return "Read and understand everything in the directory " + display + "."
44114440 }
44124441
4413-// tryPathTabCompleteEditor looks at ed's current value, finds the
4414-// path-like token immediately before the cursor (the cursor is always
4415-// at the end of the buffer after a keystroke, so "before the cursor"
4416-// is the trailing non-whitespace run), and rewrites it to its shell-
4417-// style completion against the filesystem.
4418-//
4419-// Returns true when it consumed the Tab keystroke (token recognised,
4420-// completion attempted — even if no candidates matched, the keystroke
4421-// is still consumed so it doesn't insert a literal tab character).
4422-// Returns false when the token doesn't look like a path; callers then
4423-// let Tab fall through to its normal no-op.
4424-//
4425-// Recognised path shapes:
4426-// - ~ or ~/foo expanded via os.UserHomeDir()
4427-// - /abs/path or /abs/path/foo absolute
4428-// - ./foo, ../foo, foo/bar relative to cwd
4429-//
4430-// A bare word like "hello" is not treated as a path so plain text
4431-// keeps Tab as a literal no-op.
4432-//
4433-// Free function (not a method) so the same logic runs against the
4434-// editor instances owned by btwDialog and swarmDialog without each
4435-// dialog needing its own copy.
4436-func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
4437- if ed == nil {
4438- return false
4442+type pathCompletionEntry struct {
4443+ name string
4444+ isDir bool
4445+}
4446+
4447+// pathCompletionResult contains the filesystem matches for the trailing
4448+// path token in an editor value. Keeping the token offsets alongside the
4449+// matches lets the interactive popup replace only the path, preserving any
4450+// prompt text before it.
4451+type pathCompletionResult struct {
4452+ tokenStart int
4453+ token string
4454+ displayParent string
4455+ basePrefix string
4456+ entries []pathCompletionEntry
4457+}
4458+
4459+func (p pathCompletionResult) showChoices() bool {
4460+ return len(p.entries) > 1 && longestCommonPrefix(pathEntryNames(p.entries)) == p.basePrefix
4461+}
4462+
4463+func pathEntryNames(entries []pathCompletionEntry) []string {
4464+ names := make([]string, len(entries))
4465+ for i, entry := range entries {
4466+ names[i] = entry.name
44394467 }
4440- val := ed.Value()
4441- // Find the trailing run of non-whitespace.
4442- start := len(val)
4468+ return names
4469+}
4470+
4471+// pathCompletionForInput finds the filesystem matches for the path-like
4472+// token immediately before the cursor. The cursor is at the end of the
4473+// editor value for all current callers, so the trailing non-whitespace run
4474+// is the token to complete.
4475+func pathCompletionForInput(input, cwd string) (pathCompletionResult, bool) {
4476+ result := pathCompletionResult{}
4477+ start := len(input)
44434478 for start > 0 {
4444- r := val[start-1]
4479+ r := input[start-1]
44454480 if r == ' ' || r == '\t' || r == '\n' {
44464481 break
44474482 }
44484483 start--
44494484 }
4450- token := val[start:]
4451- if token == "" {
4452- return false
4453- }
4454- if !looksLikePathToken(token) {
4455- return false
4485+ token := input[start:]
4486+ if token == "" || !looksLikePathToken(token) {
4487+ return result, false
44564488 }
4489+ result.tokenStart = start
4490+ result.token = token
44574491
4458- // Resolve the absolute parent directory + base prefix to match.
44594492 parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, cwd)
44604493 if !ok {
4461- return true
4494+ return result, true
44624495 }
4496+ result.displayParent = displayParent
4497+ result.basePrefix = basePrefix
44634498 entries, err := os.ReadDir(parentAbs)
44644499 if err != nil {
4465- return true
4500+ return result, true
44664501 }
4467- var names []string
4468- var isDir []bool
4469- for _, e := range entries {
4470- name := e.Name()
4502+ for _, entry := range entries {
4503+ name := entry.Name()
44714504 if !strings.HasPrefix(name, basePrefix) {
44724505 continue
44734506 }
@@ -4476,46 +4509,101 @@ func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
44764509 if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") {
44774510 continue
44784511 }
4479- names = append(names, name)
4480- isDir = append(isDir, e.IsDir())
4481- }
4482- if len(names) == 0 {
4483- return true
4512+ result.entries = append(result.entries, pathCompletionEntry{name: name, isDir: entry.IsDir()})
44844513 }
4514+ return result, true
4515+}
44854516
4517+// applyPathCompletion applies the ordinary shell-style completion. It
4518+// returns false when there are no matches or when the input is already at
4519+// the deepest ambiguous prefix; the caller can then decide whether to show
4520+// a choice popup for that second-Tab case.
4521+func (p pathCompletionResult) applyPathCompletion(ed *tui.Editor) bool {
4522+ if ed == nil || len(p.entries) == 0 {
4523+ return false
4524+ }
44864525 var completed string
4487- var completedIsDir bool
4488- if len(names) == 1 {
4489- completed = names[0]
4490- completedIsDir = isDir[0]
4526+ completedIsDir := false
4527+ if len(p.entries) == 1 {
4528+ completed = p.entries[0].name
4529+ completedIsDir = p.entries[0].isDir
44914530 } else {
4492- completed = longestCommonPrefix(names)
4493- if completed == basePrefix {
4494- // Already at the deepest unambiguous prefix; nothing to add.
4495- return true
4531+ completed = longestCommonPrefix(pathEntryNames(p.entries))
4532+ if completed == p.basePrefix {
4533+ return false
44964534 }
44974535 }
44984536
44994537 // Build the replacement token in the same display form the user
45004538 // typed (preserve ~ vs absolute vs relative).
4501- newToken := displayParent + completed
4502- if len(names) == 1 && completedIsDir {
4539+ newToken := p.displayParent + completed
4540+ if len(p.entries) == 1 && completedIsDir {
45034541 newToken += "/"
45044542 }
4543+ value := ed.Value()
4544+ if p.tokenStart < 0 || p.tokenStart > len(value) {
4545+ return false
4546+ }
4547+ ed.SetValue(value[:p.tokenStart] + newToken)
4548+ return true
4549+}
45054550
4506- ed.SetValue(val[:start] + newToken)
4551+// tryPathTabCompleteEditor looks at ed's current value, finds the
4552+// path-like token immediately before the cursor (the cursor is always
4553+// at the end of the buffer after a keystroke, so "before the cursor"
4554+// is the trailing non-whitespace run), and rewrites it to its shell-
4555+// style completion against the filesystem.
4556+//
4557+// Returns true when it consumed the Tab keystroke (token recognised,
4558+// completion attempted — even if no candidates matched, the keystroke
4559+// is still consumed so it doesn't insert a literal tab character).
4560+// Returns false when the token doesn't look like a path; callers then
4561+// let Tab fall through to its normal no-op.
4562+//
4563+// Recognised path shapes:
4564+// - ~ or ~/foo expanded via os.UserHomeDir()
4565+// - /abs/path or /abs/path/foo absolute
4566+// - ./foo, ../foo, foo/bar relative to cwd
4567+//
4568+// A bare word like "hello" is not treated as a path so plain text
4569+// keeps Tab as a literal no-op.
4570+//
4571+// Free function (not a method) so the same logic runs against the
4572+// editor instances owned by btwDialog and swarmDialog without each
4573+// dialog needing its own copy.
4574+func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
4575+ if ed == nil {
4576+ return false
4577+ }
4578+ result, ok := pathCompletionForInput(ed.Value(), cwd)
4579+ if !ok {
4580+ return false
4581+ }
4582+ result.applyPathCompletion(ed)
45074583 return true
45084584 }
45094585
4510-// tryPathTabComplete is the Interactive-bound convenience wrapper.
4511-// It calls the free helper against the main editor and invalidates
4512-// the frame on a successful rewrite.
4586+// tryPathTabComplete is the Interactive-bound convenience wrapper. It keeps
4587+// the main editor's repeated-Tab choice popup in sync with the same path
4588+// completion result used by the embedded dialog editors.
45134589 func (i *Interactive) tryPathTabComplete() bool {
4514- if tryPathTabCompleteEditor(i.ed, i.cfg.CWD) {
4590+ if i.pathSuggest == nil {
4591+ i.pathSuggest = newPathChoicePopup()
4592+ }
4593+ result, ok := pathCompletionForInput(i.ed.Value(), i.cfg.CWD)
4594+ if !ok {
4595+ i.pathSuggest.Reset()
4596+ return false
4597+ }
4598+ if result.showChoices() {
4599+ i.pathSuggest.Open(result)
45154600 i.invalidate()
45164601 return true
45174602 }
4518- return false
4603+ i.pathSuggest.Reset()
4604+ result.applyPathCompletion(i.ed)
4605+ i.invalidate()
4606+ return true
45194607 }
45204608
45214609 // looksLikePathToken reports whether tok is shaped like a filesystem
@@ -562,6 +562,7 @@ type Interactive struct {
562 pendingFork bool562 pendingFork bool
563 suggest *slashSuggester563 suggest *slashSuggester
564 fileSuggest *fileSuggester564 fileSuggest *fileSuggester
565+ pathSuggest *pathChoicePopup
565 spin *spinner566 spin *spinner
566 567
567 // parkedTurn is the 1-based turn number the viewport is currently568 // parkedTurn is the 1-based turn number the viewport is currently
@@ -717,6 +718,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
717 extPanel: newExtPanelDialog(),718 extPanel: newExtPanelDialog(),
718 suggest: newSlashSuggester(),719 suggest: newSlashSuggester(),
719 fileSuggest: newFileSuggester(),720 fileSuggest: newFileSuggester(),
721+ pathSuggest: newPathChoicePopup(),
720 spin: newSpinner(cfg.Theme),722 spin: newSpinner(cfg.Theme),
721 inputHistoryIndex: -1,723 inputHistoryIndex: -1,
722 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),724 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
@@ -1458,6 +1460,8 @@ func (i *Interactive) redraw() {
1458 suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)1460 suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)
1459 } else if mainInputFocused && i.fileSuggest.Active(currentInput) {1461 } else if mainInputFocused && i.fileSuggest.Active(currentInput) {
1460 suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, cols)1462 suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, cols)
1463+ } else if mainInputFocused && i.pathSuggest.Active(currentInput) {
1464+ suggest = i.pathSuggest.Render(i.cfg.Theme, cols)
1461 }1465 }
1462 1466
1463 // Detect overlay close (any dialog or slash/file suggestion popup1467 // Detect overlay close (any dialog or slash/file suggestion popup
@@ -2618,7 +2622,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2618 // notes should dismiss on Esc before we even consider the2622 // notes should dismiss on Esc before we even consider the
2619 // turn. Without these guards, a casual Esc press after2623 // turn. Without these guards, a casual Esc press after
2620 // running /help on a busy turn rips the turn away.2624 // running /help on a busy turn rips the turn away.
2621- if i.suggest.Active(i.ed.Value()) || i.fileSuggest.Active(i.ed.Value()) {2625+ if i.suggest.Active(i.ed.Value()) || i.fileSuggest.Active(i.ed.Value()) || i.pathSuggest.Active(i.ed.Value()) {
2622 break2626 break
2623 }2627 }
2624 i.mu.Lock()2628 i.mu.Lock()
@@ -2694,7 +2698,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2694 // At the editor's top edge, plain Up can browse input history when2698 // At the editor's top edge, plain Up can browse input history when
2695 // history browsing is safe/active; otherwise it falls back to chat2699 // history browsing is safe/active; otherwise it falls back to chat
2696 // scrolling, preserving the old single-line scroll behavior.2700 // scrolling, preserving the old single-line scroll behavior.
2697- if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {2701+ if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) && !i.pathSuggest.Active(i.ed.Value()) {
2698 if i.ed.MoveVertical(-1) {2702 if i.ed.MoveVertical(-1) {
2699 i.invalidate()2703 i.invalidate()
2700 return false2704 return false
@@ -2706,7 +2710,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2706 return false2710 return false
2707 }2711 }
2708 case tui.KeyDown:2712 case tui.KeyDown:
2709- if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {2713+ if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) && !i.pathSuggest.Active(i.ed.Value()) {
2710 if i.ed.MoveVertical(+1) {2714 if i.ed.MoveVertical(+1) {
2711 i.invalidate()2715 i.invalidate()
2712 return false2716 return false
@@ -2823,6 +2827,30 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2823 }2827 }
2824 }2828 }
2825 2829
2830+ // Repeated-Tab path choices use the same transient popup interaction as
2831+ // the slash and @ pickers. Any ordinary editing key invalidates the
2832+ // snapshot and falls through so the editor can update the token.
2833+ if i.pathSuggest.Active(i.ed.Value()) {
2834+ switch k.Kind {
2835+ case tui.KeyUp:
2836+ i.pathSuggest.Up()
2837+ return false
2838+ case tui.KeyDown:
2839+ i.pathSuggest.Down()
2840+ return false
2841+ case tui.KeyTab, tui.KeyEnter:
2842+ i.pathSuggest.Select(i.ed)
2843+ i.invalidate()
2844+ return false
2845+ case tui.KeyEsc:
2846+ i.pathSuggest.Reset()
2847+ i.invalidate()
2848+ return false
2849+ default:
2850+ i.pathSuggest.Reset()
2851+ }
2852+ }
2853+
2826 // Tab-complete a path token in the editor when no popup is open.2854 // Tab-complete a path token in the editor when no popup is open.
2827 // Recognises tokens that look like paths (start with ~, /, ./, ../2855 // Recognises tokens that look like paths (start with ~, /, ./, ../
2828 // or contain a slash); shell-style completion expands ~, lists the2856 // or contain a slash); shell-style completion expands ~, lists the
@@ -2859,6 +2887,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2859 i.inputHistoryIndex = -12887 i.inputHistoryIndex = -1
2860 i.suggest.Reset()2888 i.suggest.Reset()
2861 i.fileSuggest.Reset()2889 i.fileSuggest.Reset()
2890+ i.pathSuggest.Reset()
2862 2891
2863 if cmd, ok := shellEscapeCommand(text); ok {2892 if cmd, ok := shellEscapeCommand(text); ok {
2864 i.startShellEscape(ctx, cmd)2893 i.startShellEscape(ctx, cmd)
@@ -4410,64 +4439,68 @@ func buildStudyPrompt(arg, cwd string) string {
4410 return "Read and understand everything in the directory " + display + "."4439 return "Read and understand everything in the directory " + display + "."
4411 }4440 }
4412 4441
4413-// tryPathTabCompleteEditor looks at ed's current value, finds the4442+type pathCompletionEntry struct {
4414-// path-like token immediately before the cursor (the cursor is always4443+ name string
4415-// at the end of the buffer after a keystroke, so "before the cursor"4444+ isDir bool
4416-// is the trailing non-whitespace run), and rewrites it to its shell-4445+}
4417-// style completion against the filesystem.4446+
4418-//4447+// pathCompletionResult contains the filesystem matches for the trailing
4419-// Returns true when it consumed the Tab keystroke (token recognised,4448+// path token in an editor value. Keeping the token offsets alongside the
4420-// completion attempted — even if no candidates matched, the keystroke4449+// matches lets the interactive popup replace only the path, preserving any
4421-// is still consumed so it doesn't insert a literal tab character).4450+// prompt text before it.
4422-// Returns false when the token doesn't look like a path; callers then4451+type pathCompletionResult struct {
4423-// let Tab fall through to its normal no-op.4452+ tokenStart int
4424-//4453+ token string
4425-// Recognised path shapes:4454+ displayParent string
4426-// - ~ or ~/foo expanded via os.UserHomeDir()4455+ basePrefix string
4427-// - /abs/path or /abs/path/foo absolute4456+ entries []pathCompletionEntry
4428-// - ./foo, ../foo, foo/bar relative to cwd4457+}
4429-//4458+
4430-// A bare word like "hello" is not treated as a path so plain text4459+func (p pathCompletionResult) showChoices() bool {
4431-// keeps Tab as a literal no-op.4460+ return len(p.entries) > 1 && longestCommonPrefix(pathEntryNames(p.entries)) == p.basePrefix
4432-//4461+}
4433-// Free function (not a method) so the same logic runs against the4462+
4434-// editor instances owned by btwDialog and swarmDialog without each4463+func pathEntryNames(entries []pathCompletionEntry) []string {
4435-// dialog needing its own copy.4464+ names := make([]string, len(entries))
4436-func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {4465+ for i, entry := range entries {
4437- if ed == nil {4466+ names[i] = entry.name
4438- return false
4439 }4467 }
4440- val := ed.Value()4468+ return names
4441- // Find the trailing run of non-whitespace.4469+}
4442- start := len(val)4470+
4471+// pathCompletionForInput finds the filesystem matches for the path-like
4472+// token immediately before the cursor. The cursor is at the end of the
4473+// editor value for all current callers, so the trailing non-whitespace run
4474+// is the token to complete.
4475+func pathCompletionForInput(input, cwd string) (pathCompletionResult, bool) {
4476+ result := pathCompletionResult{}
4477+ start := len(input)
4443 for start > 0 {4478 for start > 0 {
4444- r := val[start-1]4479+ r := input[start-1]
4445 if r == ' ' || r == '\t' || r == '\n' {4480 if r == ' ' || r == '\t' || r == '\n' {
4446 break4481 break
4447 }4482 }
4448 start--4483 start--
4449 }4484 }
4450- token := val[start:]4485+ token := input[start:]
4451- if token == "" {4486+ if token == "" || !looksLikePathToken(token) {
4452- return false4487+ return result, false
4453- }
4454- if !looksLikePathToken(token) {
4455- return false
4456 }4488 }
4489+ result.tokenStart = start
4490+ result.token = token
4457 4491
4458- // Resolve the absolute parent directory + base prefix to match.
4459 parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, cwd)4492 parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, cwd)
4460 if !ok {4493 if !ok {
4461- return true4494+ return result, true
4462 }4495 }
4496+ result.displayParent = displayParent
4497+ result.basePrefix = basePrefix
4463 entries, err := os.ReadDir(parentAbs)4498 entries, err := os.ReadDir(parentAbs)
4464 if err != nil {4499 if err != nil {
4465- return true4500+ return result, true
4466 }4501 }
4467- var names []string4502+ for _, entry := range entries {
4468- var isDir []bool4503+ name := entry.Name()
4469- for _, e := range entries {
4470- name := e.Name()
4471 if !strings.HasPrefix(name, basePrefix) {4504 if !strings.HasPrefix(name, basePrefix) {
4472 continue4505 continue
4473 }4506 }
@@ -4476,46 +4509,101 @@ func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
4476 if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") {4509 if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") {
4477 continue4510 continue
4478 }4511 }
4479- names = append(names, name)4512+ result.entries = append(result.entries, pathCompletionEntry{name: name, isDir: entry.IsDir()})
4480- isDir = append(isDir, e.IsDir())
4481- }
4482- if len(names) == 0 {
4483- return true
4484 }4513 }
4514+ return result, true
4515+}
4485 4516
4517+// applyPathCompletion applies the ordinary shell-style completion. It
4518+// returns false when there are no matches or when the input is already at
4519+// the deepest ambiguous prefix; the caller can then decide whether to show
4520+// a choice popup for that second-Tab case.
4521+func (p pathCompletionResult) applyPathCompletion(ed *tui.Editor) bool {
4522+ if ed == nil || len(p.entries) == 0 {
4523+ return false
4524+ }
4486 var completed string4525 var completed string
4487- var completedIsDir bool4526+ completedIsDir := false
4488- if len(names) == 1 {4527+ if len(p.entries) == 1 {
4489- completed = names[0]4528+ completed = p.entries[0].name
4490- completedIsDir = isDir[0]4529+ completedIsDir = p.entries[0].isDir
4491 } else {4530 } else {
4492- completed = longestCommonPrefix(names)4531+ completed = longestCommonPrefix(pathEntryNames(p.entries))
4493- if completed == basePrefix {4532+ if completed == p.basePrefix {
4494- // Already at the deepest unambiguous prefix; nothing to add.4533+ return false
4495- return true
4496 }4534 }
4497 }4535 }
4498 4536
4499 // Build the replacement token in the same display form the user4537 // Build the replacement token in the same display form the user
4500 // typed (preserve ~ vs absolute vs relative).4538 // typed (preserve ~ vs absolute vs relative).
4501- newToken := displayParent + completed4539+ newToken := p.displayParent + completed
4502- if len(names) == 1 && completedIsDir {4540+ if len(p.entries) == 1 && completedIsDir {
4503 newToken += "/"4541 newToken += "/"
4504 }4542 }
4543+ value := ed.Value()
4544+ if p.tokenStart < 0 || p.tokenStart > len(value) {
4545+ return false
4546+ }
4547+ ed.SetValue(value[:p.tokenStart] + newToken)
4548+ return true
4549+}
4505 4550
4506- ed.SetValue(val[:start] + newToken)4551+// tryPathTabCompleteEditor looks at ed's current value, finds the
4552+// path-like token immediately before the cursor (the cursor is always
4553+// at the end of the buffer after a keystroke, so "before the cursor"
4554+// is the trailing non-whitespace run), and rewrites it to its shell-
4555+// style completion against the filesystem.
4556+//
4557+// Returns true when it consumed the Tab keystroke (token recognised,
4558+// completion attempted — even if no candidates matched, the keystroke
4559+// is still consumed so it doesn't insert a literal tab character).
4560+// Returns false when the token doesn't look like a path; callers then
4561+// let Tab fall through to its normal no-op.
4562+//
4563+// Recognised path shapes:
4564+// - ~ or ~/foo expanded via os.UserHomeDir()
4565+// - /abs/path or /abs/path/foo absolute
4566+// - ./foo, ../foo, foo/bar relative to cwd
4567+//
4568+// A bare word like "hello" is not treated as a path so plain text
4569+// keeps Tab as a literal no-op.
4570+//
4571+// Free function (not a method) so the same logic runs against the
4572+// editor instances owned by btwDialog and swarmDialog without each
4573+// dialog needing its own copy.
4574+func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
4575+ if ed == nil {
4576+ return false
4577+ }
4578+ result, ok := pathCompletionForInput(ed.Value(), cwd)
4579+ if !ok {
4580+ return false
4581+ }
4582+ result.applyPathCompletion(ed)
4507 return true4583 return true
4508 }4584 }
4509 4585
4510-// tryPathTabComplete is the Interactive-bound convenience wrapper.4586+// tryPathTabComplete is the Interactive-bound convenience wrapper. It keeps
4511-// It calls the free helper against the main editor and invalidates4587+// the main editor's repeated-Tab choice popup in sync with the same path
4512-// the frame on a successful rewrite.4588+// completion result used by the embedded dialog editors.
4513 func (i *Interactive) tryPathTabComplete() bool {4589 func (i *Interactive) tryPathTabComplete() bool {
4514- if tryPathTabCompleteEditor(i.ed, i.cfg.CWD) {4590+ if i.pathSuggest == nil {
4591+ i.pathSuggest = newPathChoicePopup()
4592+ }
4593+ result, ok := pathCompletionForInput(i.ed.Value(), i.cfg.CWD)
4594+ if !ok {
4595+ i.pathSuggest.Reset()
4596+ return false
4597+ }
4598+ if result.showChoices() {
4599+ i.pathSuggest.Open(result)
4515 i.invalidate()4600 i.invalidate()
4516 return true4601 return true
4517 }4602 }
4518- return false4603+ i.pathSuggest.Reset()
4604+ result.applyPathCompletion(i.ed)
4605+ i.invalidate()
4606+ return true
4519 }4607 }
4520 4608
4521 // looksLikePathToken reports whether tok is shaped like a filesystem4609 // looksLikePathToken reports whether tok is shaped like a filesystem
A packages/agent/modes/path_suggest.go +121 -0
@@ -0,0 +1,121 @@
1+package modes
2+
3+import "github.com/patriceckhart/zot/packages/tui"
4+
5+// pathChoicePopup is shown after a repeated Tab reaches an ambiguous
6+// filesystem prefix. It deliberately owns only the transient choice state;
7+// the editor remains the source of truth for the prompt text.
8+type pathChoicePopup struct {
9+ completion pathCompletionResult
10+ cursor int
11+}
12+
13+func newPathChoicePopup() *pathChoicePopup { return &pathChoicePopup{} }
14+
15+func (p *pathChoicePopup) Open(completion pathCompletionResult) {
16+ if p == nil {
17+ return
18+ }
19+ p.completion = completion
20+ p.cursor = 0
21+}
22+
23+func (p *pathChoicePopup) Reset() {
24+ if p == nil {
25+ return
26+ }
27+ p.completion = pathCompletionResult{}
28+ p.cursor = 0
29+}
30+
31+func (p *pathChoicePopup) Active(input string) bool {
32+ if p == nil || len(p.completion.entries) <= 1 {
33+ return false
34+ }
35+ start := len(input)
36+ for start > 0 {
37+ r := input[start-1]
38+ if r == ' ' || r == '\t' || r == '\n' {
39+ break
40+ }
41+ start--
42+ }
43+ if start != p.completion.tokenStart || input[start:] != p.completion.token {
44+ p.Reset()
45+ return false
46+ }
47+ return true
48+}
49+
50+func (p *pathChoicePopup) Up() {
51+ if p.cursor > 0 {
52+ p.cursor--
53+ }
54+}
55+
56+func (p *pathChoicePopup) Down() {
57+ if p.cursor < len(p.completion.entries)-1 {
58+ p.cursor++
59+ }
60+}
61+
62+// Select replaces the unchanged path token with the highlighted match. It
63+// returns false when the editor no longer contains the token that opened the
64+// popup, in which case the caller should let the key continue normally.
65+func (p *pathChoicePopup) Select(ed *tui.Editor) bool {
66+ if ed == nil || !p.Active(ed.Value()) {
67+ return false
68+ }
69+ if p.cursor < 0 || p.cursor >= len(p.completion.entries) {
70+ p.cursor = 0
71+ }
72+ entry := p.completion.entries[p.cursor]
73+ newToken := p.completion.displayParent + entry.name
74+ if entry.isDir {
75+ newToken += "/"
76+ }
77+ value := ed.Value()
78+ ed.SetValue(value[:p.completion.tokenStart] + newToken)
79+ p.Reset()
80+ return true
81+}
82+
83+// Render returns the candidate list and a short key hint. The list is kept
84+// intentionally small and plain: it complements the existing slash and @
85+// popups without introducing a second completion model or a new dependency.
86+func (p *pathChoicePopup) Render(th tui.Theme, width int) []string {
87+ if p == nil || len(p.completion.entries) <= 1 {
88+ return nil
89+ }
90+ header := " " + p.completion.displayParent + p.completion.basePrefix
91+ lines := []string{th.FG256(th.Muted, header), ""}
92+ const maxVisible = 12
93+ visible := p.completion.entries
94+ offset := 0
95+ if len(visible) > maxVisible {
96+ if p.cursor >= maxVisible {
97+ offset = p.cursor - maxVisible + 1
98+ }
99+ end := offset + maxVisible
100+ if end > len(visible) {
101+ end = len(visible)
102+ offset = end - maxVisible
103+ }
104+ visible = visible[offset:end]
105+ }
106+ for idx, entry := range visible {
107+ idx += offset
108+ name := entry.name
109+ if entry.isDir {
110+ name += "/"
111+ }
112+ plain := " " + name
113+ if idx == p.cursor {
114+ lines = append(lines, th.PadHighlight(plain, width))
115+ } else {
116+ lines = append(lines, th.FG256(th.Muted, plain))
117+ }
118+ }
119+ lines = append(lines, "", th.FG256(th.Muted, " ↑/↓ navigate - tab/enter complete - esc cancel"), "")
120+ return lines
121+}
@@ -0,0 +1,121 @@
1+package modes
2+
3+import "github.com/patriceckhart/zot/packages/tui"
4+
5+// pathChoicePopup is shown after a repeated Tab reaches an ambiguous
6+// filesystem prefix. It deliberately owns only the transient choice state;
7+// the editor remains the source of truth for the prompt text.
8+type pathChoicePopup struct {
9+ completion pathCompletionResult
10+ cursor int
11+}
12+
13+func newPathChoicePopup() *pathChoicePopup { return &pathChoicePopup{} }
14+
15+func (p *pathChoicePopup) Open(completion pathCompletionResult) {
16+ if p == nil {
17+ return
18+ }
19+ p.completion = completion
20+ p.cursor = 0
21+}
22+
23+func (p *pathChoicePopup) Reset() {
24+ if p == nil {
25+ return
26+ }
27+ p.completion = pathCompletionResult{}
28+ p.cursor = 0
29+}
30+
31+func (p *pathChoicePopup) Active(input string) bool {
32+ if p == nil || len(p.completion.entries) <= 1 {
33+ return false
34+ }
35+ start := len(input)
36+ for start > 0 {
37+ r := input[start-1]
38+ if r == ' ' || r == '\t' || r == '\n' {
39+ break
40+ }
41+ start--
42+ }
43+ if start != p.completion.tokenStart || input[start:] != p.completion.token {
44+ p.Reset()
45+ return false
46+ }
47+ return true
48+}
49+
50+func (p *pathChoicePopup) Up() {
51+ if p.cursor > 0 {
52+ p.cursor--
53+ }
54+}
55+
56+func (p *pathChoicePopup) Down() {
57+ if p.cursor < len(p.completion.entries)-1 {
58+ p.cursor++
59+ }
60+}
61+
62+// Select replaces the unchanged path token with the highlighted match. It
63+// returns false when the editor no longer contains the token that opened the
64+// popup, in which case the caller should let the key continue normally.
65+func (p *pathChoicePopup) Select(ed *tui.Editor) bool {
66+ if ed == nil || !p.Active(ed.Value()) {
67+ return false
68+ }
69+ if p.cursor < 0 || p.cursor >= len(p.completion.entries) {
70+ p.cursor = 0
71+ }
72+ entry := p.completion.entries[p.cursor]
73+ newToken := p.completion.displayParent + entry.name
74+ if entry.isDir {
75+ newToken += "/"
76+ }
77+ value := ed.Value()
78+ ed.SetValue(value[:p.completion.tokenStart] + newToken)
79+ p.Reset()
80+ return true
81+}
82+
83+// Render returns the candidate list and a short key hint. The list is kept
84+// intentionally small and plain: it complements the existing slash and @
85+// popups without introducing a second completion model or a new dependency.
86+func (p *pathChoicePopup) Render(th tui.Theme, width int) []string {
87+ if p == nil || len(p.completion.entries) <= 1 {
88+ return nil
89+ }
90+ header := " " + p.completion.displayParent + p.completion.basePrefix
91+ lines := []string{th.FG256(th.Muted, header), ""}
92+ const maxVisible = 12
93+ visible := p.completion.entries
94+ offset := 0
95+ if len(visible) > maxVisible {
96+ if p.cursor >= maxVisible {
97+ offset = p.cursor - maxVisible + 1
98+ }
99+ end := offset + maxVisible
100+ if end > len(visible) {
101+ end = len(visible)
102+ offset = end - maxVisible
103+ }
104+ visible = visible[offset:end]
105+ }
106+ for idx, entry := range visible {
107+ idx += offset
108+ name := entry.name
109+ if entry.isDir {
110+ name += "/"
111+ }
112+ plain := " " + name
113+ if idx == p.cursor {
114+ lines = append(lines, th.PadHighlight(plain, width))
115+ } else {
116+ lines = append(lines, th.FG256(th.Muted, plain))
117+ }
118+ }
119+ lines = append(lines, "", th.FG256(th.Muted, " ↑/↓ navigate - tab/enter complete - esc cancel"), "")
120+ return lines
121+}
A packages/agent/modes/path_suggest_test.go +90 -0
@@ -0,0 +1,90 @@
1+package modes
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "github.com/patriceckhart/zot/packages/tui"
11+)
12+
13+func newPathChoiceTestInteractive(t *testing.T) *Interactive {
14+ t.Helper()
15+ tmp := t.TempDir()
16+ for _, name := range []string{"foobar", "foobuz"} {
17+ if err := os.WriteFile(filepath.Join(tmp, name), []byte("x"), 0o644); err != nil {
18+ t.Fatal(err)
19+ }
20+ }
21+
22+ interactive := NewInteractive(InteractiveConfig{CWD: tmp})
23+ interactive.ed.SetValue("./foo")
24+ if !interactive.tryPathTabComplete() {
25+ t.Fatal("first Tab was not consumed")
26+ }
27+ if !interactive.tryPathTabComplete() {
28+ t.Fatal("second Tab was not consumed")
29+ }
30+ return interactive
31+}
32+
33+func TestPathTabCompletionShowsChoicesAfterCommonPrefix(t *testing.T) {
34+ interactive := newPathChoiceTestInteractive(t)
35+ ed := interactive.ed
36+ if got, want := ed.Value(), "./foob"; got != want {
37+ t.Fatalf("first Tab completed to %q, want %q", got, want)
38+ }
39+
40+ popup := interactive.pathSuggest
41+ if !popup.Active(ed.Value()) {
42+ t.Fatal("path choice popup is not active for the unchanged token")
43+ }
44+ rows := popup.Render(tui.Theme{}, 40)
45+ rendered := stripANSIBytes(strings.Join(rows, "\n"))
46+ for _, name := range []string{"foobar", "foobuz"} {
47+ if !strings.Contains(rendered, name) {
48+ t.Fatalf("popup omitted %q: %q", name, rendered)
49+ }
50+ }
51+
52+ if interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyDown}) {
53+ t.Fatal("navigating the path popup unexpectedly exited interactive mode")
54+ }
55+ if interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter}) {
56+ t.Fatal("selecting a path choice unexpectedly exited interactive mode")
57+ }
58+ if got, want := ed.Value(), "./foobuz"; got != want {
59+ t.Fatalf("selected path is %q, want %q", got, want)
60+ }
61+}
62+
63+func TestPathChoiceEscDoesNotCancelBusyTurn(t *testing.T) {
64+ interactive := newPathChoiceTestInteractive(t)
65+ interactive.busy = true
66+ canceled := false
67+ interactive.cancelTurn = func() { canceled = true }
68+
69+ interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
70+
71+ if canceled {
72+ t.Fatal("Esc canceled the active turn instead of dismissing the path popup")
73+ }
74+ if interactive.pathSuggest.Active(interactive.ed.Value()) {
75+ t.Fatal("Esc left the path popup active")
76+ }
77+}
78+
79+func TestPathChoiceDoesNotReopenAfterInputCleared(t *testing.T) {
80+ interactive := newPathChoiceTestInteractive(t)
81+
82+ interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlC})
83+ for _, r := range "./foob" {
84+ interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: r})
85+ }
86+
87+ if interactive.pathSuggest.Active(interactive.ed.Value()) {
88+ t.Fatal("stale path popup reopened after Ctrl+C and retyping")
89+ }
90+}
@@ -0,0 +1,90 @@
1+package modes
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "github.com/patriceckhart/zot/packages/tui"
11+)
12+
13+func newPathChoiceTestInteractive(t *testing.T) *Interactive {
14+ t.Helper()
15+ tmp := t.TempDir()
16+ for _, name := range []string{"foobar", "foobuz"} {
17+ if err := os.WriteFile(filepath.Join(tmp, name), []byte("x"), 0o644); err != nil {
18+ t.Fatal(err)
19+ }
20+ }
21+
22+ interactive := NewInteractive(InteractiveConfig{CWD: tmp})
23+ interactive.ed.SetValue("./foo")
24+ if !interactive.tryPathTabComplete() {
25+ t.Fatal("first Tab was not consumed")
26+ }
27+ if !interactive.tryPathTabComplete() {
28+ t.Fatal("second Tab was not consumed")
29+ }
30+ return interactive
31+}
32+
33+func TestPathTabCompletionShowsChoicesAfterCommonPrefix(t *testing.T) {
34+ interactive := newPathChoiceTestInteractive(t)
35+ ed := interactive.ed
36+ if got, want := ed.Value(), "./foob"; got != want {
37+ t.Fatalf("first Tab completed to %q, want %q", got, want)
38+ }
39+
40+ popup := interactive.pathSuggest
41+ if !popup.Active(ed.Value()) {
42+ t.Fatal("path choice popup is not active for the unchanged token")
43+ }
44+ rows := popup.Render(tui.Theme{}, 40)
45+ rendered := stripANSIBytes(strings.Join(rows, "\n"))
46+ for _, name := range []string{"foobar", "foobuz"} {
47+ if !strings.Contains(rendered, name) {
48+ t.Fatalf("popup omitted %q: %q", name, rendered)
49+ }
50+ }
51+
52+ if interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyDown}) {
53+ t.Fatal("navigating the path popup unexpectedly exited interactive mode")
54+ }
55+ if interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter}) {
56+ t.Fatal("selecting a path choice unexpectedly exited interactive mode")
57+ }
58+ if got, want := ed.Value(), "./foobuz"; got != want {
59+ t.Fatalf("selected path is %q, want %q", got, want)
60+ }
61+}
62+
63+func TestPathChoiceEscDoesNotCancelBusyTurn(t *testing.T) {
64+ interactive := newPathChoiceTestInteractive(t)
65+ interactive.busy = true
66+ canceled := false
67+ interactive.cancelTurn = func() { canceled = true }
68+
69+ interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
70+
71+ if canceled {
72+ t.Fatal("Esc canceled the active turn instead of dismissing the path popup")
73+ }
74+ if interactive.pathSuggest.Active(interactive.ed.Value()) {
75+ t.Fatal("Esc left the path popup active")
76+ }
77+}
78+
79+func TestPathChoiceDoesNotReopenAfterInputCleared(t *testing.T) {
80+ interactive := newPathChoiceTestInteractive(t)
81+
82+ interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlC})
83+ for _, r := range "./foob" {
84+ interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: r})
85+ }
86+
87+ if interactive.pathSuggest.Active(interactive.ed.Value()) {
88+ t.Fatal("stale path popup reopened after Ctrl+C and retyping")
89+ }
90+}

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