diff --git a/go.mod b/go.mod index 2feb44a..6e622d1 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 6cd123c..9a0ffd8 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/packages/agent/cli.go b/packages/agent/cli.go index ede1d0f..e66c05e 100644 --- a/packages/agent/cli.go +++ b/packages/agent/cli.go @@ -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, diff --git a/packages/agent/config.go b/packages/agent/config.go index f74b5c9..401ee05 100644 --- a/packages/agent/config.go +++ b/packages/agent/config.go @@ -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 diff --git a/packages/agent/modes/file_suggest.go b/packages/agent/modes/file_suggest.go index 8a4a718..cac1292 100644 --- a/packages/agent/modes/file_suggest.go +++ b/packages/agent/modes/file_suggest.go @@ -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)) diff --git a/packages/agent/modes/file_suggest_test.go b/packages/agent/modes/file_suggest_test.go index f3d364d..2fed30a 100644 --- a/packages/agent/modes/file_suggest_test.go +++ b/packages/agent/modes/file_suggest_test.go @@ -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") + } +} diff --git a/packages/agent/modes/interactive.go b/packages/agent/modes/interactive.go index cc26cd0..f40c019 100644 --- a/packages/agent/modes/interactive.go +++ b/packages/agent/modes/interactive.go @@ -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() } } diff --git a/packages/agent/settings_store.go b/packages/agent/settings_store.go index 2234d21..4f498b9 100644 --- a/packages/agent/settings_store.go +++ b/packages/agent/settings_store.go @@ -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 {