Fix path completion popup cancellation
- commit
- c17f06ba7c99576f0436201e3d57d95f0b4adb29
- author
- patriceckhart · · 2026-09-23 15:16 UTC
- parent
- 1e0af50
- signature
- unsigned
3 files · +47 -8
M packages/agent/modes/interactive.go +1 -1
| @@ -2622,7 +2622,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2622 | 2622 | // notes should dismiss on Esc before we even consider the | |
| 2623 | 2623 | // turn. Without these guards, a casual Esc press after | |
| 2624 | 2624 | // running /help on a busy turn rips the turn away. | |
| 2625 | - | 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()) { | |
| 2626 | 2626 | break | |
| 2627 | 2627 | } | |
| 2628 | 2628 | i.mu.Lock() | |
| @@ -2622,7 +2622,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2622 | // notes should dismiss on Esc before we even consider the | 2622 | // notes should dismiss on Esc before we even consider the | ||
| 2623 | // turn. Without these guards, a casual Esc press after | 2623 | // turn. Without these guards, a casual Esc press after | ||
| 2624 | // running /help on a busy turn rips the turn away. | 2624 | // running /help on a busy turn rips the turn away. | ||
| 2625 | - | 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()) { |
| 2626 | break | 2626 | break | ||
| 2627 | } | 2627 | } | ||
| 2628 | i.mu.Lock() | 2628 | i.mu.Lock() | ||
M packages/agent/modes/path_suggest.go +5 -1
| @@ -40,7 +40,11 @@ func (p *pathChoicePopup) Active(input string) bool { | |||
| 40 | 40 | } | |
| 41 | 41 | start-- | |
| 42 | 42 | } | |
| 43 | - | return start == p.completion.tokenStart && input[start:] == p.completion.token | |
| 43 | + | if start != p.completion.tokenStart || input[start:] != p.completion.token { | |
| 44 | + | p.Reset() | |
| 45 | + | return false | |
| 46 | + | } | |
| 47 | + | return true | |
| 44 | 48 | } | |
| 45 | 49 | ||
| 46 | 50 | func (p *pathChoicePopup) Up() { | |
| @@ -40,7 +40,11 @@ func (p *pathChoicePopup) Active(input string) bool { | |||||
| 40 | } | 40 | } | ||
| 41 | start-- | 41 | start-- | ||
| 42 | } | 42 | } | ||
| 43 | - | return start == p.completion.tokenStart && input[start:] == p.completion.token | 43 | + | if start != p.completion.tokenStart || input[start:] != p.completion.token { |
| 44 | + | p.Reset() | |||
| 45 | + | return false | |||
| 46 | + | } | |||
| 47 | + | return true | |||
| 44 | } | 48 | } | ||
| 45 | 49 | ||||
| 46 | func (p *pathChoicePopup) Up() { | 50 | func (p *pathChoicePopup) Up() { | ||
M packages/agent/modes/path_suggest_test.go +41 -6
| @@ -10,7 +10,8 @@ import ( | |||
| 10 | 10 | "github.com/patriceckhart/zot/packages/tui" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | - | func TestPathTabCompletionShowsChoicesAfterCommonPrefix(t *testing.T) { | |
| 13 | + | func newPathChoiceTestInteractive(t *testing.T) *Interactive { | |
| 14 | + | t.Helper() | |
| 14 | 15 | tmp := t.TempDir() | |
| 15 | 16 | for _, name := range []string{"foobar", "foobuz"} { | |
| 16 | 17 | if err := os.WriteFile(filepath.Join(tmp, name), []byte("x"), 0o644); err != nil { | |
| @@ -19,18 +20,23 @@ func TestPathTabCompletionShowsChoicesAfterCommonPrefix(t *testing.T) { | |||
| 19 | 20 | } | |
| 20 | 21 | ||
| 21 | 22 | interactive := NewInteractive(InteractiveConfig{CWD: tmp}) | |
| 22 | - | ed := interactive.ed | |
| 23 | - | ed.SetValue("./foo") | |
| 23 | + | interactive.ed.SetValue("./foo") | |
| 24 | 24 | if !interactive.tryPathTabComplete() { | |
| 25 | 25 | t.Fatal("first Tab was not consumed") | |
| 26 | 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 | |
| 27 | 36 | if got, want := ed.Value(), "./foob"; got != want { | |
| 28 | 37 | t.Fatalf("first Tab completed to %q, want %q", got, want) | |
| 29 | 38 | } | |
| 30 | 39 | ||
| 31 | - | if !interactive.tryPathTabComplete() { | |
| 32 | - | t.Fatal("second Tab was not consumed") | |
| 33 | - | } | |
| 34 | 40 | popup := interactive.pathSuggest | |
| 35 | 41 | if !popup.Active(ed.Value()) { | |
| 36 | 42 | t.Fatal("path choice popup is not active for the unchanged token") | |
| @@ -53,3 +59,32 @@ func TestPathTabCompletionShowsChoicesAfterCommonPrefix(t *testing.T) { | |||
| 53 | 59 | t.Fatalf("selected path is %q, want %q", got, want) | |
| 54 | 60 | } | |
| 55 | 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 | + | } | |
| @@ -10,7 +10,8 @@ import ( | |||||
| 10 | "github.com/patriceckhart/zot/packages/tui" | 10 | "github.com/patriceckhart/zot/packages/tui" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | - | func TestPathTabCompletionShowsChoicesAfterCommonPrefix(t *testing.T) { | 13 | + | func newPathChoiceTestInteractive(t *testing.T) *Interactive { |
| 14 | + | t.Helper() | |||
| 14 | tmp := t.TempDir() | 15 | tmp := t.TempDir() | ||
| 15 | for _, name := range []string{"foobar", "foobuz"} { | 16 | for _, name := range []string{"foobar", "foobuz"} { | ||
| 16 | if err := os.WriteFile(filepath.Join(tmp, name), []byte("x"), 0o644); err != nil { | 17 | if err := os.WriteFile(filepath.Join(tmp, name), []byte("x"), 0o644); err != nil { | ||
| @@ -19,18 +20,23 @@ func TestPathTabCompletionShowsChoicesAfterCommonPrefix(t *testing.T) { | |||||
| 19 | } | 20 | } | ||
| 20 | 21 | ||||
| 21 | interactive := NewInteractive(InteractiveConfig{CWD: tmp}) | 22 | interactive := NewInteractive(InteractiveConfig{CWD: tmp}) | ||
| 22 | - | ed := interactive.ed | 23 | + | interactive.ed.SetValue("./foo") |
| 23 | - | ed.SetValue("./foo") | |||
| 24 | if !interactive.tryPathTabComplete() { | 24 | if !interactive.tryPathTabComplete() { | ||
| 25 | t.Fatal("first Tab was not consumed") | 25 | t.Fatal("first Tab was not consumed") | ||
| 26 | } | 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 | |||
| 27 | if got, want := ed.Value(), "./foob"; got != want { | 36 | if got, want := ed.Value(), "./foob"; got != want { | ||
| 28 | t.Fatalf("first Tab completed to %q, want %q", got, want) | 37 | t.Fatalf("first Tab completed to %q, want %q", got, want) | ||
| 29 | } | 38 | } | ||
| 30 | 39 | ||||
| 31 | - | if !interactive.tryPathTabComplete() { | |||
| 32 | - | t.Fatal("second Tab was not consumed") | |||
| 33 | - | } | |||
| 34 | popup := interactive.pathSuggest | 40 | popup := interactive.pathSuggest | ||
| 35 | if !popup.Active(ed.Value()) { | 41 | if !popup.Active(ed.Value()) { | ||
| 36 | t.Fatal("path choice popup is not active for the unchanged token") | 42 | t.Fatal("path choice popup is not active for the unchanged token") | ||
| @@ -53,3 +59,32 @@ func TestPathTabCompletionShowsChoicesAfterCommonPrefix(t *testing.T) { | |||||
| 53 | t.Fatalf("selected path is %q, want %q", got, want) | 59 | t.Fatalf("selected path is %q, want %q", got, want) | ||
| 54 | } | 60 | } | ||
| 55 | } | 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 | + | } | |||