Detailed changes
@@ -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
+}
@@ -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"
+}
@@ -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
+}
@@ -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)
})
}
@@ -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))
@@ -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)
@@ -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
}
@@ -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
}
@@ -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
@@ -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 {
@@ -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 {
@@ -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"
+ ]
}
}
}