detached 7ac6034

feat(tui): fuzzy @-file matching with toggleable recursive search

The @-mention file picker previously did a plain case-insensitive
substring match within a single directory, only reachable nesting via
arrow-key drill-down.

- Rank matches with sahilm/fuzzy (pinned v0.1.1 to avoid the go 1.24.5
  directive in v0.1.2, which would exceed CI's Go 1.23).
- Add a recursive mode that walks the whole project tree below cwd,
  matching cwd-relative paths (e.g. @foobar finds src/foo/bar.go),
  skipping heavy dirs (.git, node_modules, ...) and bounded by entry
  and depth caps. Arrow drill-down is disabled in this mode.
- Persist as recursive_file_suggest in config.json, surfaced as a
  /settings checkbox, plumbed through SettingsStore/InteractiveConfig/
  cli. Toggling live flips the picker without a restart.
- Tests for fuzzy ranking, recursive cross-dir match, heavy-dir
  pruning, and cache reset on toggle.
commit
7ac6034d1dbd750e0d7dcfcef45b1f69666ef149
author
Raymond Gasper · · 2026-06-09 19:44 UTC
parent
15f76e0
signature
unsigned
8 files · +298 -14
M go.mod +3 -1
@@ -6,12 +6,14 @@ require (
66 github.com/alecthomas/chroma/v2 v2.23.1
77 github.com/google/uuid v1.6.0
88 github.com/mattn/go-runewidth v0.0.16
9+ github.com/sahilm/fuzzy v0.1.1
10+ golang.org/x/image v0.18.0
911 golang.org/x/sys v0.26.0
1012 golang.org/x/term v0.25.0
1113 )
1214
1315 require (
1416 github.com/dlclark/regexp2 v1.11.5 // indirect
17+ github.com/kylelemons/godebug v1.1.0 // indirect
1518 github.com/rivo/uniseg v0.2.0 // indirect
16- golang.org/x/image v0.18.0 // indirect
1719 )
@@ -6,12 +6,14 @@ require (
6 github.com/alecthomas/chroma/v2 v2.23.16 github.com/alecthomas/chroma/v2 v2.23.1
7 github.com/google/uuid v1.6.07 github.com/google/uuid v1.6.0
8 github.com/mattn/go-runewidth v0.0.168 github.com/mattn/go-runewidth v0.0.16
9+ github.com/sahilm/fuzzy v0.1.1
10+ golang.org/x/image v0.18.0
9 golang.org/x/sys v0.26.011 golang.org/x/sys v0.26.0
10 golang.org/x/term v0.25.012 golang.org/x/term v0.25.0
11 )13 )
12 14
13 require (15 require (
14 github.com/dlclark/regexp2 v1.11.5 // indirect16 github.com/dlclark/regexp2 v1.11.5 // indirect
17+ github.com/kylelemons/godebug v1.1.0 // indirect
15 github.com/rivo/uniseg v0.2.0 // indirect18 github.com/rivo/uniseg v0.2.0 // indirect
16- golang.org/x/image v0.18.0 // indirect
17 )19 )
M go.sum +4 -0 generated file: collapsed
M packages/agent/cli.go +1 -0
@@ -944,6 +944,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
944944 Theme: theme,
945945 InlineImagesEnabled: initialCfg.InlineImagesEnabled,
946946 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
947+ RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
947948 ThemeName: initialCfg.Theme,
948949 ExtensionThemes: extMgr.ThemeOptions,
949950 AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
@@ -944,6 +944,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
944 Theme: theme,944 Theme: theme,
945 InlineImagesEnabled: initialCfg.InlineImagesEnabled,945 InlineImagesEnabled: initialCfg.InlineImagesEnabled,
946 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,946 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
947+ RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
947 ThemeName: initialCfg.Theme,948 ThemeName: initialCfg.Theme,
948 ExtensionThemes: extMgr.ThemeOptions,949 ExtensionThemes: extMgr.ThemeOptions,
949 AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,950 AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
M packages/agent/config.go +6 -0
@@ -32,6 +32,12 @@ type Config struct {
3232 // default; nil/missing means disabled. Toggle from /settings.
3333 AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"`
3434
35+ // RecursiveFileSuggest controls the @-mention file picker. When true
36+ // the picker fuzzy-searches the whole project tree below the working
37+ // directory; nil/missing/false keeps the default directory-by-
38+ // directory browse. Toggle from /settings.
39+ RecursiveFileSuggest *bool `json:"recursive_file_suggest,omitempty"`
40+
3541 // LastChangelogShown is the version whose release-notes
3642 // dialog the user has already seen. When the running binary's
3743 // version differs, the next interactive run shows the
@@ -32,6 +32,12 @@ type Config struct {
32 // default; nil/missing means disabled. Toggle from /settings.32 // default; nil/missing means disabled. Toggle from /settings.
33 AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"`33 AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"`
34 34
35+ // RecursiveFileSuggest controls the @-mention file picker. When true
36+ // the picker fuzzy-searches the whole project tree below the working
37+ // directory; nil/missing/false keeps the default directory-by-
38+ // directory browse. Toggle from /settings.
39+ RecursiveFileSuggest *bool `json:"recursive_file_suggest,omitempty"`
40+
35 // LastChangelogShown is the version whose release-notes41 // LastChangelogShown is the version whose release-notes
36 // dialog the user has already seen. When the running binary's42 // dialog the user has already seen. When the running binary's
37 // version differs, the next interactive run shows the43 // version differs, the next interactive run shows the
M packages/agent/modes/file_suggest.go +146 -13
@@ -8,9 +8,36 @@ import (
88 "strings"
99 "time"
1010
11+ "github.com/sahilm/fuzzy"
12+
1113 "github.com/patriceckhart/zot/packages/tui"
1214 )
1315
16+// recursiveScanLimits bound the recursive walk so the picker stays
17+// responsive in very large repos. Hitting either cap stops the walk
18+// early; the entries gathered so far are still searchable.
19+const (
20+ maxRecursiveEntries = 5000
21+ maxRecursiveDepth = 12
22+)
23+
24+// recursiveSkipDirs are directory names never descended into during a
25+// recursive scan. They dominate the entry budget without being useful
26+// @-mention targets.
27+var recursiveSkipDirs = map[string]bool{
28+ ".git": true,
29+ "node_modules": true,
30+ ".venv": true,
31+ "venv": true,
32+ "vendor": true,
33+ "target": true,
34+ "dist": true,
35+ "build": true,
36+ ".idea": true,
37+ ".vscode": true,
38+ "__pycache__": true,
39+}
40+
1441 // fileSuggester provides an @-triggered file/directory picker popup.
1542 // Type "@" followed by an optional filter to list files in the working
1643 // directory. Arrow up/down navigate, enter selects, esc cancels.
@@ -20,8 +47,12 @@ type fileSuggester struct {
2047 lastMatches []fileEntry
2148 cwd string // project root
2249 browseRel string // relative path from cwd we're currently browsing ("" = cwd itself)
23- cachedDir string // absolute directory we last scanned
24- cachedAll []fileEntry
50+ // recursive enables a whole-tree fuzzy search instead of
51+ // directory-by-directory browsing. Mirrors the persisted
52+ // recursive_file_suggest setting; toggled live from /settings.
53+ recursive bool
54+ cachedDir string // absolute directory we last scanned
55+ cachedAll []fileEntry
2556 // cachedMTime is the mtime of cachedDir at the time of the scan.
2657 // scan() compares the current mtime against this on every call and
2758 // re-reads the directory if it has changed, so files or folders
@@ -49,6 +80,20 @@ func (s *fileSuggester) SetCWD(cwd string) {
4980 }
5081 }
5182
83+// SetRecursive toggles whole-tree fuzzy search. Switching modes drops
84+// the cache and resets the browse position so the next render reflects
85+// the new mode immediately.
86+func (s *fileSuggester) SetRecursive(on bool) {
87+ if s.recursive == on {
88+ return
89+ }
90+ s.recursive = on
91+ s.browseRel = ""
92+ s.cachedDir = ""
93+ s.cachedAll = nil
94+ s.cursor = 0
95+}
96+
5297 // browseDir returns the absolute directory currently being browsed.
5398 func (s *fileSuggester) browseDir() string {
5499 if s.browseRel == "" {
@@ -66,6 +111,9 @@ func (s *fileSuggester) browseDir() string {
66111 // supports). A failed stat falls through to a fresh ReadDir rather
67112 // than returning a stale cache so transient errors self-heal.
68113 func (s *fileSuggester) scan() []fileEntry {
114+ if s.recursive {
115+ return s.scanRecursive()
116+ }
69117 dir := s.browseDir()
70118 var mtime time.Time
71119 if info, err := os.Stat(dir); err == nil {
@@ -103,6 +151,68 @@ func (s *fileSuggester) scan() []fileEntry {
103151 return all
104152 }
105153
154+// scanRecursive walks the whole project tree below cwd and returns
155+// every file and directory as a fileEntry whose rel is the path
156+// relative to cwd. Common heavy directories (.git, node_modules, ...)
157+// are skipped, and the walk stops once it hits the entry/depth caps.
158+//
159+// Results are cached by cwd + mtime of cwd. Unlike the flat scan a
160+// single mtime can't catch every nested change, so the cache is best
161+// effort; Invalidate() (called on each keystroke path that matters)
162+// and the explicit cache drops on toggle keep it fresh enough for an
163+// interactive picker.
164+func (s *fileSuggester) scanRecursive() []fileEntry {
165+ root := s.cwd
166+ var mtime time.Time
167+ if info, err := os.Stat(root); err == nil {
168+ mtime = info.ModTime()
169+ }
170+ if s.cachedDir == root && s.cachedAll != nil && !mtime.IsZero() && mtime.Equal(s.cachedMTime) {
171+ return s.cachedAll
172+ }
173+
174+ var all []fileEntry
175+ rootSep := strings.Count(root, string(os.PathSeparator))
176+ _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
177+ if err != nil {
178+ if d != nil && d.IsDir() {
179+ return filepath.SkipDir
180+ }
181+ return nil
182+ }
183+ if path == root {
184+ return nil
185+ }
186+ name := d.Name()
187+ if d.IsDir() {
188+ if recursiveSkipDirs[name] {
189+ return filepath.SkipDir
190+ }
191+ if strings.Count(path, string(os.PathSeparator))-rootSep >= maxRecursiveDepth {
192+ return filepath.SkipDir
193+ }
194+ }
195+ rel, relErr := filepath.Rel(root, path)
196+ if relErr != nil {
197+ return nil
198+ }
199+ all = append(all, fileEntry{
200+ name: rel,
201+ rel: rel,
202+ isDir: d.IsDir(),
203+ })
204+ if len(all) >= maxRecursiveEntries {
205+ return filepath.SkipAll
206+ }
207+ return nil
208+ })
209+
210+ s.cachedAll = all
211+ s.cachedDir = root
212+ s.cachedMTime = mtime
213+ return all
214+}
215+
106216 // extractAtQuery returns the filter string after "@".
107217 func extractAtQuery(input string) (string, bool) {
108218 input = strings.TrimRight(input, " ")
@@ -121,6 +231,11 @@ func extractAtQuery(input string) (string, bool) {
121231 }
122232
123233 // matches returns file entries matching the current @-query.
234+//
235+// An empty query returns every entry in scan order. A non-empty query
236+// is ranked with sahilm/fuzzy: in recursive mode the pattern is matched
237+// against each entry's path relative to cwd (so "foobar" can find
238+// "src/foo/bar.go"); in flat mode it matches the entry's display name.
124239 func (s *fileSuggester) matches(input string) []fileEntry {
125240 query, ok := extractAtQuery(input)
126241 if !ok {
@@ -130,14 +245,22 @@ func (s *fileSuggester) matches(input string) []fileEntry {
130245 if len(all) == 0 {
131246 return nil
132247 }
133- needle := strings.ToLower(query)
134- if needle == "" {
248+ if query == "" {
135249 return all
136250 }
137- var out []fileEntry
138- for _, e := range all {
139- if strings.Contains(strings.ToLower(e.name), needle) {
140- out = append(out, e)
251+ haystack := make([]string, len(all))
252+ for i, e := range all {
253+ if s.recursive {
254+ haystack[i] = e.rel
255+ } else {
256+ haystack[i] = e.name
257+ }
258+ }
259+ ranked := fuzzy.Find(query, haystack)
260+ out := make([]fileEntry, 0, len(ranked))
261+ for _, m := range ranked {
262+ if m.Index >= 0 && m.Index < len(all) {
263+ out = append(out, all[m.Index])
141264 }
142265 }
143266 return out
@@ -176,8 +299,12 @@ func (s *fileSuggester) Down() {
176299 }
177300
178301 // Right opens the selected directory, descending into it.
179-// Returns true if a directory was entered.
302+// Returns true if a directory was entered. Disabled in recursive mode,
303+// where the whole tree is already flattened into the result list.
180304 func (s *fileSuggester) Right() bool {
305+ if s.recursive {
306+ return false
307+ }
181308 m := s.lastMatches
182309 if len(m) == 0 || s.cursor >= len(m) {
183310 return false
@@ -194,8 +321,11 @@ func (s *fileSuggester) Right() bool {
194321 }
195322
196323 // Left goes back to the parent directory.
197-// Returns true if we moved up.
324+// Returns true if we moved up. Disabled in recursive mode.
198325 func (s *fileSuggester) Left() bool {
326+ if s.recursive {
327+ return false
328+ }
199329 if s.browseRel == "" {
200330 return false
201331 }
@@ -283,10 +413,13 @@ func (s *fileSuggester) Render(input string, th tui.Theme, width int) []string {
283413 }
284414
285415 out = append(out, "")
286- hint := " \u2191/\u2193 navigate - enter select - esc cancel"
287- if s.browseRel != "" {
416+ var hint string
417+ switch {
418+ case s.recursive:
419+ hint = " \u2191/\u2193 navigate - enter select - esc cancel (recursive)"
420+ case s.browseRel != "":
288421 hint = " \u2191/\u2193 navigate - \u2192 open - \u2190 back - enter select - esc cancel"
289- } else {
422+ default:
290423 hint = " \u2191/\u2193 navigate - \u2192 open dir - enter select - esc cancel"
291424 }
292425 out = append(out, th.FG256(th.Muted, hint))
@@ -8,9 +8,36 @@ import (
8 "strings"8 "strings"
9 "time"9 "time"
10 10
11+ "github.com/sahilm/fuzzy"
12+
11 "github.com/patriceckhart/zot/packages/tui"13 "github.com/patriceckhart/zot/packages/tui"
12 )14 )
13 15
16+// recursiveScanLimits bound the recursive walk so the picker stays
17+// responsive in very large repos. Hitting either cap stops the walk
18+// early; the entries gathered so far are still searchable.
19+const (
20+ maxRecursiveEntries = 5000
21+ maxRecursiveDepth = 12
22+)
23+
24+// recursiveSkipDirs are directory names never descended into during a
25+// recursive scan. They dominate the entry budget without being useful
26+// @-mention targets.
27+var recursiveSkipDirs = map[string]bool{
28+ ".git": true,
29+ "node_modules": true,
30+ ".venv": true,
31+ "venv": true,
32+ "vendor": true,
33+ "target": true,
34+ "dist": true,
35+ "build": true,
36+ ".idea": true,
37+ ".vscode": true,
38+ "__pycache__": true,
39+}
40+
14 // fileSuggester provides an @-triggered file/directory picker popup.41 // fileSuggester provides an @-triggered file/directory picker popup.
15 // Type "@" followed by an optional filter to list files in the working42 // Type "@" followed by an optional filter to list files in the working
16 // directory. Arrow up/down navigate, enter selects, esc cancels.43 // directory. Arrow up/down navigate, enter selects, esc cancels.
@@ -20,8 +47,12 @@ type fileSuggester struct {
20 lastMatches []fileEntry47 lastMatches []fileEntry
21 cwd string // project root48 cwd string // project root
22 browseRel string // relative path from cwd we're currently browsing ("" = cwd itself)49 browseRel string // relative path from cwd we're currently browsing ("" = cwd itself)
23- cachedDir string // absolute directory we last scanned50+ // recursive enables a whole-tree fuzzy search instead of
24- cachedAll []fileEntry51+ // directory-by-directory browsing. Mirrors the persisted
52+ // recursive_file_suggest setting; toggled live from /settings.
53+ recursive bool
54+ cachedDir string // absolute directory we last scanned
55+ cachedAll []fileEntry
25 // cachedMTime is the mtime of cachedDir at the time of the scan.56 // cachedMTime is the mtime of cachedDir at the time of the scan.
26 // scan() compares the current mtime against this on every call and57 // scan() compares the current mtime against this on every call and
27 // re-reads the directory if it has changed, so files or folders58 // re-reads the directory if it has changed, so files or folders
@@ -49,6 +80,20 @@ func (s *fileSuggester) SetCWD(cwd string) {
49 }80 }
50 }81 }
51 82
83+// SetRecursive toggles whole-tree fuzzy search. Switching modes drops
84+// the cache and resets the browse position so the next render reflects
85+// the new mode immediately.
86+func (s *fileSuggester) SetRecursive(on bool) {
87+ if s.recursive == on {
88+ return
89+ }
90+ s.recursive = on
91+ s.browseRel = ""
92+ s.cachedDir = ""
93+ s.cachedAll = nil
94+ s.cursor = 0
95+}
96+
52 // browseDir returns the absolute directory currently being browsed.97 // browseDir returns the absolute directory currently being browsed.
53 func (s *fileSuggester) browseDir() string {98 func (s *fileSuggester) browseDir() string {
54 if s.browseRel == "" {99 if s.browseRel == "" {
@@ -66,6 +111,9 @@ func (s *fileSuggester) browseDir() string {
66 // supports). A failed stat falls through to a fresh ReadDir rather111 // supports). A failed stat falls through to a fresh ReadDir rather
67 // than returning a stale cache so transient errors self-heal.112 // than returning a stale cache so transient errors self-heal.
68 func (s *fileSuggester) scan() []fileEntry {113 func (s *fileSuggester) scan() []fileEntry {
114+ if s.recursive {
115+ return s.scanRecursive()
116+ }
69 dir := s.browseDir()117 dir := s.browseDir()
70 var mtime time.Time118 var mtime time.Time
71 if info, err := os.Stat(dir); err == nil {119 if info, err := os.Stat(dir); err == nil {
@@ -103,6 +151,68 @@ func (s *fileSuggester) scan() []fileEntry {
103 return all151 return all
104 }152 }
105 153
154+// scanRecursive walks the whole project tree below cwd and returns
155+// every file and directory as a fileEntry whose rel is the path
156+// relative to cwd. Common heavy directories (.git, node_modules, ...)
157+// are skipped, and the walk stops once it hits the entry/depth caps.
158+//
159+// Results are cached by cwd + mtime of cwd. Unlike the flat scan a
160+// single mtime can't catch every nested change, so the cache is best
161+// effort; Invalidate() (called on each keystroke path that matters)
162+// and the explicit cache drops on toggle keep it fresh enough for an
163+// interactive picker.
164+func (s *fileSuggester) scanRecursive() []fileEntry {
165+ root := s.cwd
166+ var mtime time.Time
167+ if info, err := os.Stat(root); err == nil {
168+ mtime = info.ModTime()
169+ }
170+ if s.cachedDir == root && s.cachedAll != nil && !mtime.IsZero() && mtime.Equal(s.cachedMTime) {
171+ return s.cachedAll
172+ }
173+
174+ var all []fileEntry
175+ rootSep := strings.Count(root, string(os.PathSeparator))
176+ _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
177+ if err != nil {
178+ if d != nil && d.IsDir() {
179+ return filepath.SkipDir
180+ }
181+ return nil
182+ }
183+ if path == root {
184+ return nil
185+ }
186+ name := d.Name()
187+ if d.IsDir() {
188+ if recursiveSkipDirs[name] {
189+ return filepath.SkipDir
190+ }
191+ if strings.Count(path, string(os.PathSeparator))-rootSep >= maxRecursiveDepth {
192+ return filepath.SkipDir
193+ }
194+ }
195+ rel, relErr := filepath.Rel(root, path)
196+ if relErr != nil {
197+ return nil
198+ }
199+ all = append(all, fileEntry{
200+ name: rel,
201+ rel: rel,
202+ isDir: d.IsDir(),
203+ })
204+ if len(all) >= maxRecursiveEntries {
205+ return filepath.SkipAll
206+ }
207+ return nil
208+ })
209+
210+ s.cachedAll = all
211+ s.cachedDir = root
212+ s.cachedMTime = mtime
213+ return all
214+}
215+
106 // extractAtQuery returns the filter string after "@".216 // extractAtQuery returns the filter string after "@".
107 func extractAtQuery(input string) (string, bool) {217 func extractAtQuery(input string) (string, bool) {
108 input = strings.TrimRight(input, " ")218 input = strings.TrimRight(input, " ")
@@ -121,6 +231,11 @@ func extractAtQuery(input string) (string, bool) {
121 }231 }
122 232
123 // matches returns file entries matching the current @-query.233 // matches returns file entries matching the current @-query.
234+//
235+// An empty query returns every entry in scan order. A non-empty query
236+// is ranked with sahilm/fuzzy: in recursive mode the pattern is matched
237+// against each entry's path relative to cwd (so "foobar" can find
238+// "src/foo/bar.go"); in flat mode it matches the entry's display name.
124 func (s *fileSuggester) matches(input string) []fileEntry {239 func (s *fileSuggester) matches(input string) []fileEntry {
125 query, ok := extractAtQuery(input)240 query, ok := extractAtQuery(input)
126 if !ok {241 if !ok {
@@ -130,14 +245,22 @@ func (s *fileSuggester) matches(input string) []fileEntry {
130 if len(all) == 0 {245 if len(all) == 0 {
131 return nil246 return nil
132 }247 }
133- needle := strings.ToLower(query)248+ if query == "" {
134- if needle == "" {
135 return all249 return all
136 }250 }
137- var out []fileEntry251+ haystack := make([]string, len(all))
138- for _, e := range all {252+ for i, e := range all {
139- if strings.Contains(strings.ToLower(e.name), needle) {253+ if s.recursive {
140- out = append(out, e)254+ haystack[i] = e.rel
255+ } else {
256+ haystack[i] = e.name
257+ }
258+ }
259+ ranked := fuzzy.Find(query, haystack)
260+ out := make([]fileEntry, 0, len(ranked))
261+ for _, m := range ranked {
262+ if m.Index >= 0 && m.Index < len(all) {
263+ out = append(out, all[m.Index])
141 }264 }
142 }265 }
143 return out266 return out
@@ -176,8 +299,12 @@ func (s *fileSuggester) Down() {
176 }299 }
177 300
178 // Right opens the selected directory, descending into it.301 // Right opens the selected directory, descending into it.
179-// Returns true if a directory was entered.302+// Returns true if a directory was entered. Disabled in recursive mode,
303+// where the whole tree is already flattened into the result list.
180 func (s *fileSuggester) Right() bool {304 func (s *fileSuggester) Right() bool {
305+ if s.recursive {
306+ return false
307+ }
181 m := s.lastMatches308 m := s.lastMatches
182 if len(m) == 0 || s.cursor >= len(m) {309 if len(m) == 0 || s.cursor >= len(m) {
183 return false310 return false
@@ -194,8 +321,11 @@ func (s *fileSuggester) Right() bool {
194 }321 }
195 322
196 // Left goes back to the parent directory.323 // Left goes back to the parent directory.
197-// Returns true if we moved up.324+// Returns true if we moved up. Disabled in recursive mode.
198 func (s *fileSuggester) Left() bool {325 func (s *fileSuggester) Left() bool {
326+ if s.recursive {
327+ return false
328+ }
199 if s.browseRel == "" {329 if s.browseRel == "" {
200 return false330 return false
201 }331 }
@@ -283,10 +413,13 @@ func (s *fileSuggester) Render(input string, th tui.Theme, width int) []string {
283 }413 }
284 414
285 out = append(out, "")415 out = append(out, "")
286- hint := " \u2191/\u2193 navigate - enter select - esc cancel"416+ var hint string
287- if s.browseRel != "" {417+ switch {
418+ case s.recursive:
419+ hint = " \u2191/\u2193 navigate - enter select - esc cancel (recursive)"
420+ case s.browseRel != "":
288 hint = " \u2191/\u2193 navigate - \u2192 open - \u2190 back - enter select - esc cancel"421 hint = " \u2191/\u2193 navigate - \u2192 open - \u2190 back - enter select - esc cancel"
289- } else {422+ default:
290 hint = " \u2191/\u2193 navigate - \u2192 open dir - enter select - esc cancel"423 hint = " \u2191/\u2193 navigate - \u2192 open dir - enter select - esc cancel"
291 }424 }
292 out = append(out, th.FG256(th.Muted, hint))425 out = append(out, th.FG256(th.Muted, hint))
M packages/agent/modes/file_suggest_test.go +96 -0
@@ -4,6 +4,7 @@ import (
44 "os"
55 "path/filepath"
66 "sort"
7+ "strings"
78 "testing"
89 "time"
910 )
@@ -97,3 +98,98 @@ func (b byDirsFirst) Less(i, j int) bool {
9798 }
9899 return b[i].name < b[j].name
99100 }
101+
102+// TestFileSuggesterFuzzyMatch verifies the @-query ranks entries with
103+// a fuzzy subsequence match rather than a plain substring, so a
104+// non-contiguous pattern like "fsg" still finds "file_suggest.go".
105+func TestFileSuggesterFuzzyMatch(t *testing.T) {
106+ tmp := t.TempDir()
107+ for _, name := range []string{"file_suggest.go", "interactive.go", "README.md"} {
108+ if err := os.WriteFile(filepath.Join(tmp, name), []byte("x"), 0o644); err != nil {
109+ t.Fatal(err)
110+ }
111+ }
112+ s := newFileSuggester()
113+ s.SetCWD(tmp)
114+
115+ got := s.matches("@fsg")
116+ if !containsEntry(got, "file_suggest.go", false) {
117+ t.Fatalf("fuzzy query @fsg did not match file_suggest.go: %#v", got)
118+ }
119+ if len(got) == 0 || got[0].name != "file_suggest.go" {
120+ t.Fatalf("file_suggest.go not ranked first for @fsg: %#v", got)
121+ }
122+}
123+
124+// TestFileSuggesterRecursiveMatch verifies recursive mode flattens the
125+// tree and matches against the cwd-relative path, so a pattern can
126+// span directory boundaries.
127+func TestFileSuggesterRecursiveMatch(t *testing.T) {
128+ tmp := t.TempDir()
129+ if err := os.MkdirAll(filepath.Join(tmp, "src", "foo"), 0o755); err != nil {
130+ t.Fatal(err)
131+ }
132+ if err := os.WriteFile(filepath.Join(tmp, "src", "foo", "bar.go"), []byte("x"), 0o644); err != nil {
133+ t.Fatal(err)
134+ }
135+ s := newFileSuggester()
136+ s.SetCWD(tmp)
137+ s.SetRecursive(true)
138+
139+ rel := filepath.Join("src", "foo", "bar.go")
140+ got := s.matches("@foobar")
141+ if !containsEntry(got, rel, false) {
142+ t.Fatalf("recursive @foobar did not match %s: %#v", rel, got)
143+ }
144+}
145+
146+// TestFileSuggesterRecursiveSkipsHeavyDirs ensures the walk prunes
147+// directories like .git that would otherwise dominate the budget.
148+func TestFileSuggesterRecursiveSkipsHeavyDirs(t *testing.T) {
149+ tmp := t.TempDir()
150+ if err := os.MkdirAll(filepath.Join(tmp, ".git", "objects"), 0o755); err != nil {
151+ t.Fatal(err)
152+ }
153+ if err := os.WriteFile(filepath.Join(tmp, ".git", "objects", "deadbeef"), []byte("x"), 0o644); err != nil {
154+ t.Fatal(err)
155+ }
156+ if err := os.WriteFile(filepath.Join(tmp, "main.go"), []byte("x"), 0o644); err != nil {
157+ t.Fatal(err)
158+ }
159+ s := newFileSuggester()
160+ s.SetCWD(tmp)
161+ s.SetRecursive(true)
162+
163+ all := s.scan()
164+ for _, e := range all {
165+ if e.rel == ".git" || strings.HasPrefix(e.rel, ".git"+string(filepath.Separator)) {
166+ t.Fatalf("recursive scan descended into .git: %#v", e)
167+ }
168+ }
169+ if !containsEntry(all, "main.go", false) {
170+ t.Fatalf("recursive scan missing main.go: %#v", all)
171+ }
172+}
173+
174+// TestFileSuggesterToggleResetsCache verifies SetRecursive drops the
175+// cached scan so the next matches() reflects the new mode.
176+func TestFileSuggesterToggleResetsCache(t *testing.T) {
177+ tmp := t.TempDir()
178+ if err := os.MkdirAll(filepath.Join(tmp, "pkg"), 0o755); err != nil {
179+ t.Fatal(err)
180+ }
181+ if err := os.WriteFile(filepath.Join(tmp, "pkg", "nested.go"), []byte("x"), 0o644); err != nil {
182+ t.Fatal(err)
183+ }
184+ s := newFileSuggester()
185+ s.SetCWD(tmp)
186+
187+ rel := filepath.Join("pkg", "nested.go")
188+ if containsEntry(s.matches("@nested"), rel, false) {
189+ t.Fatal("flat mode unexpectedly saw nested.go")
190+ }
191+ s.SetRecursive(true)
192+ if !containsEntry(s.matches("@nested"), rel, false) {
193+ t.Fatal("recursive mode did not surface nested.go after toggle")
194+ }
195+}
@@ -4,6 +4,7 @@ import (
4 "os"4 "os"
5 "path/filepath"5 "path/filepath"
6 "sort"6 "sort"
7+ "strings"
7 "testing"8 "testing"
8 "time"9 "time"
9 )10 )
@@ -97,3 +98,98 @@ func (b byDirsFirst) Less(i, j int) bool {
97 }98 }
98 return b[i].name < b[j].name99 return b[i].name < b[j].name
99 }100 }
101+
102+// TestFileSuggesterFuzzyMatch verifies the @-query ranks entries with
103+// a fuzzy subsequence match rather than a plain substring, so a
104+// non-contiguous pattern like "fsg" still finds "file_suggest.go".
105+func TestFileSuggesterFuzzyMatch(t *testing.T) {
106+ tmp := t.TempDir()
107+ for _, name := range []string{"file_suggest.go", "interactive.go", "README.md"} {
108+ if err := os.WriteFile(filepath.Join(tmp, name), []byte("x"), 0o644); err != nil {
109+ t.Fatal(err)
110+ }
111+ }
112+ s := newFileSuggester()
113+ s.SetCWD(tmp)
114+
115+ got := s.matches("@fsg")
116+ if !containsEntry(got, "file_suggest.go", false) {
117+ t.Fatalf("fuzzy query @fsg did not match file_suggest.go: %#v", got)
118+ }
119+ if len(got) == 0 || got[0].name != "file_suggest.go" {
120+ t.Fatalf("file_suggest.go not ranked first for @fsg: %#v", got)
121+ }
122+}
123+
124+// TestFileSuggesterRecursiveMatch verifies recursive mode flattens the
125+// tree and matches against the cwd-relative path, so a pattern can
126+// span directory boundaries.
127+func TestFileSuggesterRecursiveMatch(t *testing.T) {
128+ tmp := t.TempDir()
129+ if err := os.MkdirAll(filepath.Join(tmp, "src", "foo"), 0o755); err != nil {
130+ t.Fatal(err)
131+ }
132+ if err := os.WriteFile(filepath.Join(tmp, "src", "foo", "bar.go"), []byte("x"), 0o644); err != nil {
133+ t.Fatal(err)
134+ }
135+ s := newFileSuggester()
136+ s.SetCWD(tmp)
137+ s.SetRecursive(true)
138+
139+ rel := filepath.Join("src", "foo", "bar.go")
140+ got := s.matches("@foobar")
141+ if !containsEntry(got, rel, false) {
142+ t.Fatalf("recursive @foobar did not match %s: %#v", rel, got)
143+ }
144+}
145+
146+// TestFileSuggesterRecursiveSkipsHeavyDirs ensures the walk prunes
147+// directories like .git that would otherwise dominate the budget.
148+func TestFileSuggesterRecursiveSkipsHeavyDirs(t *testing.T) {
149+ tmp := t.TempDir()
150+ if err := os.MkdirAll(filepath.Join(tmp, ".git", "objects"), 0o755); err != nil {
151+ t.Fatal(err)
152+ }
153+ if err := os.WriteFile(filepath.Join(tmp, ".git", "objects", "deadbeef"), []byte("x"), 0o644); err != nil {
154+ t.Fatal(err)
155+ }
156+ if err := os.WriteFile(filepath.Join(tmp, "main.go"), []byte("x"), 0o644); err != nil {
157+ t.Fatal(err)
158+ }
159+ s := newFileSuggester()
160+ s.SetCWD(tmp)
161+ s.SetRecursive(true)
162+
163+ all := s.scan()
164+ for _, e := range all {
165+ if e.rel == ".git" || strings.HasPrefix(e.rel, ".git"+string(filepath.Separator)) {
166+ t.Fatalf("recursive scan descended into .git: %#v", e)
167+ }
168+ }
169+ if !containsEntry(all, "main.go", false) {
170+ t.Fatalf("recursive scan missing main.go: %#v", all)
171+ }
172+}
173+
174+// TestFileSuggesterToggleResetsCache verifies SetRecursive drops the
175+// cached scan so the next matches() reflects the new mode.
176+func TestFileSuggesterToggleResetsCache(t *testing.T) {
177+ tmp := t.TempDir()
178+ if err := os.MkdirAll(filepath.Join(tmp, "pkg"), 0o755); err != nil {
179+ t.Fatal(err)
180+ }
181+ if err := os.WriteFile(filepath.Join(tmp, "pkg", "nested.go"), []byte("x"), 0o644); err != nil {
182+ t.Fatal(err)
183+ }
184+ s := newFileSuggester()
185+ s.SetCWD(tmp)
186+
187+ rel := filepath.Join("pkg", "nested.go")
188+ if containsEntry(s.matches("@nested"), rel, false) {
189+ t.Fatal("flat mode unexpectedly saw nested.go")
190+ }
191+ s.SetRecursive(true)
192+ if !containsEntry(s.matches("@nested"), rel, false) {
193+ t.Fatal("recursive mode did not surface nested.go after toggle")
194+ }
195+}
M packages/agent/modes/interactive.go +33 -0
@@ -46,6 +46,11 @@ type InteractiveConfig struct {
4646 // re-reading config.json on every open.
4747 AutoSwarmEnabled *bool
4848
49+ // RecursiveFileSuggest mirrors the persisted recursive_file_suggest
50+ // flag at startup. When true the @-mention picker fuzzy-searches the
51+ // whole project tree instead of browsing one directory at a time.
52+ RecursiveFileSuggest *bool
53+
4954 // ThemeName mirrors the persisted config theme value. Empty means auto.
5055 ThemeName string
5156 // ExtensionThemes returns themes bundled with loaded extensions.
@@ -228,6 +233,7 @@ type chatCacheKey struct {
228233 type SettingsStore interface {
229234 SetInlineImages(enabled bool) error
230235 SetAutoSwarm(enabled bool) error
236+ SetRecursiveFileSuggest(enabled bool) error
231237 SetReasoning(level string) error
232238 SetTheme(name string) error
233239 }
@@ -474,6 +480,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
474480 spin: newSpinner(cfg.Theme),
475481 inputHistoryIndex: -1,
476482 }
483+ i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
477484 if cfg.Agent != nil {
478485 i.agent = cfg.Agent
479486 i.view.Messages = cfg.Agent.Messages()
@@ -2622,6 +2629,8 @@ func (i *Interactive) openSettingsDialog() {
26222629 autoSwarmHint = "swarm supervisor not available in this mode"
26232630 }
26242631
2632+ recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
2633+
26252634 reasoningOptions := []settingsOption{
26262635 {value: "", label: "off", desc: "no reasoning"},
26272636 {value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"},
@@ -2685,6 +2694,12 @@ func (i *Interactive) openSettingsDialog() {
26852694 disabled: autoSwarmDisabled,
26862695 hint: autoSwarmHint,
26872696 },
2697+ {
2698+ key: "recursive_file_suggest",
2699+ label: "recursive @-file search",
2700+ desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time",
2701+ value: recursiveFiles,
2702+ },
26882703 {
26892704 key: "reasoning",
26902705 label: "thinking level",
@@ -2767,6 +2782,24 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
27672782 i.statusOK = "auto-swarm " + onOff(value)
27682783 i.statusErr = ""
27692784 i.mu.Unlock()
2785+ case "recursive_file_suggest":
2786+ val := value
2787+ i.cfg.RecursiveFileSuggest = &val
2788+ if i.cfg.SettingsStore != nil {
2789+ if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil {
2790+ i.mu.Lock()
2791+ i.statusErr = "settings: " + err.Error()
2792+ i.mu.Unlock()
2793+ return
2794+ }
2795+ }
2796+ // Flip the live picker so the next @ reflects the new mode
2797+ // without restarting zot. SetRecursive drops its cache.
2798+ i.fileSuggest.SetRecursive(value)
2799+ i.mu.Lock()
2800+ i.statusOK = "recursive @-file search " + onOff(value)
2801+ i.statusErr = ""
2802+ i.mu.Unlock()
27702803 }
27712804 }
27722805
@@ -46,6 +46,11 @@ type InteractiveConfig struct {
46 // re-reading config.json on every open.46 // re-reading config.json on every open.
47 AutoSwarmEnabled *bool47 AutoSwarmEnabled *bool
48 48
49+ // RecursiveFileSuggest mirrors the persisted recursive_file_suggest
50+ // flag at startup. When true the @-mention picker fuzzy-searches the
51+ // whole project tree instead of browsing one directory at a time.
52+ RecursiveFileSuggest *bool
53+
49 // ThemeName mirrors the persisted config theme value. Empty means auto.54 // ThemeName mirrors the persisted config theme value. Empty means auto.
50 ThemeName string55 ThemeName string
51 // ExtensionThemes returns themes bundled with loaded extensions.56 // ExtensionThemes returns themes bundled with loaded extensions.
@@ -228,6 +233,7 @@ type chatCacheKey struct {
228 type SettingsStore interface {233 type SettingsStore interface {
229 SetInlineImages(enabled bool) error234 SetInlineImages(enabled bool) error
230 SetAutoSwarm(enabled bool) error235 SetAutoSwarm(enabled bool) error
236+ SetRecursiveFileSuggest(enabled bool) error
231 SetReasoning(level string) error237 SetReasoning(level string) error
232 SetTheme(name string) error238 SetTheme(name string) error
233 }239 }
@@ -474,6 +480,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
474 spin: newSpinner(cfg.Theme),480 spin: newSpinner(cfg.Theme),
475 inputHistoryIndex: -1,481 inputHistoryIndex: -1,
476 }482 }
483+ i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
477 if cfg.Agent != nil {484 if cfg.Agent != nil {
478 i.agent = cfg.Agent485 i.agent = cfg.Agent
479 i.view.Messages = cfg.Agent.Messages()486 i.view.Messages = cfg.Agent.Messages()
@@ -2622,6 +2629,8 @@ func (i *Interactive) openSettingsDialog() {
2622 autoSwarmHint = "swarm supervisor not available in this mode"2629 autoSwarmHint = "swarm supervisor not available in this mode"
2623 }2630 }
2624 2631
2632+ recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
2633+
2625 reasoningOptions := []settingsOption{2634 reasoningOptions := []settingsOption{
2626 {value: "", label: "off", desc: "no reasoning"},2635 {value: "", label: "off", desc: "no reasoning"},
2627 {value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"},2636 {value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"},
@@ -2685,6 +2694,12 @@ func (i *Interactive) openSettingsDialog() {
2685 disabled: autoSwarmDisabled,2694 disabled: autoSwarmDisabled,
2686 hint: autoSwarmHint,2695 hint: autoSwarmHint,
2687 },2696 },
2697+ {
2698+ key: "recursive_file_suggest",
2699+ label: "recursive @-file search",
2700+ desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time",
2701+ value: recursiveFiles,
2702+ },
2688 {2703 {
2689 key: "reasoning",2704 key: "reasoning",
2690 label: "thinking level",2705 label: "thinking level",
@@ -2767,6 +2782,24 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
2767 i.statusOK = "auto-swarm " + onOff(value)2782 i.statusOK = "auto-swarm " + onOff(value)
2768 i.statusErr = ""2783 i.statusErr = ""
2769 i.mu.Unlock()2784 i.mu.Unlock()
2785+ case "recursive_file_suggest":
2786+ val := value
2787+ i.cfg.RecursiveFileSuggest = &val
2788+ if i.cfg.SettingsStore != nil {
2789+ if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil {
2790+ i.mu.Lock()
2791+ i.statusErr = "settings: " + err.Error()
2792+ i.mu.Unlock()
2793+ return
2794+ }
2795+ }
2796+ // Flip the live picker so the next @ reflects the new mode
2797+ // without restarting zot. SetRecursive drops its cache.
2798+ i.fileSuggest.SetRecursive(value)
2799+ i.mu.Lock()
2800+ i.statusOK = "recursive @-file search " + onOff(value)
2801+ i.statusErr = ""
2802+ i.mu.Unlock()
2770 }2803 }
2771 }2804 }
2772 2805
M packages/agent/settings_store.go +9 -0
@@ -22,6 +22,15 @@ func (configSettingsStore) SetAutoSwarm(enabled bool) error {
2222 return SaveConfig(cfg)
2323 }
2424
25+func (configSettingsStore) SetRecursiveFileSuggest(enabled bool) error {
26+ cfg, err := LoadConfig()
27+ if err != nil {
28+ return err
29+ }
30+ cfg.RecursiveFileSuggest = &enabled
31+ return SaveConfig(cfg)
32+}
33+
2534 func (configSettingsStore) SetReasoning(level string) error {
2635 cfg, err := LoadConfig()
2736 if err != nil {
@@ -22,6 +22,15 @@ func (configSettingsStore) SetAutoSwarm(enabled bool) error {
22 return SaveConfig(cfg)22 return SaveConfig(cfg)
23 }23 }
24 24
25+func (configSettingsStore) SetRecursiveFileSuggest(enabled bool) error {
26+ cfg, err := LoadConfig()
27+ if err != nil {
28+ return err
29+ }
30+ cfg.RecursiveFileSuggest = &enabled
31+ return SaveConfig(cfg)
32+}
33+
25 func (configSettingsStore) SetReasoning(level string) error {34 func (configSettingsStore) SetReasoning(level string) error {
26 cfg, err := LoadConfig()35 cfg, err := LoadConfig()
27 if err != nil {36 if err != nil {

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