feat: limit filepath walk, automatic low limits when not git repo (#1052)

Carlos Alexandro Becker created

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

Change summary

internal/config/config.go                     | 35 ++++++++
internal/config/load.go                       | 27 ++++++
internal/fsext/fileutil.go                    | 65 ++++----------
internal/fsext/fileutil_test.go               | 90 ++++++++++----------
internal/fsext/ignore_test.go                 |  8 -
internal/fsext/lookup_test.go                 | 64 ++------------
internal/fsext/ls.go                          | 33 ++++---
internal/fsext/ls_test.go                     | 73 +++++++---------
internal/llm/prompt/coder.go                  |  2 
internal/llm/tools/ls.go                      | 61 +++++++------
internal/tui/components/chat/editor/editor.go |  4 
schema.json                                   | 72 ++++++++++++++++
12 files changed, 296 insertions(+), 238 deletions(-)

Detailed changes

internal/config/config.go 🔗

@@ -131,6 +131,19 @@ type TUIOptions struct {
 	CompactMode bool   `json:"compact_mode,omitempty" jsonschema:"description=Enable compact mode for the TUI interface,default=false"`
 	DiffMode    string `json:"diff_mode,omitempty" jsonschema:"description=Diff mode for the TUI interface,enum=unified,enum=split"`
 	// Here we can add themes later or any TUI related options
+	//
+
+	Completions Completions `json:"completions,omitzero" jsonschema:"description=Completions UI options"`
+}
+
+// Completions defines options for the completions UI.
+type Completions struct {
+	MaxDepth *int `json:"max_depth,omitempty" jsonschema:"description=Maximum depth for the ls tool,default=0,example=10"`
+	MaxItems *int `json:"max_items,omitempty" jsonschema:"description=Maximum number of items to return for the ls tool,default=1000,example=100"`
+}
+
+func (c Completions) Limits() (depth, items int) {
+	return ptrValOr(c.MaxDepth, -1), ptrValOr(c.MaxItems, -1)
 }
 
 type Permissions struct {
@@ -246,6 +259,19 @@ type Agent struct {
 	ContextPaths []string `json:"context_paths,omitempty"`
 }
 
+type Tools struct {
+	Ls ToolLs `json:"ls,omitzero"`
+}
+
+type ToolLs struct {
+	MaxDepth *int `json:"max_depth,omitempty" jsonschema:"description=Maximum depth for the ls tool,default=0,example=10"`
+	MaxItems *int `json:"max_items,omitempty" jsonschema:"description=Maximum number of items to return for the ls tool,default=1000,example=100"`
+}
+
+func (t ToolLs) Limits() (depth, items int) {
+	return ptrValOr(t.MaxDepth, -1), ptrValOr(t.MaxItems, -1)
+}
+
 // Config holds the configuration for crush.
 type Config struct {
 	Schema string `json:"$schema,omitempty"`
@@ -264,6 +290,8 @@ type Config struct {
 
 	Permissions *Permissions `json:"permissions,omitempty" jsonschema:"description=Permission settings for tool usage"`
 
+	Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"`
+
 	// Internal
 	workingDir string `json:"-"`
 	// TODO: most likely remove this concept when I come back to it
@@ -579,3 +607,10 @@ func resolveEnvs(envs map[string]string) []string {
 	}
 	return res
 }
+
+func ptrValOr[T any](t *T, el T) T {
+	if t == nil {
+		return el
+	}
+	return *t
+}

internal/config/load.go 🔗

@@ -1,12 +1,14 @@
 package config
 
 import (
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
 	"log/slog"
 	"maps"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"runtime"
 	"slices"
@@ -62,6 +64,16 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) {
 		cfg.Options.Debug,
 	)
 
+	if !isInsideWorktree() {
+		const depth = 2
+		const items = 100
+		slog.Warn("No git repository detected in working directory, will limit file walk operations", "depth", depth, "items", items)
+		assignIfNil(&cfg.Tools.Ls.MaxDepth, depth)
+		assignIfNil(&cfg.Tools.Ls.MaxItems, items)
+		assignIfNil(&cfg.Options.TUI.Completions.MaxDepth, depth)
+		assignIfNil(&cfg.Options.TUI.Completions.MaxItems, items)
+	}
+
 	// Load known providers, this loads the config from catwalk
 	providers, err := Providers(cfg)
 	if err != nil {
@@ -638,3 +650,18 @@ func GlobalConfigData() string {
 
 	return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
 }
+
+func assignIfNil[T any](ptr **T, val T) {
+	if *ptr == nil {
+		*ptr = &val
+	}
+}
+
+func isInsideWorktree() bool {
+	bts, err := exec.CommandContext(
+		context.Background(),
+		"git", "rev-parse",
+		"--is-inside-work-tree",
+	).CombinedOutput()
+	return err == nil && strings.TrimSpace(string(bts)) == "true"
+}

internal/fsext/fileutil.go 🔗

@@ -1,15 +1,17 @@
 package fsext
 
 import (
+	"errors"
 	"fmt"
 	"os"
 	"path/filepath"
-	"sort"
+	"slices"
 	"strings"
 	"time"
 
 	"github.com/bmatcuk/doublestar/v4"
 	"github.com/charlievieth/fastwalk"
+	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/home"
 )
 
@@ -80,10 +82,9 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool,
 	pattern = filepath.ToSlash(pattern)
 
 	walker := NewFastGlobWalker(searchPath)
-	var matches []FileInfo
+	found := csync.NewSlice[FileInfo]()
 	conf := fastwalk.Config{
-		Follow: true,
-		// Use forward slashes when running a Windows binary under WSL or MSYS
+		Follow:  true,
 		ToSlash: fastwalk.DefaultToSlash(),
 		Sort:    fastwalk.SortFilesFirst,
 	}
@@ -121,31 +122,26 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool,
 			return nil
 		}
 
-		matches = append(matches, FileInfo{Path: path, ModTime: info.ModTime()})
-		if limit > 0 && len(matches) >= limit*2 {
+		found.Append(FileInfo{Path: path, ModTime: info.ModTime()})
+		if limit > 0 && found.Len() >= limit*2 { // NOTE: why x2?
 			return filepath.SkipAll
 		}
 		return nil
 	})
-	if err != nil {
+	if err != nil && !errors.Is(err, filepath.SkipAll) {
 		return nil, false, fmt.Errorf("fastwalk error: %w", err)
 	}
 
-	sort.Slice(matches, func(i, j int) bool {
-		return matches[i].ModTime.After(matches[j].ModTime)
+	matches := slices.SortedFunc(found.Seq(), func(a, b FileInfo) int {
+		return b.ModTime.Compare(a.ModTime)
 	})
-
-	truncated := false
-	if limit > 0 && len(matches) > limit {
-		matches = matches[:limit]
-		truncated = true
-	}
+	matches, truncated := truncate(matches, limit)
 
 	results := make([]string, len(matches))
 	for i, m := range matches {
 		results[i] = m.Path
 	}
-	return results, truncated, nil
+	return results, truncated || errors.Is(err, filepath.SkipAll), nil
 }
 
 // ShouldExcludeFile checks if a file should be excluded from processing
@@ -155,36 +151,6 @@ func ShouldExcludeFile(rootPath, filePath string) bool {
 		shouldIgnore(filePath, nil)
 }
 
-// WalkDirectories walks a directory tree and calls the provided function for each directory,
-// respecting hierarchical .gitignore/.crushignore files like git does.
-func WalkDirectories(rootPath string, fn func(path string, d os.DirEntry, err error) error) error {
-	dl := NewDirectoryLister(rootPath)
-
-	conf := fastwalk.Config{
-		Follow:  true,
-		ToSlash: fastwalk.DefaultToSlash(),
-		Sort:    fastwalk.SortDirsFirst,
-	}
-
-	return fastwalk.Walk(&conf, rootPath, func(path string, d os.DirEntry, err error) error {
-		if err != nil {
-			return fn(path, d, err)
-		}
-
-		// Only process directories
-		if !d.IsDir() {
-			return nil
-		}
-
-		// Check if directory should be ignored
-		if dl.shouldIgnore(path, nil) {
-			return filepath.SkipDir
-		}
-
-		return fn(path, d, err)
-	})
-}
-
 func PrettyPath(path string) string {
 	return home.Short(path)
 }
@@ -248,3 +214,10 @@ func ToWindowsLineEndings(content string) (string, bool) {
 	}
 	return content, false
 }
+
+func truncate[T any](input []T, limit int) ([]T, bool) {
+	if limit > 0 && len(input) > limit {
+		return input[:limit], true
+	}
+	return input, false
+}

internal/fsext/fileutil_test.go 🔗

@@ -5,7 +5,6 @@ import (
 	"os"
 	"path/filepath"
 	"testing"
-	"testing/synctest"
 	"time"
 
 	"github.com/stretchr/testify/require"
@@ -148,37 +147,35 @@ func TestGlobWithDoubleStar(t *testing.T) {
 		require.NoError(t, err)
 		require.False(t, truncated)
 
-		require.Equal(t, matches, []string{file1})
+		require.Equal(t, []string{file1}, matches)
 	})
 
 	t.Run("returns results sorted by modification time (newest first)", func(t *testing.T) {
-		synctest.Test(t, func(t *testing.T) {
-			testDir := t.TempDir()
+		testDir := t.TempDir()
 
-			file1 := filepath.Join(testDir, "file1.txt")
-			require.NoError(t, os.WriteFile(file1, []byte("first"), 0o644))
+		file1 := filepath.Join(testDir, "file1.txt")
+		require.NoError(t, os.WriteFile(file1, []byte("first"), 0o644))
 
-			file2 := filepath.Join(testDir, "file2.txt")
-			require.NoError(t, os.WriteFile(file2, []byte("second"), 0o644))
+		file2 := filepath.Join(testDir, "file2.txt")
+		require.NoError(t, os.WriteFile(file2, []byte("second"), 0o644))
 
-			file3 := filepath.Join(testDir, "file3.txt")
-			require.NoError(t, os.WriteFile(file3, []byte("third"), 0o644))
+		file3 := filepath.Join(testDir, "file3.txt")
+		require.NoError(t, os.WriteFile(file3, []byte("third"), 0o644))
 
-			base := time.Now()
-			m1 := base
-			m2 := base.Add(1 * time.Millisecond)
-			m3 := base.Add(2 * time.Millisecond)
+		base := time.Now()
+		m1 := base
+		m2 := base.Add(10 * time.Hour)
+		m3 := base.Add(20 * time.Hour)
 
-			require.NoError(t, os.Chtimes(file1, m1, m1))
-			require.NoError(t, os.Chtimes(file2, m2, m2))
-			require.NoError(t, os.Chtimes(file3, m3, m3))
+		require.NoError(t, os.Chtimes(file1, m1, m1))
+		require.NoError(t, os.Chtimes(file2, m2, m2))
+		require.NoError(t, os.Chtimes(file3, m3, m3))
 
-			matches, truncated, err := GlobWithDoubleStar("*.txt", testDir, 0)
-			require.NoError(t, err)
-			require.False(t, truncated)
+		matches, truncated, err := GlobWithDoubleStar("*.txt", testDir, 0)
+		require.NoError(t, err)
+		require.False(t, truncated)
 
-			require.Equal(t, matches, []string{file3, file2, file1})
-		})
+		require.Equal(t, []string{file3, file2, file1}, matches)
 	})
 
 	t.Run("handles empty directory", func(t *testing.T) {
@@ -188,7 +185,7 @@ func TestGlobWithDoubleStar(t *testing.T) {
 		require.NoError(t, err)
 		require.False(t, truncated)
 		// Even empty directories should return the directory itself
-		require.Equal(t, matches, []string{testDir})
+		require.Equal(t, []string{testDir}, matches)
 	})
 
 	t.Run("handles non-existent search path", func(t *testing.T) {
@@ -235,39 +232,38 @@ func TestGlobWithDoubleStar(t *testing.T) {
 		matches, truncated, err = GlobWithDoubleStar("*.txt", testDir, 0)
 		require.NoError(t, err)
 		require.False(t, truncated)
-		require.Equal(t, matches, []string{goodFile})
+		require.Equal(t, []string{goodFile}, matches)
 	})
 
 	t.Run("handles mixed file and directory matching with sorting", func(t *testing.T) {
-		synctest.Test(t, func(t *testing.T) {
-			testDir := t.TempDir()
+		testDir := t.TempDir()
 
-			oldestFile := filepath.Join(testDir, "old.test")
-			require.NoError(t, os.WriteFile(oldestFile, []byte("old"), 0o644))
+		oldestFile := filepath.Join(testDir, "old.rs")
+		require.NoError(t, os.WriteFile(oldestFile, []byte("old"), 0o644))
 
-			middleDir := filepath.Join(testDir, "mid.test")
-			require.NoError(t, os.MkdirAll(middleDir, 0o755))
+		middleDir := filepath.Join(testDir, "mid.rs")
+		require.NoError(t, os.MkdirAll(middleDir, 0o755))
 
-			newestFile := filepath.Join(testDir, "new.test")
-			require.NoError(t, os.WriteFile(newestFile, []byte("new"), 0o644))
+		newestFile := filepath.Join(testDir, "new.rs")
+		require.NoError(t, os.WriteFile(newestFile, []byte("new"), 0o644))
 
-			base := time.Now()
-			tOldest := base
-			tMiddle := base.Add(1 * time.Millisecond)
-			tNewest := base.Add(2 * time.Millisecond)
+		base := time.Now()
+		tOldest := base
+		tMiddle := base.Add(10 * time.Hour)
+		tNewest := base.Add(20 * time.Hour)
 
-			// Reverse the expected order
-			require.NoError(t, os.Chtimes(newestFile, tOldest, tOldest))
-			require.NoError(t, os.Chtimes(middleDir, tMiddle, tMiddle))
-			require.NoError(t, os.Chtimes(oldestFile, tNewest, tNewest))
+		// Reverse the expected order
+		require.NoError(t, os.Chtimes(newestFile, tOldest, tOldest))
+		require.NoError(t, os.Chtimes(middleDir, tMiddle, tMiddle))
+		require.NoError(t, os.Chtimes(oldestFile, tNewest, tNewest))
 
-			matches, truncated, err := GlobWithDoubleStar("*.test", testDir, 0)
-			require.NoError(t, err)
-			require.False(t, truncated)
+		matches, truncated, err := GlobWithDoubleStar("*.rs", testDir, 0)
+		require.NoError(t, err)
+		require.False(t, truncated)
+		require.Len(t, matches, 3)
 
-			// Results should be sorted by mod time, but we set the oldestFile
-			// to have the most recent mod time
-			require.Equal(t, matches, []string{oldestFile, middleDir, newestFile})
-		})
+		// Results should be sorted by mod time, but we set the oldestFile
+		// to have the most recent mod time
+		require.Equal(t, []string{oldestFile, middleDir, newestFile}, matches)
 	})
 }

internal/fsext/ignore_test.go 🔗

@@ -9,14 +9,8 @@ import (
 )
 
 func TestCrushIgnore(t *testing.T) {
-	// Create a temporary directory for testing
 	tempDir := t.TempDir()
-
-	// Change to temp directory
-	oldWd, _ := os.Getwd()
-	err := os.Chdir(tempDir)
-	require.NoError(t, err)
-	defer os.Chdir(oldWd)
+	t.Chdir(tempDir)
 
 	// Create test files
 	require.NoError(t, os.WriteFile("test1.txt", []byte("test"), 0o644))

internal/fsext/lookup_test.go 🔗

@@ -12,15 +12,7 @@ import (
 
 func TestLookupClosest(t *testing.T) {
 	tempDir := t.TempDir()
-
-	// Change to temp directory
-	oldWd, _ := os.Getwd()
-	err := os.Chdir(tempDir)
-	require.NoError(t, err)
-
-	t.Cleanup(func() {
-		os.Chdir(oldWd)
-	})
+	t.Chdir(tempDir)
 
 	t.Run("target found in starting directory", func(t *testing.T) {
 		testDir := t.TempDir()
@@ -114,24 +106,15 @@ func TestLookupClosest(t *testing.T) {
 	})
 
 	t.Run("relative path handling", func(t *testing.T) {
-		testDir := t.TempDir()
-
-		// Change to test directory
-		oldWd, _ := os.Getwd()
-		err := os.Chdir(testDir)
-		require.NoError(t, err)
-		defer os.Chdir(oldWd)
-
 		// Create target file in current directory
-		err = os.WriteFile("target.txt", []byte("test"), 0o644)
-		require.NoError(t, err)
+		require.NoError(t, os.WriteFile("target.txt", []byte("test"), 0o644))
 
 		// Search using relative path
 		foundPath, found := LookupClosest(".", "target.txt")
 		require.True(t, found)
 
 		// Resolve symlinks to handle macOS /private/var vs /var discrepancy
-		expectedPath, err := filepath.EvalSymlinks(filepath.Join(testDir, "target.txt"))
+		expectedPath, err := filepath.EvalSymlinks(filepath.Join(tempDir, "target.txt"))
 		require.NoError(t, err)
 		actualPath, err := filepath.EvalSymlinks(foundPath)
 		require.NoError(t, err)
@@ -145,15 +128,7 @@ func TestLookupClosestWithOwnership(t *testing.T) {
 	// This test focuses on the basic functionality when ownership checks pass.
 
 	tempDir := t.TempDir()
-
-	// Change to temp directory
-	oldWd, _ := os.Getwd()
-	err := os.Chdir(tempDir)
-	require.NoError(t, err)
-
-	t.Cleanup(func() {
-		os.Chdir(oldWd)
-	})
+	t.Chdir(tempDir)
 
 	t.Run("search respects same ownership", func(t *testing.T) {
 		testDir := t.TempDir()
@@ -177,15 +152,7 @@ func TestLookupClosestWithOwnership(t *testing.T) {
 
 func TestLookup(t *testing.T) {
 	tempDir := t.TempDir()
-
-	// Change to temp directory
-	oldWd, _ := os.Getwd()
-	err := os.Chdir(tempDir)
-	require.NoError(t, err)
-
-	t.Cleanup(func() {
-		os.Chdir(oldWd)
-	})
+	t.Chdir(tempDir)
 
 	t.Run("no targets returns empty slice", func(t *testing.T) {
 		testDir := t.TempDir()
@@ -358,22 +325,9 @@ func TestLookup(t *testing.T) {
 	})
 
 	t.Run("relative path handling", func(t *testing.T) {
-		testDir := t.TempDir()
-
-		// Change to test directory
-		oldWd, _ := os.Getwd()
-		err := os.Chdir(testDir)
-		require.NoError(t, err)
-
-		t.Cleanup(func() {
-			os.Chdir(oldWd)
-		})
-
 		// Create target files in current directory
-		err = os.WriteFile("target1.txt", []byte("test1"), 0o644)
-		require.NoError(t, err)
-		err = os.WriteFile("target2.txt", []byte("test2"), 0o644)
-		require.NoError(t, err)
+		require.NoError(t, os.WriteFile("target1.txt", []byte("test1"), 0o644))
+		require.NoError(t, os.WriteFile("target2.txt", []byte("test2"), 0o644))
 
 		// Search using relative path
 		found, err := Lookup(".", "target1.txt", "target2.txt")
@@ -381,9 +335,9 @@ func TestLookup(t *testing.T) {
 		require.Len(t, found, 2)
 
 		// Resolve symlinks to handle macOS /private/var vs /var discrepancy
-		expectedPath1, err := filepath.EvalSymlinks(filepath.Join(testDir, "target1.txt"))
+		expectedPath1, err := filepath.EvalSymlinks(filepath.Join(tempDir, "target1.txt"))
 		require.NoError(t, err)
-		expectedPath2, err := filepath.EvalSymlinks(filepath.Join(testDir, "target2.txt"))
+		expectedPath2, err := filepath.EvalSymlinks(filepath.Join(tempDir, "target2.txt"))
 		require.NoError(t, err)
 
 		// Check that found paths match expected paths (order may vary)

internal/fsext/ls.go 🔗

@@ -1,6 +1,7 @@
 package fsext
 
 import (
+	"errors"
 	"log/slog"
 	"os"
 	"path/filepath"
@@ -71,6 +72,11 @@ var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser {
 
 		// Crush
 		".crush",
+
+		// macOS stuff
+		"OrbStack",
+		".local",
+		".share",
 	)
 })
 
@@ -200,16 +206,17 @@ func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser {
 }
 
 // ListDirectory lists files and directories in the specified path,
-func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
-	results := csync.NewSlice[string]()
-	truncated := false
+func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int) ([]string, bool, error) {
+	found := csync.NewSlice[string]()
 	dl := NewDirectoryLister(initialPath)
 
+	slog.Warn("listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns)
+
 	conf := fastwalk.Config{
-		Follow: true,
-		// Use forward slashes when running a Windows binary under WSL or MSYS
-		ToSlash: fastwalk.DefaultToSlash(),
-		Sort:    fastwalk.SortDirsFirst,
+		Follow:   true,
+		ToSlash:  fastwalk.DefaultToSlash(),
+		Sort:     fastwalk.SortDirsFirst,
+		MaxDepth: depth,
 	}
 
 	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
 			if d.IsDir() {
 				path = path + string(filepath.Separator)
 			}
-			results.Append(path)
+			found.Append(path)
 		}
 
-		if limit > 0 && results.Len() >= limit {
-			truncated = true
+		if limit > 0 && found.Len() >= limit {
 			return filepath.SkipAll
 		}
 
 		return nil
 	})
-	if err != nil && results.Len() == 0 {
-		return nil, truncated, err
+	if err != nil && !errors.Is(err, filepath.SkipAll) {
+		return nil, false, err
 	}
 
-	return slices.Collect(results.Seq()), truncated, nil
+	matches, truncated := truncate(slices.Collect(found.Seq()), limit)
+	return matches, truncated || errors.Is(err, filepath.SkipAll), nil
 }

internal/fsext/ls_test.go 🔗

@@ -5,26 +5,11 @@ import (
 	"path/filepath"
 	"testing"
 
-	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
-func chdir(t *testing.T, dir string) {
-	original, err := os.Getwd()
-	require.NoError(t, err)
-
-	err = os.Chdir(dir)
-	require.NoError(t, err)
-
-	t.Cleanup(func() {
-		err := os.Chdir(original)
-		require.NoError(t, err)
-	})
-}
-
 func TestListDirectory(t *testing.T) {
-	tempDir := t.TempDir()
-	chdir(t, tempDir)
+	tmp := t.TempDir()
 
 	testFiles := map[string]string{
 		"regular.txt":     "content",
@@ -35,32 +20,40 @@ func TestListDirectory(t *testing.T) {
 		"build.log":       "build output",
 	}
 
-	for filePath, content := range testFiles {
-		dir := filepath.Dir(filePath)
-		if dir != "." {
-			require.NoError(t, os.MkdirAll(dir, 0o755))
-		}
-
-		err := os.WriteFile(filePath, []byte(content), 0o644)
-		require.NoError(t, err)
+	for name, content := range testFiles {
+		fp := filepath.Join(tmp, name)
+		dir := filepath.Dir(fp)
+		require.NoError(t, os.MkdirAll(dir, 0o755))
+		require.NoError(t, os.WriteFile(fp, []byte(content), 0o644))
 	}
 
-	files, truncated, err := ListDirectory(".", nil, 0)
-	require.NoError(t, err)
-	assert.False(t, truncated)
-	assert.Equal(t, len(files), 4)
+	t.Run("no limit", func(t *testing.T) {
+		files, truncated, err := ListDirectory(tmp, nil, -1, -1)
+		require.NoError(t, err)
+		require.False(t, truncated)
+		require.Len(t, files, 4)
+		require.ElementsMatch(t, []string{
+			"regular.txt",
+			"subdir",
+			"subdir/.another",
+			"subdir/file.go",
+		}, relPaths(t, files, tmp))
+	})
+	t.Run("limit", func(t *testing.T) {
+		files, truncated, err := ListDirectory(tmp, nil, -1, 2)
+		require.NoError(t, err)
+		require.True(t, truncated)
+		require.Len(t, files, 2)
+	})
+}
 
-	fileSet := make(map[string]bool)
-	for _, file := range files {
-		fileSet[filepath.ToSlash(file)] = true
+func relPaths(tb testing.TB, in []string, base string) []string {
+	tb.Helper()
+	out := make([]string, 0, len(in))
+	for _, p := range in {
+		rel, err := filepath.Rel(base, p)
+		require.NoError(tb, err)
+		out = append(out, filepath.ToSlash(rel))
 	}
-
-	assert.True(t, fileSet["./regular.txt"])
-	assert.True(t, fileSet["./subdir/"])
-	assert.True(t, fileSet["./subdir/file.go"])
-	assert.True(t, fileSet["./regular.txt"])
-
-	assert.False(t, fileSet["./.hidden"])
-	assert.False(t, fileSet["./.gitignore"])
-	assert.False(t, fileSet["./build.log"])
+	return out
 }

internal/llm/prompt/coder.go 🔗

@@ -53,7 +53,7 @@ func getEnvironmentInfo() string {
 	isGit := isGitRepo(cwd)
 	platform := runtime.GOOS
 	date := time.Now().Format("1/2/2006")
-	output, _ := tools.ListDirectoryTree(cwd, nil)
+	output, _, _ := tools.ListDirectoryTree(cwd, tools.LSParams{})
 	return fmt.Sprintf(`Here is useful information about the environment you are running in:
 <env>
 Working directory: %s

internal/llm/tools/ls.go 🔗

@@ -1,6 +1,7 @@
 package tools
 
 import (
+	"cmp"
 	"context"
 	_ "embed"
 	"encoding/json"
@@ -9,6 +10,7 @@ import (
 	"path/filepath"
 	"strings"
 
+	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/permission"
 )
@@ -16,11 +18,13 @@ import (
 type LSParams struct {
 	Path   string   `json:"path"`
 	Ignore []string `json:"ignore"`
+	Depth  int      `json:"depth"`
 }
 
 type LSPermissionsParams struct {
 	Path   string   `json:"path"`
 	Ignore []string `json:"ignore"`
+	Depth  int      `json:"depth"`
 }
 
 type TreeNode struct {
@@ -42,7 +46,7 @@ type lsTool struct {
 
 const (
 	LSToolName = "ls"
-	MaxLSFiles = 1000
+	maxLSFiles = 1000
 )
 
 //go:embed ls.md
@@ -68,6 +72,10 @@ func (l *lsTool) Info() ToolInfo {
 				"type":        "string",
 				"description": "The path to the directory to list (defaults to current working directory)",
 			},
+			"depth": map[string]any{
+				"type":        "integer",
+				"description": "The maximum depth to traverse",
+			},
 			"ignore": map[string]any{
 				"type":        "array",
 				"description": "List of glob patterns to ignore",
@@ -86,13 +94,7 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
 		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
 	}
 
-	searchPath := params.Path
-	if searchPath == "" {
-		searchPath = l.workingDir
-	}
-
-	var err error
-	searchPath, err = fsext.Expand(searchPath)
+	searchPath, err := fsext.Expand(cmp.Or(params.Path, l.workingDir))
 	if err != nil {
 		return ToolResponse{}, fmt.Errorf("error expanding path: %w", err)
 	}
@@ -137,44 +139,49 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
 		}
 	}
 
-	output, err := ListDirectoryTree(searchPath, params.Ignore)
+	output, metadata, err := ListDirectoryTree(searchPath, params)
 	if err != nil {
 		return ToolResponse{}, err
 	}
 
-	// Get file count for metadata
-	files, truncated, err := fsext.ListDirectory(searchPath, params.Ignore, MaxLSFiles)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("error listing directory for metadata: %w", err)
-	}
-
 	return WithResponseMetadata(
 		NewTextResponse(output),
-		LSResponseMetadata{
-			NumberOfFiles: len(files),
-			Truncated:     truncated,
-		},
+		metadata,
 	), nil
 }
 
-func ListDirectoryTree(searchPath string, ignore []string) (string, error) {
+func ListDirectoryTree(searchPath string, params LSParams) (string, LSResponseMetadata, error) {
 	if _, err := os.Stat(searchPath); os.IsNotExist(err) {
-		return "", fmt.Errorf("path does not exist: %s", searchPath)
+		return "", LSResponseMetadata{}, fmt.Errorf("path does not exist: %s", searchPath)
 	}
 
-	files, truncated, err := fsext.ListDirectory(searchPath, ignore, MaxLSFiles)
+	ls := config.Get().Tools.Ls
+	depth, limit := ls.Limits()
+	maxFiles := min(limit, maxLSFiles)
+	files, truncated, err := fsext.ListDirectory(
+		searchPath,
+		params.Ignore,
+		cmp.Or(params.Depth, depth),
+		maxFiles,
+	)
 	if err != nil {
-		return "", fmt.Errorf("error listing directory: %w", err)
+		return "", LSResponseMetadata{}, fmt.Errorf("error listing directory: %w", err)
 	}
 
+	metadata := LSResponseMetadata{
+		NumberOfFiles: len(files),
+		Truncated:     truncated,
+	}
 	tree := createFileTree(files, searchPath)
-	output := printTree(tree, searchPath)
 
+	var output string
 	if truncated {
-		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)
+		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)
 	}
-
-	return output, nil
+	if depth > 0 {
+		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))
+	}
+	return output + "\n" + printTree(tree, searchPath), metadata, nil
 }
 
 func createFileTree(sortedPaths []string, rootPath string) []*TreeNode {

internal/tui/components/chat/editor/editor.go 🔗

@@ -480,7 +480,9 @@ func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
 }
 
 func (m *editorCmp) startCompletions() tea.Msg {
-	files, _, _ := fsext.ListDirectory(".", nil, 0)
+	ls := m.app.Config().Options.TUI.Completions
+	depth, limit := ls.Limits()
+	files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
 	slices.Sort(files)
 	completionItems := make([]completions.Completion, 0, len(files))
 	for _, file := range files {

schema.json 🔗

@@ -19,6 +19,28 @@
       "additionalProperties": false,
       "type": "object"
     },
+    "Completions": {
+      "properties": {
+        "max_depth": {
+          "type": "integer",
+          "description": "Maximum depth for the ls tool",
+          "default": 0,
+          "examples": [
+            10
+          ]
+        },
+        "max_items": {
+          "type": "integer",
+          "description": "Maximum number of items to return for the ls tool",
+          "default": 1000,
+          "examples": [
+            100
+          ]
+        }
+      },
+      "additionalProperties": false,
+      "type": "object"
+    },
     "Config": {
       "properties": {
         "$schema": {
@@ -53,10 +75,17 @@
         "permissions": {
           "$ref": "#/$defs/Permissions",
           "description": "Permission settings for tool usage"
+        },
+        "tools": {
+          "$ref": "#/$defs/Tools",
+          "description": "Tool configurations"
         }
       },
       "additionalProperties": false,
-      "type": "object"
+      "type": "object",
+      "required": [
+        "tools"
+      ]
     },
     "LSPConfig": {
       "properties": {
@@ -484,10 +513,51 @@
             "split"
           ],
           "description": "Diff mode for the TUI interface"
+        },
+        "completions": {
+          "$ref": "#/$defs/Completions",
+          "description": "Completions UI options"
+        }
+      },
+      "additionalProperties": false,
+      "type": "object",
+      "required": [
+        "completions"
+      ]
+    },
+    "ToolLs": {
+      "properties": {
+        "max_depth": {
+          "type": "integer",
+          "description": "Maximum depth for the ls tool",
+          "default": 0,
+          "examples": [
+            10
+          ]
+        },
+        "max_items": {
+          "type": "integer",
+          "description": "Maximum number of items to return for the ls tool",
+          "default": 1000,
+          "examples": [
+            100
+          ]
         }
       },
       "additionalProperties": false,
       "type": "object"
+    },
+    "Tools": {
+      "properties": {
+        "ls": {
+          "$ref": "#/$defs/ToolLs"
+        }
+      },
+      "additionalProperties": false,
+      "type": "object",
+      "required": [
+        "ls"
+      ]
     }
   }
 }