detached c17f06b

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) {
26222622 // notes should dismiss on Esc before we even consider the
26232623 // turn. Without these guards, a casual Esc press after
26242624 // 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()) {
26262626 break
26272627 }
26282628 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 the2622 // notes should dismiss on Esc before we even consider the
2623 // turn. Without these guards, a casual Esc press after2623 // 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 break2626 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 {
4040 }
4141 start--
4242 }
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
4448 }
4549
4650 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.token43+ 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 (
1010 "github.com/patriceckhart/zot/packages/tui"
1111 )
1212
13-func TestPathTabCompletionShowsChoicesAfterCommonPrefix(t *testing.T) {
13+func newPathChoiceTestInteractive(t *testing.T) *Interactive {
14+ t.Helper()
1415 tmp := t.TempDir()
1516 for _, name := range []string{"foobar", "foobuz"} {
1617 if err := os.WriteFile(filepath.Join(tmp, name), []byte("x"), 0o644); err != nil {
@@ -19,18 +20,23 @@ func TestPathTabCompletionShowsChoicesAfterCommonPrefix(t *testing.T) {
1920 }
2021
2122 interactive := NewInteractive(InteractiveConfig{CWD: tmp})
22- ed := interactive.ed
23- ed.SetValue("./foo")
23+ interactive.ed.SetValue("./foo")
2424 if !interactive.tryPathTabComplete() {
2525 t.Fatal("first Tab was not consumed")
2626 }
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
2736 if got, want := ed.Value(), "./foob"; got != want {
2837 t.Fatalf("first Tab completed to %q, want %q", got, want)
2938 }
3039
31- if !interactive.tryPathTabComplete() {
32- t.Fatal("second Tab was not consumed")
33- }
3440 popup := interactive.pathSuggest
3541 if !popup.Active(ed.Value()) {
3642 t.Fatal("path choice popup is not active for the unchanged token")
@@ -53,3 +59,32 @@ func TestPathTabCompletionShowsChoicesAfterCommonPrefix(t *testing.T) {
5359 t.Fatalf("selected path is %q, want %q", got, want)
5460 }
5561 }
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.ed23+ 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.pathSuggest40 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+}

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