From c96abaed6b4115352e490129006dcfd165b49cba Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 1 Oct 2025 11:16:10 -0300 Subject: [PATCH] feat: limit filepath walk, automatic low limits when not git repo (#1052) Signed-off-by: Carlos Alexandro Becker --- 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(-) diff --git a/internal/config/config.go b/internal/config/config.go index fc5d62ef1c361c4e4aae29a2683ed92c8e76fd9d..858fa1c47b33f6a5e6bafb81b4799ea5739736f9 100644 --- a/internal/config/config.go +++ b/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 +} diff --git a/internal/config/load.go b/internal/config/load.go index b36813084049a89b5e67d79d6342335cb85230e3..9fb45028d6936a652f2657f51707b6cde73f4084 100644 --- a/internal/config/load.go +++ b/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" +} diff --git a/internal/fsext/fileutil.go b/internal/fsext/fileutil.go index 30c552324452cbce4436701506419916c014d7f9..182b145a609311d20544d399c1212097c7519dda 100644 --- a/internal/fsext/fileutil.go +++ b/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 +} diff --git a/internal/fsext/fileutil_test.go b/internal/fsext/fileutil_test.go index 1779bfb9312f7834748badaf72a47563878f21da..3788fe5477b082dec496275a8ac028788d55fc64 100644 --- a/internal/fsext/fileutil_test.go +++ b/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) }) } diff --git a/internal/fsext/ignore_test.go b/internal/fsext/ignore_test.go index 1b517ec0408fe69726bf4fa4bbb95c2a206e548c..a652f3a285fd256840fb3a711fb36e0217a43e28 100644 --- a/internal/fsext/ignore_test.go +++ b/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)) diff --git a/internal/fsext/lookup_test.go b/internal/fsext/lookup_test.go index b7604331673aad0d65d34e046901bc9eae722195..97c167f37d8ebcf4d19124367955874e7f816b67 100644 --- a/internal/fsext/lookup_test.go +++ b/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) diff --git a/internal/fsext/ls.go b/internal/fsext/ls.go index 2027f734c4156572b134c012b2e3c143c364bd29..80d25a57f19867a4ca2af44df7e691bb9d109496 100644 --- a/internal/fsext/ls.go +++ b/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 } diff --git a/internal/fsext/ls_test.go b/internal/fsext/ls_test.go index a74ca3276c9af0edac6adbe1bd6e367d952af492..7bdad17fc46955d49fa08f7488d6efe8239294cb 100644 --- a/internal/fsext/ls_test.go +++ b/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 } diff --git a/internal/llm/prompt/coder.go b/internal/llm/prompt/coder.go index 90e5a17191f346a5df53622e1826bc04214ddbfc..57ed088b22de03fe875ad0822f159b35eb36a834 100644 --- a/internal/llm/prompt/coder.go +++ b/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: Working directory: %s diff --git a/internal/llm/tools/ls.go b/internal/llm/tools/ls.go index f421e69e7af938801aa9c3affacfe30ed669fabc..305f7f10249594ff06ac008a8bf81145d7d834de 100644 --- a/internal/llm/tools/ls.go +++ b/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 { diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 86390611f6115fc14def1e8a7713b252b0d6a59d..f70a0a3dbe63a9473f552efa233e03bd4efc0ee1 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/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 { diff --git a/schema.json b/schema.json index deb65846fe30ca689779e36745b9a429082c452b..014155f1b1f22309ec6381f44c41e97b3b3825dc 100644 --- a/schema.json +++ b/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" + ] } } }