detached 4a8d2ed

fix(tui): clear @-picker filter when browsing into/out of a directory

In flat (non-recursive) mode, typing a filter to locate a directory and
then opening it with Right re-applied that same filter inside the
directory. Typing "@eda" then Right to open eda/ showed nothing,
because no child of eda/ matches "eda". The filter the user typed
selected the directory at the current level; it has no meaning one
level deeper.

Clear the text after the last "@" (keeping the bare "@" so the picker
stays open) whenever Right or Left successfully changes the browse
level. The filter was scoped to the level just left, so dropping it
shows the new directory's full contents.

Adds a regression test that opens eda/ after an "@eda" filter and
asserts the directory's contents are listed while the stale filter
would have matched nothing.
commit
4a8d2ed68e4fa245abc83600fcf9d35bc1e7466d
author
Raymond Gasper · · 2026-06-10 13:41 UTC
parent
1a3e0a5
signature
unsigned
2 files · +73 -4
M packages/agent/modes/file_suggest_test.go +48 -0
@@ -176,6 +176,54 @@ func TestFileSuggesterFuzzyMatch(t *testing.T) {
176176 }
177177 }
178178
179+// TestFileSuggesterFlatBrowseIntoDirIgnoresStaleFilter reproduces the
180+// reported bug: in flat (non-recursive) mode, typing "@eda" to find a
181+// directory then opening it with Right must show that directory's
182+// contents, not re-apply the "eda" filter inside it (which matches
183+// nothing). The interactive layer clears the @-query when descending,
184+// so here we model that by browsing with Right and then matching an
185+// empty query against the new level.
186+func TestFileSuggesterFlatBrowseIntoDirIgnoresStaleFilter(t *testing.T) {
187+ tmp := t.TempDir()
188+ // eda/rjg/enk-1150 with a file inside, plus a sibling so the filter
189+ // is meaningful at the top level.
190+ if err := os.MkdirAll(filepath.Join(tmp, "eda", "rjg", "enk-1150"), 0o755); err != nil {
191+ t.Fatal(err)
192+ }
193+ if err := os.WriteFile(filepath.Join(tmp, "eda", "rjg", "enk-1150", "pipeline.py"), []byte("x"), 0o644); err != nil {
194+ t.Fatal(err)
195+ }
196+ if err := os.MkdirAll(filepath.Join(tmp, "unrelated"), 0o755); err != nil {
197+ t.Fatal(err)
198+ }
199+
200+ s := newFileSuggester()
201+ s.SetCWD(tmp) // flat mode
202+
203+ // Top level: "@eda" highlights the eda/ directory. Render populates
204+ // lastMatches, which Right/Left act on (it runs every frame before
205+ // key handling in the live UI).
206+ s.lastMatches = s.matches("@eda")
207+ if !containsEntry(s.lastMatches, "eda", true) {
208+ t.Fatalf("@eda did not match eda/: %#v", s.lastMatches)
209+ }
210+ s.cursor = 0 // eda/ is the (only) match, selected.
211+
212+ // Open it. After the interactive layer clears the query, the picker
213+ // is browsing eda/ with an empty filter and must show rjg/.
214+ if !s.Right() {
215+ t.Fatal("Right() did not open eda/")
216+ }
217+ if got := s.matches("@"); !containsEntry(got, "rjg", true) {
218+ t.Fatalf("after opening eda/, empty filter did not show rjg/: %#v", got)
219+ }
220+ // The stale filter would have shown nothing: confirm that's the
221+ // behavior the fix avoids.
222+ if got := s.matches("@eda"); len(got) != 0 {
223+ t.Fatalf("stale @eda filter inside eda/ unexpectedly matched: %#v", got)
224+ }
225+}
226+
179227 // TestFileSuggesterRecursiveMatch verifies recursive mode flattens the
180228 // tree and matches against the cwd-relative path, so a pattern can
181229 // span directory boundaries.
@@ -176,6 +176,54 @@ func TestFileSuggesterFuzzyMatch(t *testing.T) {
176 }176 }
177 }177 }
178 178
179+// TestFileSuggesterFlatBrowseIntoDirIgnoresStaleFilter reproduces the
180+// reported bug: in flat (non-recursive) mode, typing "@eda" to find a
181+// directory then opening it with Right must show that directory's
182+// contents, not re-apply the "eda" filter inside it (which matches
183+// nothing). The interactive layer clears the @-query when descending,
184+// so here we model that by browsing with Right and then matching an
185+// empty query against the new level.
186+func TestFileSuggesterFlatBrowseIntoDirIgnoresStaleFilter(t *testing.T) {
187+ tmp := t.TempDir()
188+ // eda/rjg/enk-1150 with a file inside, plus a sibling so the filter
189+ // is meaningful at the top level.
190+ if err := os.MkdirAll(filepath.Join(tmp, "eda", "rjg", "enk-1150"), 0o755); err != nil {
191+ t.Fatal(err)
192+ }
193+ if err := os.WriteFile(filepath.Join(tmp, "eda", "rjg", "enk-1150", "pipeline.py"), []byte("x"), 0o644); err != nil {
194+ t.Fatal(err)
195+ }
196+ if err := os.MkdirAll(filepath.Join(tmp, "unrelated"), 0o755); err != nil {
197+ t.Fatal(err)
198+ }
199+
200+ s := newFileSuggester()
201+ s.SetCWD(tmp) // flat mode
202+
203+ // Top level: "@eda" highlights the eda/ directory. Render populates
204+ // lastMatches, which Right/Left act on (it runs every frame before
205+ // key handling in the live UI).
206+ s.lastMatches = s.matches("@eda")
207+ if !containsEntry(s.lastMatches, "eda", true) {
208+ t.Fatalf("@eda did not match eda/: %#v", s.lastMatches)
209+ }
210+ s.cursor = 0 // eda/ is the (only) match, selected.
211+
212+ // Open it. After the interactive layer clears the query, the picker
213+ // is browsing eda/ with an empty filter and must show rjg/.
214+ if !s.Right() {
215+ t.Fatal("Right() did not open eda/")
216+ }
217+ if got := s.matches("@"); !containsEntry(got, "rjg", true) {
218+ t.Fatalf("after opening eda/, empty filter did not show rjg/: %#v", got)
219+ }
220+ // The stale filter would have shown nothing: confirm that's the
221+ // behavior the fix avoids.
222+ if got := s.matches("@eda"); len(got) != 0 {
223+ t.Fatalf("stale @eda filter inside eda/ unexpectedly matched: %#v", got)
224+ }
225+}
226+
179 // TestFileSuggesterRecursiveMatch verifies recursive mode flattens the227 // TestFileSuggesterRecursiveMatch verifies recursive mode flattens the
180 // tree and matches against the cwd-relative path, so a pattern can228 // tree and matches against the cwd-relative path, so a pattern can
181 // span directory boundaries.229 // span directory boundaries.
M packages/agent/modes/interactive.go +25 -4
@@ -1605,6 +1605,18 @@ func (i *Interactive) ctrlCExitArmed() bool {
16051605 return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
16061606 }
16071607
1608+// clearFileSuggestQuery strips the filter the user typed after the
1609+// last "@", leaving the bare "@" so the picker stays open. Called when
1610+// navigating between directory levels (Right/Left): the filter applied
1611+// to the level the user was on, not the one being entered, so carrying
1612+// it forward would wrongly hide the new directory's contents.
1613+func (i *Interactive) clearFileSuggestQuery() {
1614+ val := i.ed.Value()
1615+ if idx := strings.LastIndex(val, "@"); idx >= 0 {
1616+ i.ed.SetValue(val[:idx+1])
1617+ }
1618+}
1619+
16081620 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
16091621 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
16101622 // pressing ctrl+c then typing then ctrl+c much later doesn't quit
@@ -2083,12 +2095,21 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
20832095 i.fileSuggest.Down()
20842096 return false
20852097 case tui.KeyRight:
2086- // Open selected directory.
2087- i.fileSuggest.Right()
2098+ // Open selected directory. The filter the user typed picked
2099+ // that directory at the current level; once we descend it no
2100+ // longer applies to the directory's contents, so clear it.
2101+ // Otherwise typing "@eda" then right would re-filter inside
2102+ // eda/ by "eda" and show nothing.
2103+ if i.fileSuggest.Right() {
2104+ i.clearFileSuggestQuery()
2105+ }
20882106 return false
20892107 case tui.KeyLeft:
2090- // Go back to parent directory.
2091- i.fileSuggest.Left()
2108+ // Go back to parent directory. Clear the filter for the same
2109+ // reason as Right: it was scoped to the level we just left.
2110+ if i.fileSuggest.Left() {
2111+ i.clearFileSuggestQuery()
2112+ }
20922113 return false
20932114 case tui.KeyEnter:
20942115 if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok {
@@ -1605,6 +1605,18 @@ func (i *Interactive) ctrlCExitArmed() bool {
1605 return !t.IsZero() && time.Since(t) <= ctrlCExitWindow1605 return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
1606 }1606 }
1607 1607
1608+// clearFileSuggestQuery strips the filter the user typed after the
1609+// last "@", leaving the bare "@" so the picker stays open. Called when
1610+// navigating between directory levels (Right/Left): the filter applied
1611+// to the level the user was on, not the one being entered, so carrying
1612+// it forward would wrongly hide the new directory's contents.
1613+func (i *Interactive) clearFileSuggestQuery() {
1614+ val := i.ed.Value()
1615+ if idx := strings.LastIndex(val, "@"); idx >= 0 {
1616+ i.ed.SetValue(val[:idx+1])
1617+ }
1618+}
1619+
1608 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {1620 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1609 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so1621 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
1610 // pressing ctrl+c then typing then ctrl+c much later doesn't quit1622 // pressing ctrl+c then typing then ctrl+c much later doesn't quit
@@ -2083,12 +2095,21 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2083 i.fileSuggest.Down()2095 i.fileSuggest.Down()
2084 return false2096 return false
2085 case tui.KeyRight:2097 case tui.KeyRight:
2086- // Open selected directory.2098+ // Open selected directory. The filter the user typed picked
2087- i.fileSuggest.Right()2099+ // that directory at the current level; once we descend it no
2100+ // longer applies to the directory's contents, so clear it.
2101+ // Otherwise typing "@eda" then right would re-filter inside
2102+ // eda/ by "eda" and show nothing.
2103+ if i.fileSuggest.Right() {
2104+ i.clearFileSuggestQuery()
2105+ }
2088 return false2106 return false
2089 case tui.KeyLeft:2107 case tui.KeyLeft:
2090- // Go back to parent directory.2108+ // Go back to parent directory. Clear the filter for the same
2091- i.fileSuggest.Left()2109+ // reason as Right: it was scoped to the level we just left.
2110+ if i.fileSuggest.Left() {
2111+ i.clearFileSuggestQuery()
2112+ }
2092 return false2113 return false
2093 case tui.KeyEnter:2114 case tui.KeyEnter:
2094 if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok {2115 if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok {

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