Merge pull request #200 from Oscar-Williams/feat/path-completion-choices
feat(tui): show path choices on repeated tab
3 files · +366 -67
M packages/agent/modes/interactive.go +155 -67
| @@ -562,6 +562,7 @@ type Interactive struct { | |||
| 562 | 562 | pendingFork bool | |
| 563 | 563 | suggest *slashSuggester | |
| 564 | 564 | fileSuggest *fileSuggester | |
| 565 | + | pathSuggest *pathChoicePopup | |
| 565 | 566 | spin *spinner | |
| 566 | 567 | ||
| 567 | 568 | // parkedTurn is the 1-based turn number the viewport is currently | |
| @@ -717,6 +718,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 717 | 718 | extPanel: newExtPanelDialog(), | |
| 718 | 719 | suggest: newSlashSuggester(), | |
| 719 | 720 | fileSuggest: newFileSuggester(), | |
| 721 | + | pathSuggest: newPathChoicePopup(), | |
| 720 | 722 | spin: newSpinner(cfg.Theme), | |
| 721 | 723 | inputHistoryIndex: -1, | |
| 722 | 724 | reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...), | |
| @@ -1458,6 +1460,8 @@ func (i *Interactive) redraw() { | |||
| 1458 | 1460 | suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols) | |
| 1459 | 1461 | } else if mainInputFocused && i.fileSuggest.Active(currentInput) { | |
| 1460 | 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 | 1467 | // 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 | 2622 | // notes should dismiss on Esc before we even consider the | |
| 2619 | 2623 | // turn. Without these guards, a casual Esc press after | |
| 2620 | 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 | 2626 | break | |
| 2623 | 2627 | } | |
| 2624 | 2628 | i.mu.Lock() | |
| @@ -2694,7 +2698,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2694 | 2698 | // At the editor's top edge, plain Up can browse input history when | |
| 2695 | 2699 | // history browsing is safe/active; otherwise it falls back to chat | |
| 2696 | 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 | 2702 | if i.ed.MoveVertical(-1) { | |
| 2699 | 2703 | i.invalidate() | |
| 2700 | 2704 | return false | |
| @@ -2706,7 +2710,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2706 | 2710 | return false | |
| 2707 | 2711 | } | |
| 2708 | 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 | 2714 | if i.ed.MoveVertical(+1) { | |
| 2711 | 2715 | i.invalidate() | |
| 2712 | 2716 | 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 | 2854 | // Tab-complete a path token in the editor when no popup is open. | |
| 2827 | 2855 | // Recognises tokens that look like paths (start with ~, /, ./, ../ | |
| 2828 | 2856 | // 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 | 2887 | i.inputHistoryIndex = -1 | |
| 2860 | 2888 | i.suggest.Reset() | |
| 2861 | 2889 | i.fileSuggest.Reset() | |
| 2890 | + | i.pathSuggest.Reset() | |
| 2862 | 2891 | ||
| 2863 | 2892 | if cmd, ok := shellEscapeCommand(text); ok { | |
| 2864 | 2893 | i.startShellEscape(ctx, cmd) | |
| @@ -4410,64 +4439,68 @@ func buildStudyPrompt(arg, cwd string) string { | |||
| 4410 | 4439 | return "Read and understand everything in the directory " + display + "." | |
| 4411 | 4440 | } | |
| 4412 | 4441 | ||
| 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 | |
| 4439 | 4467 | } | |
| 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) | |
| 4443 | 4478 | for start > 0 { | |
| 4444 | - | r := val[start-1] | |
| 4479 | + | r := input[start-1] | |
| 4445 | 4480 | if r == ' ' || r == '\t' || r == '\n' { | |
| 4446 | 4481 | break | |
| 4447 | 4482 | } | |
| 4448 | 4483 | start-- | |
| 4449 | 4484 | } | |
| 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 | |
| 4456 | 4488 | } | |
| 4489 | + | result.tokenStart = start | |
| 4490 | + | result.token = token | |
| 4457 | 4491 | ||
| 4458 | - | // Resolve the absolute parent directory + base prefix to match. | |
| 4459 | 4492 | parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, cwd) | |
| 4460 | 4493 | if !ok { | |
| 4461 | - | return true | |
| 4494 | + | return result, true | |
| 4462 | 4495 | } | |
| 4496 | + | result.displayParent = displayParent | |
| 4497 | + | result.basePrefix = basePrefix | |
| 4463 | 4498 | entries, err := os.ReadDir(parentAbs) | |
| 4464 | 4499 | if err != nil { | |
| 4465 | - | return true | |
| 4500 | + | return result, true | |
| 4466 | 4501 | } | |
| 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() | |
| 4471 | 4504 | if !strings.HasPrefix(name, basePrefix) { | |
| 4472 | 4505 | continue | |
| 4473 | 4506 | } | |
| @@ -4476,46 +4509,101 @@ func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool { | |||
| 4476 | 4509 | if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") { | |
| 4477 | 4510 | continue | |
| 4478 | 4511 | } | |
| 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()}) | |
| 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 | 4525 | 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 | |
| 4491 | 4530 | } 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 | |
| 4496 | 4534 | } | |
| 4497 | 4535 | } | |
| 4498 | 4536 | ||
| 4499 | 4537 | // Build the replacement token in the same display form the user | |
| 4500 | 4538 | // 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 { | |
| 4503 | 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 | 4583 | return true | |
| 4508 | 4584 | } | |
| 4509 | 4585 | ||
| 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. | |
| 4513 | 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 | 4600 | i.invalidate() | |
| 4516 | 4601 | return true | |
| 4517 | 4602 | } | |
| 4518 | - | return false | |
| 4603 | + | i.pathSuggest.Reset() | |
| 4604 | + | result.applyPathCompletion(i.ed) | |
| 4605 | + | i.invalidate() | |
| 4606 | + | return true | |
| 4519 | 4607 | } | |
| 4520 | 4608 | ||
| 4521 | 4609 | // looksLikePathToken reports whether tok is shaped like a filesystem | |
| @@ -562,6 +562,7 @@ type Interactive struct { | |||||
| 562 | pendingFork bool | 562 | pendingFork bool | ||
| 563 | suggest *slashSuggester | 563 | suggest *slashSuggester | ||
| 564 | fileSuggest *fileSuggester | 564 | fileSuggest *fileSuggester | ||
| 565 | + | pathSuggest *pathChoicePopup | |||
| 565 | spin *spinner | 566 | spin *spinner | ||
| 566 | 567 | ||||
| 567 | // parkedTurn is the 1-based turn number the viewport is currently | 568 | // 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 popup | 1467 | // 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 the | 2622 | // notes should dismiss on Esc before we even consider the | ||
| 2619 | // turn. Without these guards, a casual Esc press after | 2623 | // 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 | break | 2626 | 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 when | 2698 | // At the editor's top edge, plain Up can browse input history when | ||
| 2695 | // history browsing is safe/active; otherwise it falls back to chat | 2699 | // 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 false | 2704 | return false | ||
| @@ -2706,7 +2710,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2706 | return false | 2710 | 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 false | 2716 | 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 the | 2856 | // 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 = -1 | 2887 | 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 the | 4442 | + | type pathCompletionEntry struct { |
| 4414 | - | // path-like token immediately before the cursor (the cursor is always | 4443 | + | 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 keystroke | 4449 | + | // 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 then | 4451 | + | 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 absolute | 4456 | + | entries []pathCompletionEntry |
| 4428 | - | // - ./foo, ../foo, foo/bar relative to cwd | 4457 | + | } |
| 4429 | - | // | 4458 | + | |
| 4430 | - | // A bare word like "hello" is not treated as a path so plain text | 4459 | + | 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 the | 4462 | + | |
| 4434 | - | // editor instances owned by btwDialog and swarmDialog without each | 4463 | + | 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 | break | 4481 | 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 false | 4487 | + | 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 true | 4494 | + | 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 true | 4500 | + | return result, true |
| 4466 | } | 4501 | } | ||
| 4467 | - | var names []string | 4502 | + | for _, entry := range entries { |
| 4468 | - | var isDir []bool | 4503 | + | 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 | continue | 4505 | 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 | continue | 4510 | 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 string | 4525 | var completed string | ||
| 4487 | - | var completedIsDir bool | 4526 | + | 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 user | 4537 | // 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 + completed | 4539 | + | 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 true | 4583 | 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 invalidates | 4587 | + | // 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 true | 4601 | return true | ||
| 4517 | } | 4602 | } | ||
| 4518 | - | return false | 4603 | + | 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 filesystem | 4609 | // 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 | + | } | |||