on main help sign in
feat: limit filepath walk, automatic low limits when not git repo (#1052)

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

commitc96abaed6b4115352e490129006dcfd165b49cba authorCarlos Alexandro Becker <caarlos0@users.noreply.github.com> dateWed 1 Oct 2025, 11:16 (1 year ago) parentd102102
12 files changed+296−238
internal/config/config.go+35 internal/config/load.go+27 internal/fsext/fileutil.go+19−46 internal/fsext/fileutil_test.go+43−47 internal/fsext/ignore_test.go+1−7 internal/fsext/lookup_test.go+9−55 internal/fsext/ls.go+20−13 internal/fsext/ls_test.go+33−40 internal/llm/prompt/coder.go+1−1 internal/llm/tools/ls.go+34−27 internal/tui/components/chat/editor/editor.go+3−1 schema.json+71−1
internal/config/config.go+35
@@ -131,6 +131,19 @@ type TUIOptions struct {
131131 CompactMode bool `json:"compact_mode,omitempty" jsonschema:"description=Enable compact mode for the TUI interface,default=false"`
132132 DiffMode string `json:"diff_mode,omitempty" jsonschema:"description=Diff mode for the TUI interface,enum=unified,enum=split"`
133133 // Here we can add themes later or any TUI related options
134+ //
135+
136+ Completions Completions `json:"completions,omitzero" jsonschema:"description=Completions UI options"`
137+}
138+
139+// Completions defines options for the completions UI.
140+type Completions struct {
141+ MaxDepth *int `json:"max_depth,omitempty" jsonschema:"description=Maximum depth for the ls tool,default=0,example=10"`
142+ MaxItems *int `json:"max_items,omitempty" jsonschema:"description=Maximum number of items to return for the ls tool,default=1000,example=100"`
143+}
144+
145+func (c Completions) Limits() (depth, items int) {
146+ return ptrValOr(c.MaxDepth, -1), ptrValOr(c.MaxItems, -1)
134147}
135148
136149type Permissions struct {
@@ -246,6 +259,19 @@ type Agent struct {
246259 ContextPaths []string `json:"context_paths,omitempty"`
247260}
248261
262+type Tools struct {
263+ Ls ToolLs `json:"ls,omitzero"`
264+}
265+
266+type ToolLs struct {
267+ MaxDepth *int `json:"max_depth,omitempty" jsonschema:"description=Maximum depth for the ls tool,default=0,example=10"`
268+ MaxItems *int `json:"max_items,omitempty" jsonschema:"description=Maximum number of items to return for the ls tool,default=1000,example=100"`
269+}
270+
271+func (t ToolLs) Limits() (depth, items int) {
272+ return ptrValOr(t.MaxDepth, -1), ptrValOr(t.MaxItems, -1)
273+}
274+
249275// Config holds the configuration for crush.
250276type Config struct {
251277 Schema string `json:"$schema,omitempty"`
@@ -264,6 +290,8 @@ type Config struct {
264290
265291 Permissions *Permissions `json:"permissions,omitempty" jsonschema:"description=Permission settings for tool usage"`
266292
293+ Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"`
294+
267295 // Internal
268296 workingDir string `json:"-"`
269297 // TODO: most likely remove this concept when I come back to it
@@ -579,3 +607,10 @@ func resolveEnvs(envs map[string]string) []string {
579607 }
580608 return res
581609}
610+
611+func ptrValOr[T any](t *T, el T) T {
612+ if t == nil {
613+ return el
614+ }
615+ return *t
616+}
internal/config/load.go+27
@@ -1,12 +1,14 @@
11package config
22
33import (
4+ "context"
45 "encoding/json"
56 "fmt"
67 "io"
78 "log/slog"
89 "maps"
910 "os"
11+ "os/exec"
1012 "path/filepath"
1113 "runtime"
1214 "slices"
@@ -62,6 +64,16 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) {
6264 cfg.Options.Debug,
6365 )
6466
67+ if !isInsideWorktree() {
68+ const depth = 2
69+ const items = 100
70+ slog.Warn("No git repository detected in working directory, will limit file walk operations", "depth", depth, "items", items)
71+ assignIfNil(&cfg.Tools.Ls.MaxDepth, depth)
72+ assignIfNil(&cfg.Tools.Ls.MaxItems, items)
73+ assignIfNil(&cfg.Options.TUI.Completions.MaxDepth, depth)
74+ assignIfNil(&cfg.Options.TUI.Completions.MaxItems, items)
75+ }
76+
6577 // Load known providers, this loads the config from catwalk
6678 providers, err := Providers(cfg)
6779 if err != nil {
@@ -638,3 +650,18 @@ func GlobalConfigData() string {
638650
639651 return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
640652}
653+
654+func assignIfNil[T any](ptr **T, val T) {
655+ if *ptr == nil {
656+ *ptr = &val
657+ }
658+}
659+
660+func isInsideWorktree() bool {
661+ bts, err := exec.CommandContext(
662+ context.Background(),
663+ "git", "rev-parse",
664+ "--is-inside-work-tree",
665+ ).CombinedOutput()
666+ return err == nil && strings.TrimSpace(string(bts)) == "true"
667+}
internal/fsext/fileutil.go+19−46
@@ -1,15 +1,17 @@
11package fsext
22
33import (
4+ "errors"
45 "fmt"
56 "os"
67 "path/filepath"
7− "sort"
8+ "slices"
89 "strings"
910 "time"
1011
1112 "github.com/bmatcuk/doublestar/v4"
1213 "github.com/charlievieth/fastwalk"
14+ "github.com/charmbracelet/crush/internal/csync"
1315 "github.com/charmbracelet/crush/internal/home"
1416)
1517
@@ -80,10 +82,9 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool,
8082 pattern = filepath.ToSlash(pattern)
8183
8284 walker := NewFastGlobWalker(searchPath)
83− var matches []FileInfo
85+ found := csync.NewSlice[FileInfo]()
8486 conf := fastwalk.Config{
85− Follow: true,
86− // Use forward slashes when running a Windows binary under WSL or MSYS
87+ Follow: true,
8788 ToSlash: fastwalk.DefaultToSlash(),
8889 Sort: fastwalk.SortFilesFirst,
8990 }
@@ -121,31 +122,26 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool,
121122 return nil
122123 }
123124
124− matches = append(matches, FileInfo{Path: path, ModTime: info.ModTime()})
125− if limit > 0 && len(matches) >= limit*2 {
125+ found.Append(FileInfo{Path: path, ModTime: info.ModTime()})
126+ if limit > 0 && found.Len() >= limit*2 { // NOTE: why x2?
126127 return filepath.SkipAll
127128 }
128129 return nil
129130 })
130− if err != nil {
131+ if err != nil && !errors.Is(err, filepath.SkipAll) {
131132 return nil, false, fmt.Errorf("fastwalk error: %w", err)
132133 }
133134
134− sort.Slice(matches, func(i, j int) bool {
135− return matches[i].ModTime.After(matches[j].ModTime)
135+ matches := slices.SortedFunc(found.Seq(), func(a, b FileInfo) int {
136+ return b.ModTime.Compare(a.ModTime)
136137 })
137−
138− truncated := false
139− if limit > 0 && len(matches) > limit {
140− matches = matches[:limit]
141− truncated = true
142− }
138+ matches, truncated := truncate(matches, limit)
143139
144140 results := make([]string, len(matches))
145141 for i, m := range matches {
146142 results[i] = m.Path
147143 }
148− return results, truncated, nil
144+ return results, truncated || errors.Is(err, filepath.SkipAll), nil
149145}
150146
151147// ShouldExcludeFile checks if a file should be excluded from processing
@@ -155,36 +151,6 @@ func ShouldExcludeFile(rootPath, filePath string) bool {
155151 shouldIgnore(filePath, nil)
156152}
157153
158−// WalkDirectories walks a directory tree and calls the provided function for each directory,
159−// respecting hierarchical .gitignore/.crushignore files like git does.
160−func WalkDirectories(rootPath string, fn func(path string, d os.DirEntry, err error) error) error {
161− dl := NewDirectoryLister(rootPath)
162−
163− conf := fastwalk.Config{
164− Follow: true,
165− ToSlash: fastwalk.DefaultToSlash(),
166− Sort: fastwalk.SortDirsFirst,
167− }
168−
169− return fastwalk.Walk(&conf, rootPath, func(path string, d os.DirEntry, err error) error {
170− if err != nil {
171− return fn(path, d, err)
172− }
173−
174− // Only process directories
175− if !d.IsDir() {
176− return nil
177− }
178−
179− // Check if directory should be ignored
180− if dl.shouldIgnore(path, nil) {
181− return filepath.SkipDir
182− }
183−
184− return fn(path, d, err)
185− })
186−}
187−
188154func PrettyPath(path string) string {
189155 return home.Short(path)
190156}
@@ -248,3 +214,10 @@ func ToWindowsLineEndings(content string) (string, bool) {
248214 }
249215 return content, false
250216}
217+
218+func truncate[T any](input []T, limit int) ([]T, bool) {
219+ if limit > 0 && len(input) > limit {
220+ return input[:limit], true
221+ }
222+ return input, false
223+}
internal/fsext/fileutil_test.go+43−47
@@ -5,7 +5,6 @@ import (
55 "os"
66 "path/filepath"
77 "testing"
8− "testing/synctest"
98 "time"
109
1110 "github.com/stretchr/testify/require"
@@ -148,37 +147,35 @@ func TestGlobWithDoubleStar(t *testing.T) {
148147 require.NoError(t, err)
149148 require.False(t, truncated)
150149
151− require.Equal(t, matches, []string{file1})
150+ require.Equal(t, []string{file1}, matches)
152151 })
153152
154153 t.Run("returns results sorted by modification time (newest first)", func(t *testing.T) {
155− synctest.Test(t, func(t *testing.T) {
156− testDir := t.TempDir()
154+ testDir := t.TempDir()
157155
158− file1 := filepath.Join(testDir, "file1.txt")
159− require.NoError(t, os.WriteFile(file1, []byte("first"), 0o644))
156+ file1 := filepath.Join(testDir, "file1.txt")
157+ require.NoError(t, os.WriteFile(file1, []byte("first"), 0o644))
160158
161− file2 := filepath.Join(testDir, "file2.txt")
162− require.NoError(t, os.WriteFile(file2, []byte("second"), 0o644))
159+ file2 := filepath.Join(testDir, "file2.txt")
160+ require.NoError(t, os.WriteFile(file2, []byte("second"), 0o644))
163161
164− file3 := filepath.Join(testDir, "file3.txt")
165− require.NoError(t, os.WriteFile(file3, []byte("third"), 0o644))
162+ file3 := filepath.Join(testDir, "file3.txt")
163+ require.NoError(t, os.WriteFile(file3, []byte("third"), 0o644))
166164
167− base := time.Now()
168− m1 := base
169− m2 := base.Add(1 * time.Millisecond)
170− m3 := base.Add(2 * time.Millisecond)
165+ base := time.Now()
166+ m1 := base
167+ m2 := base.Add(10 * time.Hour)
168+ m3 := base.Add(20 * time.Hour)
171169
172− require.NoError(t, os.Chtimes(file1, m1, m1))
173− require.NoError(t, os.Chtimes(file2, m2, m2))
174− require.NoError(t, os.Chtimes(file3, m3, m3))
170+ require.NoError(t, os.Chtimes(file1, m1, m1))
171+ require.NoError(t, os.Chtimes(file2, m2, m2))
172+ require.NoError(t, os.Chtimes(file3, m3, m3))
175173
176− matches, truncated, err := GlobWithDoubleStar("*.txt", testDir, 0)
177− require.NoError(t, err)
178− require.False(t, truncated)
174+ matches, truncated, err := GlobWithDoubleStar("*.txt", testDir, 0)
175+ require.NoError(t, err)
176+ require.False(t, truncated)
179177
180− require.Equal(t, matches, []string{file3, file2, file1})
181− })
178+ require.Equal(t, []string{file3, file2, file1}, matches)
182179 })
183180
184181 t.Run("handles empty directory", func(t *testing.T) {
@@ -188,7 +185,7 @@ func TestGlobWithDoubleStar(t *testing.T) {
188185 require.NoError(t, err)
189186 require.False(t, truncated)
190187 // Even empty directories should return the directory itself
191− require.Equal(t, matches, []string{testDir})
188+ require.Equal(t, []string{testDir}, matches)
192189 })
193190
194191 t.Run("handles non-existent search path", func(t *testing.T) {
@@ -235,39 +232,38 @@ func TestGlobWithDoubleStar(t *testing.T) {
235232 matches, truncated, err = GlobWithDoubleStar("*.txt", testDir, 0)
236233 require.NoError(t, err)
237234 require.False(t, truncated)
238− require.Equal(t, matches, []string{goodFile})
235+ require.Equal(t, []string{goodFile}, matches)
239236 })
240237
241238 t.Run("handles mixed file and directory matching with sorting", func(t *testing.T) {
242− synctest.Test(t, func(t *testing.T) {
243− testDir := t.TempDir()
239+ testDir := t.TempDir()
244240
245− oldestFile := filepath.Join(testDir, "old.test")
246− require.NoError(t, os.WriteFile(oldestFile, []byte("old"), 0o644))
241+ oldestFile := filepath.Join(testDir, "old.rs")
242+ require.NoError(t, os.WriteFile(oldestFile, []byte("old"), 0o644))
247243
248− middleDir := filepath.Join(testDir, "mid.test")
249− require.NoError(t, os.MkdirAll(middleDir, 0o755))
244+ middleDir := filepath.Join(testDir, "mid.rs")
245+ require.NoError(t, os.MkdirAll(middleDir, 0o755))
250246
251− newestFile := filepath.Join(testDir, "new.test")
252− require.NoError(t, os.WriteFile(newestFile, []byte("new"), 0o644))
247+ newestFile := filepath.Join(testDir, "new.rs")
248+ require.NoError(t, os.WriteFile(newestFile, []byte("new"), 0o644))
253249
254− base := time.Now()
255− tOldest := base
256− tMiddle := base.Add(1 * time.Millisecond)
257− tNewest := base.Add(2 * time.Millisecond)
250+ base := time.Now()
251+ tOldest := base
252+ tMiddle := base.Add(10 * time.Hour)
253+ tNewest := base.Add(20 * time.Hour)
258254
259− // Reverse the expected order
260− require.NoError(t, os.Chtimes(newestFile, tOldest, tOldest))
261− require.NoError(t, os.Chtimes(middleDir, tMiddle, tMiddle))
262− require.NoError(t, os.Chtimes(oldestFile, tNewest, tNewest))
255+ // Reverse the expected order
256+ require.NoError(t, os.Chtimes(newestFile, tOldest, tOldest))
257+ require.NoError(t, os.Chtimes(middleDir, tMiddle, tMiddle))
258+ require.NoError(t, os.Chtimes(oldestFile, tNewest, tNewest))
263259
264− matches, truncated, err := GlobWithDoubleStar("*.test", testDir, 0)
265− require.NoError(t, err)
266− require.False(t, truncated)
260+ matches, truncated, err := GlobWithDoubleStar("*.rs", testDir, 0)
261+ require.NoError(t, err)
262+ require.False(t, truncated)
263+ require.Len(t, matches, 3)
267264
268− // Results should be sorted by mod time, but we set the oldestFile
269− // to have the most recent mod time
270− require.Equal(t, matches, []string{oldestFile, middleDir, newestFile})
271− })
265+ // Results should be sorted by mod time, but we set the oldestFile
266+ // to have the most recent mod time
267+ require.Equal(t, []string{oldestFile, middleDir, newestFile}, matches)
272268 })
273269}
internal/fsext/ignore_test.go+1−7
@@ -9,14 +9,8 @@ import (
99)
1010
1111func TestCrushIgnore(t *testing.T) {
12− // Create a temporary directory for testing
1312 tempDir := t.TempDir()
14−
15− // Change to temp directory
16− oldWd, _ := os.Getwd()
17− err := os.Chdir(tempDir)
18− require.NoError(t, err)
19− defer os.Chdir(oldWd)
13+ t.Chdir(tempDir)
2014
2115 // Create test files
2216 require.NoError(t, os.WriteFile("test1.txt", []byte("test"), 0o644))
internal/fsext/lookup_test.go+9−55
@@ -12,15 +12,7 @@ import (
1212
1313func TestLookupClosest(t *testing.T) {
1414 tempDir := t.TempDir()
15−
16− // Change to temp directory
17− oldWd, _ := os.Getwd()
18− err := os.Chdir(tempDir)
19− require.NoError(t, err)
20−
21− t.Cleanup(func() {
22− os.Chdir(oldWd)
23− })
15+ t.Chdir(tempDir)
2416
2517 t.Run("target found in starting directory", func(t *testing.T) {
2618 testDir := t.TempDir()
@@ -114,24 +106,15 @@ func TestLookupClosest(t *testing.T) {
114106 })
115107
116108 t.Run("relative path handling", func(t *testing.T) {
117− testDir := t.TempDir()
118−
119− // Change to test directory
120− oldWd, _ := os.Getwd()
121− err := os.Chdir(testDir)
122− require.NoError(t, err)
123− defer os.Chdir(oldWd)
124−
125109 // Create target file in current directory
126− err = os.WriteFile("target.txt", []byte("test"), 0o644)
127− require.NoError(t, err)
110+ require.NoError(t, os.WriteFile("target.txt", []byte("test"), 0o644))
128111
129112 // Search using relative path
130113 foundPath, found := LookupClosest(".", "target.txt")
131114 require.True(t, found)
132115
133116 // Resolve symlinks to handle macOS /private/var vs /var discrepancy
134− expectedPath, err := filepath.EvalSymlinks(filepath.Join(testDir, "target.txt"))
117+ expectedPath, err := filepath.EvalSymlinks(filepath.Join(tempDir, "target.txt"))
135118 require.NoError(t, err)
136119 actualPath, err := filepath.EvalSymlinks(foundPath)
137120 require.NoError(t, err)
@@ -145,15 +128,7 @@ func TestLookupClosestWithOwnership(t *testing.T) {
145128 // This test focuses on the basic functionality when ownership checks pass.
146129
147130 tempDir := t.TempDir()
148−
149− // Change to temp directory
150− oldWd, _ := os.Getwd()
151− err := os.Chdir(tempDir)
152− require.NoError(t, err)
153−
154− t.Cleanup(func() {
155− os.Chdir(oldWd)
156− })
131+ t.Chdir(tempDir)
157132
158133 t.Run("search respects same ownership", func(t *testing.T) {
159134 testDir := t.TempDir()
@@ -177,15 +152,7 @@ func TestLookupClosestWithOwnership(t *testing.T) {
177152
178153func TestLookup(t *testing.T) {
179154 tempDir := t.TempDir()
180−
181− // Change to temp directory
182− oldWd, _ := os.Getwd()
183− err := os.Chdir(tempDir)
184− require.NoError(t, err)
185−
186− t.Cleanup(func() {
187− os.Chdir(oldWd)
188− })
155+ t.Chdir(tempDir)
189156
190157 t.Run("no targets returns empty slice", func(t *testing.T) {
191158 testDir := t.TempDir()
@@ -358,22 +325,9 @@ func TestLookup(t *testing.T) {
358325 })
359326
360327 t.Run("relative path handling", func(t *testing.T) {
361− testDir := t.TempDir()
362−
363− // Change to test directory
364− oldWd, _ := os.Getwd()
365− err := os.Chdir(testDir)
366− require.NoError(t, err)
367−
368− t.Cleanup(func() {
369− os.Chdir(oldWd)
370− })
371−
372328 // Create target files in current directory
373− err = os.WriteFile("target1.txt", []byte("test1"), 0o644)
374− require.NoError(t, err)
375− err = os.WriteFile("target2.txt", []byte("test2"), 0o644)
376− require.NoError(t, err)
329+ require.NoError(t, os.WriteFile("target1.txt", []byte("test1"), 0o644))
330+ require.NoError(t, os.WriteFile("target2.txt", []byte("test2"), 0o644))
377331
378332 // Search using relative path
379333 found, err := Lookup(".", "target1.txt", "target2.txt")
@@ -381,9 +335,9 @@ func TestLookup(t *testing.T) {
381335 require.Len(t, found, 2)
382336
383337 // Resolve symlinks to handle macOS /private/var vs /var discrepancy
384− expectedPath1, err := filepath.EvalSymlinks(filepath.Join(testDir, "target1.txt"))
338+ expectedPath1, err := filepath.EvalSymlinks(filepath.Join(tempDir, "target1.txt"))
385339 require.NoError(t, err)
386− expectedPath2, err := filepath.EvalSymlinks(filepath.Join(testDir, "target2.txt"))
340+ expectedPath2, err := filepath.EvalSymlinks(filepath.Join(tempDir, "target2.txt"))
387341 require.NoError(t, err)
388342
389343 // Check that found paths match expected paths (order may vary)
internal/fsext/ls.go+20−13
@@ -1,6 +1,7 @@
11package fsext
22
33import (
4+ "errors"
45 "log/slog"
56 "os"
67 "path/filepath"
@@ -71,6 +72,11 @@ var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser {
7172
7273 // Crush
7374 ".crush",
75+
76+ // macOS stuff
77+ "OrbStack",
78+ ".local",
79+ ".share",
7480 )
7581})
7682
@@ -200,16 +206,17 @@ func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser {
200206}
201207
202208// ListDirectory lists files and directories in the specified path,
203−func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
204− results := csync.NewSlice[string]()
205− truncated := false
209+func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int) ([]string, bool, error) {
210+ found := csync.NewSlice[string]()
206211 dl := NewDirectoryLister(initialPath)
207212
213+ slog.Warn("listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns)
214+
208215 conf := fastwalk.Config{
209− Follow: true,
210− // Use forward slashes when running a Windows binary under WSL or MSYS
211− ToSlash: fastwalk.DefaultToSlash(),
212− Sort: fastwalk.SortDirsFirst,
216+ Follow: true,
217+ ToSlash: fastwalk.DefaultToSlash(),
218+ Sort: fastwalk.SortDirsFirst,
219+ MaxDepth: depth,
213220 }
214221
215222 err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
@@ -228,19 +235,19 @@ func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]st
228235 if d.IsDir() {
229236 path = path + string(filepath.Separator)
230237 }
231− results.Append(path)
238+ found.Append(path)
232239 }
233240
234− if limit > 0 && results.Len() >= limit {
235− truncated = true
241+ if limit > 0 && found.Len() >= limit {
236242 return filepath.SkipAll
237243 }
238244
239245 return nil
240246 })
241− if err != nil && results.Len() == 0 {
242− return nil, truncated, err
247+ if err != nil && !errors.Is(err, filepath.SkipAll) {
248+ return nil, false, err
243249 }
244250
245− return slices.Collect(results.Seq()), truncated, nil
251+ matches, truncated := truncate(slices.Collect(found.Seq()), limit)
252+ return matches, truncated || errors.Is(err, filepath.SkipAll), nil
246253}
internal/fsext/ls_test.go+33−40
@@ -5,26 +5,11 @@ import (
55 "path/filepath"
66 "testing"
77
8− "github.com/stretchr/testify/assert"
98 "github.com/stretchr/testify/require"
109)
1110
12−func chdir(t *testing.T, dir string) {
13− original, err := os.Getwd()
14− require.NoError(t, err)
15−
16− err = os.Chdir(dir)
17− require.NoError(t, err)
18−
19− t.Cleanup(func() {
20− err := os.Chdir(original)
21− require.NoError(t, err)
22− })
23−}
24−
2511func TestListDirectory(t *testing.T) {
26− tempDir := t.TempDir()
27− chdir(t, tempDir)
12+ tmp := t.TempDir()
2813
2914 testFiles := map[string]string{
3015 "regular.txt": "content",
@@ -35,32 +20,40 @@ func TestListDirectory(t *testing.T) {
3520 "build.log": "build output",
3621 }
3722
38− for filePath, content := range testFiles {
39− dir := filepath.Dir(filePath)
40− if dir != "." {
41− require.NoError(t, os.MkdirAll(dir, 0o755))
42− }
43−
44− err := os.WriteFile(filePath, []byte(content), 0o644)
45− require.NoError(t, err)
23+ for name, content := range testFiles {
24+ fp := filepath.Join(tmp, name)
25+ dir := filepath.Dir(fp)
26+ require.NoError(t, os.MkdirAll(dir, 0o755))
27+ require.NoError(t, os.WriteFile(fp, []byte(content), 0o644))
4628 }
4729
48− files, truncated, err := ListDirectory(".", nil, 0)
49− require.NoError(t, err)
50− assert.False(t, truncated)
51− assert.Equal(t, len(files), 4)
30+ t.Run("no limit", func(t *testing.T) {
31+ files, truncated, err := ListDirectory(tmp, nil, -1, -1)
32+ require.NoError(t, err)
33+ require.False(t, truncated)
34+ require.Len(t, files, 4)
35+ require.ElementsMatch(t, []string{
36+ "regular.txt",
37+ "subdir",
38+ "subdir/.another",
39+ "subdir/file.go",
40+ }, relPaths(t, files, tmp))
41+ })
42+ t.Run("limit", func(t *testing.T) {
43+ files, truncated, err := ListDirectory(tmp, nil, -1, 2)
44+ require.NoError(t, err)
45+ require.True(t, truncated)
46+ require.Len(t, files, 2)
47+ })
48+}
5249
53− fileSet := make(map[string]bool)
54− for _, file := range files {
55− fileSet[filepath.ToSlash(file)] = true
50+func relPaths(tb testing.TB, in []string, base string) []string {
51+ tb.Helper()
52+ out := make([]string, 0, len(in))
53+ for _, p := range in {
54+ rel, err := filepath.Rel(base, p)
55+ require.NoError(tb, err)
56+ out = append(out, filepath.ToSlash(rel))
5657 }
57−
58− assert.True(t, fileSet["./regular.txt"])
59− assert.True(t, fileSet["./subdir/"])
60− assert.True(t, fileSet["./subdir/file.go"])
61− assert.True(t, fileSet["./regular.txt"])
62−
63− assert.False(t, fileSet["./.hidden"])
64− assert.False(t, fileSet["./.gitignore"])
65− assert.False(t, fileSet["./build.log"])
58+ return out
6659}
internal/llm/prompt/coder.go+1−1
@@ -53,7 +53,7 @@ func getEnvironmentInfo() string {
5353 isGit := isGitRepo(cwd)
5454 platform := runtime.GOOS
5555 date := time.Now().Format("1/2/2006")
56− output, _ := tools.ListDirectoryTree(cwd, nil)
56+ output, _, _ := tools.ListDirectoryTree(cwd, tools.LSParams{})
5757 return fmt.Sprintf(`Here is useful information about the environment you are running in:
5858<env>
5959Working directory: %s
internal/llm/tools/ls.go+34−27
@@ -1,6 +1,7 @@
11package tools
22
33import (
4+ "cmp"
45 "context"
56 _ "embed"
67 "encoding/json"
@@ -9,6 +10,7 @@ import (
910 "path/filepath"
1011 "strings"
1112
13+ "github.com/charmbracelet/crush/internal/config"
1214 "github.com/charmbracelet/crush/internal/fsext"
1315 "github.com/charmbracelet/crush/internal/permission"
1416)
@@ -16,11 +18,13 @@ import (
1618type LSParams struct {
1719 Path string `json:"path"`
1820 Ignore []string `json:"ignore"`
21+ Depth int `json:"depth"`
1922}
2023
2124type LSPermissionsParams struct {
2225 Path string `json:"path"`
2326 Ignore []string `json:"ignore"`
27+ Depth int `json:"depth"`
2428}
2529
2630type TreeNode struct {
@@ -42,7 +46,7 @@ type lsTool struct {
4246
4347const (
4448 LSToolName = "ls"
45− MaxLSFiles = 1000
49+ maxLSFiles = 1000
4650)
4751
4852//go:embed ls.md
@@ -68,6 +72,10 @@ func (l *lsTool) Info() ToolInfo {
6872 "type": "string",
6973 "description": "The path to the directory to list (defaults to current working directory)",
7074 },
75+ "depth": map[string]any{
76+ "type": "integer",
77+ "description": "The maximum depth to traverse",
78+ },
7179 "ignore": map[string]any{
7280 "type": "array",
7381 "description": "List of glob patterns to ignore",
@@ -86,13 +94,7 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
8694 return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
8795 }
8896
89− searchPath := params.Path
90− if searchPath == "" {
91− searchPath = l.workingDir
92− }
93−
94− var err error
95− searchPath, err = fsext.Expand(searchPath)
97+ searchPath, err := fsext.Expand(cmp.Or(params.Path, l.workingDir))
9698 if err != nil {
9799 return ToolResponse{}, fmt.Errorf("error expanding path: %w", err)
98100 }
@@ -137,44 +139,49 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
137139 }
138140 }
139141
140− output, err := ListDirectoryTree(searchPath, params.Ignore)
142+ output, metadata, err := ListDirectoryTree(searchPath, params)
141143 if err != nil {
142144 return ToolResponse{}, err
143145 }
144146
145− // Get file count for metadata
146− files, truncated, err := fsext.ListDirectory(searchPath, params.Ignore, MaxLSFiles)
147− if err != nil {
148− return ToolResponse{}, fmt.Errorf("error listing directory for metadata: %w", err)
149− }
150−
151147 return WithResponseMetadata(
152148 NewTextResponse(output),
153− LSResponseMetadata{
154− NumberOfFiles: len(files),
155− Truncated: truncated,
156− },
149+ metadata,
157150 ), nil
158151}
159152
160−func ListDirectoryTree(searchPath string, ignore []string) (string, error) {
153+func ListDirectoryTree(searchPath string, params LSParams) (string, LSResponseMetadata, error) {
161154 if _, err := os.Stat(searchPath); os.IsNotExist(err) {
162− return "", fmt.Errorf("path does not exist: %s", searchPath)
155+ return "", LSResponseMetadata{}, fmt.Errorf("path does not exist: %s", searchPath)
163156 }
164157
165− files, truncated, err := fsext.ListDirectory(searchPath, ignore, MaxLSFiles)
158+ ls := config.Get().Tools.Ls
159+ depth, limit := ls.Limits()
160+ maxFiles := min(limit, maxLSFiles)
161+ files, truncated, err := fsext.ListDirectory(
162+ searchPath,
163+ params.Ignore,
164+ cmp.Or(params.Depth, depth),
165+ maxFiles,
166+ )
166167 if err != nil {
167− return "", fmt.Errorf("error listing directory: %w", err)
168+ return "", LSResponseMetadata{}, fmt.Errorf("error listing directory: %w", err)
168169 }
169170
171+ metadata := LSResponseMetadata{
172+ NumberOfFiles: len(files),
173+ Truncated: truncated,
174+ }
170175 tree := createFileTree(files, searchPath)
171− output := printTree(tree, searchPath)
172176
177+ var output string
173178 if truncated {
174− output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %d files and directories are included below:\n\n%s", MaxLSFiles, MaxLSFiles, output)
179+ output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %[1]d files and directories are included below.\n", maxFiles)
175180 }
176−
177− return output, nil
181+ if depth > 0 {
182+ output = fmt.Sprintf("The directory tree is shown up to a depth of %d. Use a higher depth and a specific path to see more levels.\n", cmp.Or(params.Depth, depth))
183+ }
184+ return output + "\n" + printTree(tree, searchPath), metadata, nil
178185}
179186
180187func createFileTree(sortedPaths []string, rootPath string) []*TreeNode {
internal/tui/components/chat/editor/editor.go+3−1
@@ -480,7 +480,9 @@ func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
480480}
481481
482482func (m *editorCmp) startCompletions() tea.Msg {
483− files, _, _ := fsext.ListDirectory(".", nil, 0)
483+ ls := m.app.Config().Options.TUI.Completions
484+ depth, limit := ls.Limits()
485+ files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
484486 slices.Sort(files)
485487 completionItems := make([]completions.Completion, 0, len(files))
486488 for _, file := range files {
schema.json+71−1
@@ -19,6 +19,28 @@
1919 "additionalProperties": false,
2020 "type": "object"
2121 },
22+ "Completions": {
23+ "properties": {
24+ "max_depth": {
25+ "type": "integer",
26+ "description": "Maximum depth for the ls tool",
27+ "default": 0,
28+ "examples": [
29+ 10
30+ ]
31+ },
32+ "max_items": {
33+ "type": "integer",
34+ "description": "Maximum number of items to return for the ls tool",
35+ "default": 1000,
36+ "examples": [
37+ 100
38+ ]
39+ }
40+ },
41+ "additionalProperties": false,
42+ "type": "object"
43+ },
2244 "Config": {
2345 "properties": {
2446 "$schema": {
@@ -53,10 +75,17 @@
5375 "permissions": {
5476 "$ref": "#/$defs/Permissions",
5577 "description": "Permission settings for tool usage"
78+ },
79+ "tools": {
80+ "$ref": "#/$defs/Tools",
81+ "description": "Tool configurations"
5682 }
5783 },
5884 "additionalProperties": false,
59− "type": "object"
85+ "type": "object",
86+ "required": [
87+ "tools"
88+ ]
6089 },
6190 "LSPConfig": {
6291 "properties": {
@@ -484,10 +513,51 @@
484513 "split"
485514 ],
486515 "description": "Diff mode for the TUI interface"
516+ },
517+ "completions": {
518+ "$ref": "#/$defs/Completions",
519+ "description": "Completions UI options"
520+ }
521+ },
522+ "additionalProperties": false,
523+ "type": "object",
524+ "required": [
525+ "completions"
526+ ]
527+ },
528+ "ToolLs": {
529+ "properties": {
530+ "max_depth": {
531+ "type": "integer",
532+ "description": "Maximum depth for the ls tool",
533+ "default": 0,
534+ "examples": [
535+ 10
536+ ]
537+ },
538+ "max_items": {
539+ "type": "integer",
540+ "description": "Maximum number of items to return for the ls tool",
541+ "default": 1000,
542+ "examples": [
543+ 100
544+ ]
487545 }
488546 },
489547 "additionalProperties": false,
490548 "type": "object"
549+ },
550+ "Tools": {
551+ "properties": {
552+ "ls": {
553+ "$ref": "#/$defs/ToolLs"
554+ }
555+ },
556+ "additionalProperties": false,
557+ "type": "object",
558+ "required": [
559+ "ls"
560+ ]
491561 }
492562 }
493563}