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.
This commit is contained in:
Raymond Gasper 2026-06-09 15:44:47 -04:00
parent 15f76e0fcd
commit 7ac6034d1d
8 changed files with 298 additions and 14 deletions

4
go.mod
View file

@ -6,12 +6,14 @@ require (
github.com/alecthomas/chroma/v2 v2.23.1
github.com/google/uuid v1.6.0
github.com/mattn/go-runewidth v0.0.16
github.com/sahilm/fuzzy v0.1.1
golang.org/x/image v0.18.0
golang.org/x/sys v0.26.0
golang.org/x/term v0.25.0
)
require (
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
golang.org/x/image v0.18.0 // indirect
)

4
go.sum
View file

@ -10,10 +10,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=

View file

@ -944,6 +944,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
Theme: theme,
InlineImagesEnabled: initialCfg.InlineImagesEnabled,
AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
ThemeName: initialCfg.Theme,
ExtensionThemes: extMgr.ThemeOptions,
AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,

View file

@ -32,6 +32,12 @@ type Config struct {
// default; nil/missing means disabled. Toggle from /settings.
AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"`
// RecursiveFileSuggest controls the @-mention file picker. When true
// the picker fuzzy-searches the whole project tree below the working
// directory; nil/missing/false keeps the default directory-by-
// directory browse. Toggle from /settings.
RecursiveFileSuggest *bool `json:"recursive_file_suggest,omitempty"`
// LastChangelogShown is the version whose release-notes
// dialog the user has already seen. When the running binary's
// version differs, the next interactive run shows the

View file

@ -8,9 +8,36 @@ import (
"strings"
"time"
"github.com/sahilm/fuzzy"
"github.com/patriceckhart/zot/packages/tui"
)
// recursiveScanLimits bound the recursive walk so the picker stays
// responsive in very large repos. Hitting either cap stops the walk
// early; the entries gathered so far are still searchable.
const (
maxRecursiveEntries = 5000
maxRecursiveDepth = 12
)
// recursiveSkipDirs are directory names never descended into during a
// recursive scan. They dominate the entry budget without being useful
// @-mention targets.
var recursiveSkipDirs = map[string]bool{
".git": true,
"node_modules": true,
".venv": true,
"venv": true,
"vendor": true,
"target": true,
"dist": true,
"build": true,
".idea": true,
".vscode": true,
"__pycache__": true,
}
// fileSuggester provides an @-triggered file/directory picker popup.
// Type "@" followed by an optional filter to list files in the working
// directory. Arrow up/down navigate, enter selects, esc cancels.
@ -20,8 +47,12 @@ type fileSuggester struct {
lastMatches []fileEntry
cwd string // project root
browseRel string // relative path from cwd we're currently browsing ("" = cwd itself)
cachedDir string // absolute directory we last scanned
cachedAll []fileEntry
// recursive enables a whole-tree fuzzy search instead of
// directory-by-directory browsing. Mirrors the persisted
// recursive_file_suggest setting; toggled live from /settings.
recursive bool
cachedDir string // absolute directory we last scanned
cachedAll []fileEntry
// cachedMTime is the mtime of cachedDir at the time of the scan.
// scan() compares the current mtime against this on every call and
// re-reads the directory if it has changed, so files or folders
@ -49,6 +80,20 @@ func (s *fileSuggester) SetCWD(cwd string) {
}
}
// SetRecursive toggles whole-tree fuzzy search. Switching modes drops
// the cache and resets the browse position so the next render reflects
// the new mode immediately.
func (s *fileSuggester) SetRecursive(on bool) {
if s.recursive == on {
return
}
s.recursive = on
s.browseRel = ""
s.cachedDir = ""
s.cachedAll = nil
s.cursor = 0
}
// browseDir returns the absolute directory currently being browsed.
func (s *fileSuggester) browseDir() string {
if s.browseRel == "" {
@ -66,6 +111,9 @@ func (s *fileSuggester) browseDir() string {
// supports). A failed stat falls through to a fresh ReadDir rather
// than returning a stale cache so transient errors self-heal.
func (s *fileSuggester) scan() []fileEntry {
if s.recursive {
return s.scanRecursive()
}
dir := s.browseDir()
var mtime time.Time
if info, err := os.Stat(dir); err == nil {
@ -103,6 +151,68 @@ func (s *fileSuggester) scan() []fileEntry {
return all
}
// scanRecursive walks the whole project tree below cwd and returns
// every file and directory as a fileEntry whose rel is the path
// relative to cwd. Common heavy directories (.git, node_modules, ...)
// are skipped, and the walk stops once it hits the entry/depth caps.
//
// Results are cached by cwd + mtime of cwd. Unlike the flat scan a
// single mtime can't catch every nested change, so the cache is best
// effort; Invalidate() (called on each keystroke path that matters)
// and the explicit cache drops on toggle keep it fresh enough for an
// interactive picker.
func (s *fileSuggester) scanRecursive() []fileEntry {
root := s.cwd
var mtime time.Time
if info, err := os.Stat(root); err == nil {
mtime = info.ModTime()
}
if s.cachedDir == root && s.cachedAll != nil && !mtime.IsZero() && mtime.Equal(s.cachedMTime) {
return s.cachedAll
}
var all []fileEntry
rootSep := strings.Count(root, string(os.PathSeparator))
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
if d != nil && d.IsDir() {
return filepath.SkipDir
}
return nil
}
if path == root {
return nil
}
name := d.Name()
if d.IsDir() {
if recursiveSkipDirs[name] {
return filepath.SkipDir
}
if strings.Count(path, string(os.PathSeparator))-rootSep >= maxRecursiveDepth {
return filepath.SkipDir
}
}
rel, relErr := filepath.Rel(root, path)
if relErr != nil {
return nil
}
all = append(all, fileEntry{
name: rel,
rel: rel,
isDir: d.IsDir(),
})
if len(all) >= maxRecursiveEntries {
return filepath.SkipAll
}
return nil
})
s.cachedAll = all
s.cachedDir = root
s.cachedMTime = mtime
return all
}
// extractAtQuery returns the filter string after "@".
func extractAtQuery(input string) (string, bool) {
input = strings.TrimRight(input, " ")
@ -121,6 +231,11 @@ func extractAtQuery(input string) (string, bool) {
}
// matches returns file entries matching the current @-query.
//
// An empty query returns every entry in scan order. A non-empty query
// is ranked with sahilm/fuzzy: in recursive mode the pattern is matched
// against each entry's path relative to cwd (so "foobar" can find
// "src/foo/bar.go"); in flat mode it matches the entry's display name.
func (s *fileSuggester) matches(input string) []fileEntry {
query, ok := extractAtQuery(input)
if !ok {
@ -130,14 +245,22 @@ func (s *fileSuggester) matches(input string) []fileEntry {
if len(all) == 0 {
return nil
}
needle := strings.ToLower(query)
if needle == "" {
if query == "" {
return all
}
var out []fileEntry
for _, e := range all {
if strings.Contains(strings.ToLower(e.name), needle) {
out = append(out, e)
haystack := make([]string, len(all))
for i, e := range all {
if s.recursive {
haystack[i] = e.rel
} else {
haystack[i] = e.name
}
}
ranked := fuzzy.Find(query, haystack)
out := make([]fileEntry, 0, len(ranked))
for _, m := range ranked {
if m.Index >= 0 && m.Index < len(all) {
out = append(out, all[m.Index])
}
}
return out
@ -176,8 +299,12 @@ func (s *fileSuggester) Down() {
}
// Right opens the selected directory, descending into it.
// Returns true if a directory was entered.
// Returns true if a directory was entered. Disabled in recursive mode,
// where the whole tree is already flattened into the result list.
func (s *fileSuggester) Right() bool {
if s.recursive {
return false
}
m := s.lastMatches
if len(m) == 0 || s.cursor >= len(m) {
return false
@ -194,8 +321,11 @@ func (s *fileSuggester) Right() bool {
}
// Left goes back to the parent directory.
// Returns true if we moved up.
// Returns true if we moved up. Disabled in recursive mode.
func (s *fileSuggester) Left() bool {
if s.recursive {
return false
}
if s.browseRel == "" {
return false
}
@ -283,10 +413,13 @@ func (s *fileSuggester) Render(input string, th tui.Theme, width int) []string {
}
out = append(out, "")
hint := " \u2191/\u2193 navigate - enter select - esc cancel"
if s.browseRel != "" {
var hint string
switch {
case s.recursive:
hint = " \u2191/\u2193 navigate - enter select - esc cancel (recursive)"
case s.browseRel != "":
hint = " \u2191/\u2193 navigate - \u2192 open - \u2190 back - enter select - esc cancel"
} else {
default:
hint = " \u2191/\u2193 navigate - \u2192 open dir - enter select - esc cancel"
}
out = append(out, th.FG256(th.Muted, hint))

View file

@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
)
@ -97,3 +98,98 @@ func (b byDirsFirst) Less(i, j int) bool {
}
return b[i].name < b[j].name
}
// TestFileSuggesterFuzzyMatch verifies the @-query ranks entries with
// a fuzzy subsequence match rather than a plain substring, so a
// non-contiguous pattern like "fsg" still finds "file_suggest.go".
func TestFileSuggesterFuzzyMatch(t *testing.T) {
tmp := t.TempDir()
for _, name := range []string{"file_suggest.go", "interactive.go", "README.md"} {
if err := os.WriteFile(filepath.Join(tmp, name), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
s := newFileSuggester()
s.SetCWD(tmp)
got := s.matches("@fsg")
if !containsEntry(got, "file_suggest.go", false) {
t.Fatalf("fuzzy query @fsg did not match file_suggest.go: %#v", got)
}
if len(got) == 0 || got[0].name != "file_suggest.go" {
t.Fatalf("file_suggest.go not ranked first for @fsg: %#v", got)
}
}
// TestFileSuggesterRecursiveMatch verifies recursive mode flattens the
// tree and matches against the cwd-relative path, so a pattern can
// span directory boundaries.
func TestFileSuggesterRecursiveMatch(t *testing.T) {
tmp := t.TempDir()
if err := os.MkdirAll(filepath.Join(tmp, "src", "foo"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmp, "src", "foo", "bar.go"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
s := newFileSuggester()
s.SetCWD(tmp)
s.SetRecursive(true)
rel := filepath.Join("src", "foo", "bar.go")
got := s.matches("@foobar")
if !containsEntry(got, rel, false) {
t.Fatalf("recursive @foobar did not match %s: %#v", rel, got)
}
}
// TestFileSuggesterRecursiveSkipsHeavyDirs ensures the walk prunes
// directories like .git that would otherwise dominate the budget.
func TestFileSuggesterRecursiveSkipsHeavyDirs(t *testing.T) {
tmp := t.TempDir()
if err := os.MkdirAll(filepath.Join(tmp, ".git", "objects"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmp, ".git", "objects", "deadbeef"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmp, "main.go"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
s := newFileSuggester()
s.SetCWD(tmp)
s.SetRecursive(true)
all := s.scan()
for _, e := range all {
if e.rel == ".git" || strings.HasPrefix(e.rel, ".git"+string(filepath.Separator)) {
t.Fatalf("recursive scan descended into .git: %#v", e)
}
}
if !containsEntry(all, "main.go", false) {
t.Fatalf("recursive scan missing main.go: %#v", all)
}
}
// TestFileSuggesterToggleResetsCache verifies SetRecursive drops the
// cached scan so the next matches() reflects the new mode.
func TestFileSuggesterToggleResetsCache(t *testing.T) {
tmp := t.TempDir()
if err := os.MkdirAll(filepath.Join(tmp, "pkg"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmp, "pkg", "nested.go"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
s := newFileSuggester()
s.SetCWD(tmp)
rel := filepath.Join("pkg", "nested.go")
if containsEntry(s.matches("@nested"), rel, false) {
t.Fatal("flat mode unexpectedly saw nested.go")
}
s.SetRecursive(true)
if !containsEntry(s.matches("@nested"), rel, false) {
t.Fatal("recursive mode did not surface nested.go after toggle")
}
}

View file

@ -46,6 +46,11 @@ type InteractiveConfig struct {
// re-reading config.json on every open.
AutoSwarmEnabled *bool
// RecursiveFileSuggest mirrors the persisted recursive_file_suggest
// flag at startup. When true the @-mention picker fuzzy-searches the
// whole project tree instead of browsing one directory at a time.
RecursiveFileSuggest *bool
// ThemeName mirrors the persisted config theme value. Empty means auto.
ThemeName string
// ExtensionThemes returns themes bundled with loaded extensions.
@ -228,6 +233,7 @@ type chatCacheKey struct {
type SettingsStore interface {
SetInlineImages(enabled bool) error
SetAutoSwarm(enabled bool) error
SetRecursiveFileSuggest(enabled bool) error
SetReasoning(level string) error
SetTheme(name string) error
}
@ -474,6 +480,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
spin: newSpinner(cfg.Theme),
inputHistoryIndex: -1,
}
i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
if cfg.Agent != nil {
i.agent = cfg.Agent
i.view.Messages = cfg.Agent.Messages()
@ -2622,6 +2629,8 @@ func (i *Interactive) openSettingsDialog() {
autoSwarmHint = "swarm supervisor not available in this mode"
}
recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
reasoningOptions := []settingsOption{
{value: "", label: "off", desc: "no reasoning"},
{value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"},
@ -2685,6 +2694,12 @@ func (i *Interactive) openSettingsDialog() {
disabled: autoSwarmDisabled,
hint: autoSwarmHint,
},
{
key: "recursive_file_suggest",
label: "recursive @-file search",
desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time",
value: recursiveFiles,
},
{
key: "reasoning",
label: "thinking level",
@ -2767,6 +2782,24 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
i.statusOK = "auto-swarm " + onOff(value)
i.statusErr = ""
i.mu.Unlock()
case "recursive_file_suggest":
val := value
i.cfg.RecursiveFileSuggest = &val
if i.cfg.SettingsStore != nil {
if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil {
i.mu.Lock()
i.statusErr = "settings: " + err.Error()
i.mu.Unlock()
return
}
}
// Flip the live picker so the next @ reflects the new mode
// without restarting zot. SetRecursive drops its cache.
i.fileSuggest.SetRecursive(value)
i.mu.Lock()
i.statusOK = "recursive @-file search " + onOff(value)
i.statusErr = ""
i.mu.Unlock()
}
}

View file

@ -22,6 +22,15 @@ func (configSettingsStore) SetAutoSwarm(enabled bool) error {
return SaveConfig(cfg)
}
func (configSettingsStore) SetRecursiveFileSuggest(enabled bool) error {
cfg, err := LoadConfig()
if err != nil {
return err
}
cfg.RecursiveFileSuggest = &enabled
return SaveConfig(cfg)
}
func (configSettingsStore) SetReasoning(level string) error {
cfg, err := LoadConfig()
if err != nil {