on main help sign in
Merge remote-tracking branch 'origin/main' into crush-fantasy
commit0cb2b2cbd0c8c6b123d2b688a22d198d11867bde authorkujtimiihoxha <kujtimii.h@gmail.com> dateThu 23 Oct 2025, 13:00 (1 year ago) parent92944bb45cf614
33 files changed+787−211
.github/cla-signatures.json+16 .goreleaser.yml+1 CRUSH.md+1−1 README.md+4−3 Taskfile.yaml+12−4 go.mod+3−2 go.sum+4−2 internal/agent/coordinator.go+7−1 internal/agent/tools/diagnostics.go+1−1 internal/agent/tools/grep.go+78−99 internal/agent/tools/grep_test.go+221 internal/agent/tools/ls.go+1−1 internal/agent/tools/mcp-tools.go+62−9 internal/agent/tools/references.go+190 internal/agent/tools/references.md+36 internal/agent/tools/rg.go+1−1 internal/agent/tools/testdata/grep.txt+3 internal/cmd/dirs_test.go+46 internal/config/config.go+6−4 internal/config/load.go+9 internal/config/provider.go+1−33 internal/config/provider_test.go+1−1 internal/log/http.go+21−17 internal/lsp/client.go+10 internal/tui/components/chat/editor/editor.go+2 internal/tui/components/chat/messages/messages.go+3−2 internal/tui/components/completions/completions.go+2 internal/tui/components/dialogs/commands/arguments.go+6−1 internal/tui/components/dialogs/commands/keys.go+7 internal/tui/components/dialogs/permissions/keys.go+1−1 internal/tui/exp/list/filterable.go+28−19 internal/tui/exp/list/list.go+3−2 internal/tui/util/util.go−7
.github/cla-signatures.json+16
@@ -711,6 +711,22 @@
711711 "created_at": "2025-10-13T05:56:20Z",
712712 "repoId": 987670088,
713713 "pullRequestNo": 1223
714+ },
715+ {
716+ "name": "BrunoKrugel",
717+ "id": 30608179,
718+ "comment_id": 3411978929,
719+ "created_at": "2025-10-16T17:30:07Z",
720+ "repoId": 987670088,
721+ "pullRequestNo": 1245
722+ },
723+ {
724+ "name": "dpolishuk",
725+ "id": 466424,
726+ "comment_id": 3418756045,
727+ "created_at": "2025-10-18T19:24:00Z",
728+ "repoId": 987670088,
729+ "pullRequestNo": 1254
714730 }
715731 ]
716732}
.goreleaser.yml+1
@@ -303,6 +303,7 @@ changelog:
303303 - "^docs: update$"
304304 - "^test:"
305305 - "^test\\("
306+ - "^v\\d.*"
306307 - "merge conflict"
307308 - "merge conflict"
308309 - Merge branch
CRUSH.md+1−1
@@ -54,7 +54,7 @@ func TestYourFunction(t *testing.T) {
5454## Formatting
5555
5656- ALWAYS format any Go code you write.
57− - First, try `goftumpt -w .`.
57+ - First, try `gofumpt -w .`.
5858 - If `gofumpt` is not available, use `goimports`.
5959 - If `goimports` is not available, use `gofmt`.
6060 - You can also use `task fmt` to run `gofumpt -w .` on the entire project,
README.md+4−3
@@ -189,8 +189,8 @@ That said, you can also set environment variables for preferred providers.
189189| `AWS_ACCESS_KEY_ID` | AWS Bedrock (Claude) |
190190| `AWS_SECRET_ACCESS_KEY` | AWS Bedrock (Claude) |
191191| `AWS_REGION` | AWS Bedrock (Claude) |
192−| `AWS_PROFILE` | Custom AWS Profile |
193−| `AWS_REGION` | AWS Region |
192+| `AWS_PROFILE` | AWS Bedrock (Custom Profile) |
193+| `AWS_BEARER_TOKEN_BEDROCK` | AWS Bedrock |
194194| `AZURE_OPENAI_API_ENDPOINT` | Azure OpenAI models |
195195| `AZURE_OPENAI_API_KEY` | Azure OpenAI models (optional when using Entra ID) |
196196| `AZURE_OPENAI_API_VERSION` | Azure OpenAI models |
@@ -479,6 +479,7 @@ Crush currently supports running Anthropic models through Bedrock, with caching
479479- A Bedrock provider will appear once you have AWS configured, i.e. `aws configure`
480480- Crush also expects the `AWS_REGION` or `AWS_DEFAULT_REGION` to be set
481481- To use a specific AWS profile set `AWS_PROFILE` in your environment, i.e. `AWS_PROFILE=myprofile crush`
482+- Alternatively to `aws configure`, you can also just set `AWS_BEARER_TOKEN_BEDROCK`
482483
483484### Vertex AI Platform
484485
@@ -649,8 +650,8 @@ See the [contributing guide](https://github.com/charmbracelet/crush?tab=contribu
649650We’d love to hear your thoughts on this project. Need help? We gotchu. You can find us on:
650651
651652- [Twitter](https://twitter.com/charmcli)
652−- [Discord][discord]
653653- [Slack](https://charm.land/slack)
654+- [Discord][discord]
654655- [The Fediverse](https://mastodon.social/@charmcli)
655656- [Bluesky](https://bsky.app/profile/charm.land)
656657
Taskfile.yaml+12−4
@@ -2,6 +2,10 @@
22
33version: "3"
44
5+vars:
6+ VERSION:
7+ sh: git describe --long 2>/dev/null || echo ""
8+
59env:
610 CGO_ENABLED: 0
711 GOEXPERIMENT: greenteagc
@@ -30,8 +34,10 @@ tasks:
3034
3135 build:
3236 desc: Run build
37+ vars:
38+ LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}'
3339 cmds:
34− - go build .
40+ - go build {{.LDFLAGS}} .
3541 generates:
3642 - crush
3743
@@ -59,8 +65,10 @@ tasks:
5965
6066 install:
6167 desc: Install the application
68+ vars:
69+ LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}'
6270 cmds:
63− - go install -v .
71+ - go install {{.LDFLAGS}} -v .
6472
6573 profile:cpu:
6674 desc: 10s CPU profile
@@ -99,9 +107,9 @@ tasks:
99107 cmds:
100108 - task: fetch-tags
101109 - git commit --allow-empty -m "{{.NEXT}}"
102− - git tag --annotate --sign {{.NEXT}} {{.CLI_ARGS}}
110+ - git tag --annotate --sign -m "{{.NEXT}}" {{.NEXT}} {{.CLI_ARGS}}
103111 - echo "Pushing {{.NEXT}}..."
104− - git push origin --tags
112+ - git push origin main --follow-tags
105113
106114 fetch-tags:
107115 cmds:
go.mod+3−2
@@ -22,6 +22,9 @@ require (
2222 github.com/charmbracelet/x/ansi v0.10.2
2323 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
2424 github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a
25+ github.com/charmbracelet/x/exp/ordered v0.1.0
26+ github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4
27+ github.com/charmbracelet/x/term v0.2.1
2528 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
2629 github.com/google/uuid v1.6.0
2730 github.com/invopop/jsonschema v0.13.0
@@ -82,8 +85,6 @@ require (
8285 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a // indirect
8386 github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5
8487 github.com/charmbracelet/x/json v0.2.0 // indirect
85− github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4
86− github.com/charmbracelet/x/term v0.2.1
8788 github.com/charmbracelet/x/termios v0.1.1 // indirect
8889 github.com/charmbracelet/x/windows v0.2.2 // indirect
8990 github.com/davecgh/go-spew v1.1.1 // indirect
go.sum+4−2
@@ -104,12 +104,14 @@ github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1
104104github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
105105github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
106106github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
107+github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
108+github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
107109github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 h1:DTSZxdV9qQagD4iGcAt9RgaRBZtJl01bfKgdLzUzUPI=
108110github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
109111github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
110112github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
111−github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4 h1:ZhDGU688EHQXslD9KphRpXwK0pKP03egUoZAATUDlV0=
112−github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
113+github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc=
114+github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
113115github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
114116github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
115117github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
internal/agent/coordinator.go+7−1
@@ -305,6 +305,10 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
305305 tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
306306 )
307307
308+ if len(c.cfg.LSP) > 0 {
309+ allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients))
310+ }
311+
308312 var filteredTools []fantasy.AgentTool
309313 for _, tool := range allTools {
310314 if slices.Contains(agent.AllowedTools, tool.Info().Name) {
@@ -337,7 +341,9 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
337341 }
338342 }
339343 }
340−
344+ slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
345+ return strings.Compare(a.Info().Name, b.Info().Name)
346+ })
341347 return filteredTools, nil
342348}
343349
internal/agent/tools/diagnostics.go+1−1
@@ -19,7 +19,7 @@ type DiagnosticsParams struct {
1919 FilePath string `json:"file_path,omitempty" description:"The path to the file to get diagnostics for (leave w empty for project diagnostics)"`
2020}
2121
22−const DiagnosticsToolName = "diagnostics"
22+const DiagnosticsToolName = "lsp_diagnostics"
2323
2424//go:embed diagnostics.md
2525var diagnosticsDescription []byte
internal/agent/tools/grep.go+78−99
@@ -2,16 +2,18 @@ package tools
22
33import (
44 "bufio"
5+ "bytes"
56 "context"
67 _ "embed"
8+ "encoding/json"
79 "fmt"
810 "io"
11+ "net/http"
912 "os"
1013 "os/exec"
1114 "path/filepath"
1215 "regexp"
1316 "sort"
14− "strconv"
1517 "strings"
1618 "sync"
1719 "time"
@@ -81,6 +83,7 @@ type grepMatch struct {
8183 path string
8284 modTime time.Time
8385 lineNum int
86+ charNum int
8487 lineText string
8588}
8689
@@ -97,6 +100,18 @@ const (
97100//go:embed grep.md
98101var grepDescription []byte
99102
103+// escapeRegexPattern escapes special regex characters so they're treated as literal characters
104+func escapeRegexPattern(pattern string) string {
105+ specialChars := []string{"\\", ".", "+", "*", "?", "(", ")", "[", "]", "{", "}", "^", "$", "|"}
106+ escaped := pattern
107+
108+ for _, char := range specialChars {
109+ escaped = strings.ReplaceAll(escaped, char, "\\"+char)
110+ }
111+
112+ return escaped
113+}
114+
100115func NewGrepTool(workingDir string) fantasy.AgentTool {
101116 return fantasy.NewAgentTool(
102117 GrepToolName,
@@ -142,7 +157,11 @@ func NewGrepTool(workingDir string) fantasy.AgentTool {
142157 if len(lineText) > maxGrepContentWidth {
143158 lineText = lineText[:maxGrepContentWidth] + "..."
144159 }
145− fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, lineText)
160+ if match.charNum > 0 {
161+ fmt.Fprintf(&output, " Line %d, Char %d: %s\n", match.lineNum, match.charNum, lineText)
162+ } else {
163+ fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, lineText)
164+ }
146165 } else {
147166 fmt.Fprintf(&output, " %s\n", match.path)
148167 }
@@ -163,18 +182,6 @@ func NewGrepTool(workingDir string) fantasy.AgentTool {
163182 })
164183}
165184
166−// escapeRegexPattern escapes special regex characters so they're treated as literal characters
167−func escapeRegexPattern(pattern string) string {
168− specialChars := []string{"\\", ".", "+", "*", "?", "(", ")", "[", "]", "{", "}", "^", "$", "|"}
169− escaped := pattern
170−
171− for _, char := range specialChars {
172− escaped = strings.ReplaceAll(escaped, char, "\\"+char)
173− }
174−
175− return escaped
176−}
177−
178185func searchFiles(ctx context.Context, pattern, rootPath, include string, limit int) ([]grepMatch, bool, error) {
179186 matches, err := searchWithRipgrep(ctx, pattern, rootPath, include)
180187 if err != nil {
@@ -218,66 +225,51 @@ func searchWithRipgrep(ctx context.Context, pattern, path, include string) ([]gr
218225 return nil, err
219226 }
220227
221− lines := strings.Split(strings.TrimSpace(string(output)), "\n")
222− matches := make([]grepMatch, 0, len(lines))
223−
224− for _, line := range lines {
225− if line == "" {
228+ var matches []grepMatch
229+ for line := range bytes.SplitSeq(bytes.TrimSpace(output), []byte{'\n'}) {
230+ if len(line) == 0 {
226231 continue
227232 }
228−
229− // Parse ripgrep output using null separation
230− filePath, lineNumStr, lineText, ok := parseRipgrepLine(line)
231− if !ok {
233+ var match ripgrepMatch
234+ if err := json.Unmarshal(line, &match); err != nil {
232235 continue
233236 }
234−
235− lineNum, err := strconv.Atoi(lineNumStr)
236− if err != nil {
237+ if match.Type != "match" {
237238 continue
238239 }
239−
240− fileInfo, err := os.Stat(filePath)
241− if err != nil {
242− continue // Skip files we can't access
240+ for _, m := range match.Data.Submatches {
241+ fi, err := os.Stat(match.Data.Path.Text)
242+ if err != nil {
243+ continue // Skip files we can't access
244+ }
245+ matches = append(matches, grepMatch{
246+ path: match.Data.Path.Text,
247+ modTime: fi.ModTime(),
248+ lineNum: match.Data.LineNumber,
249+ charNum: m.Start + 1, // ensure 1-based
250+ lineText: strings.TrimSpace(match.Data.Lines.Text),
251+ })
252+ // only get the first match of each line
253+ break
243254 }
244−
245− matches = append(matches, grepMatch{
246− path: filePath,
247− modTime: fileInfo.ModTime(),
248− lineNum: lineNum,
249− lineText: lineText,
250− })
251255 }
252−
253256 return matches, nil
254257}
255258
256−// parseRipgrepLine parses ripgrep output with null separation to handle Windows paths
257−func parseRipgrepLine(line string) (filePath, lineNum, lineText string, ok bool) {
258− // Split on null byte first to separate filename from rest
259− parts := strings.SplitN(line, "\x00", 2)
260− if len(parts) != 2 {
261− return "", "", "", false
262− }
263−
264− filePath = parts[0]
265− remainder := parts[1]
266−
267− // Now split the remainder on first colon: "linenum:content"
268− colonIndex := strings.Index(remainder, ":")
269− if colonIndex == -1 {
270− return "", "", "", false
271− }
272−
273− lineNumStr := remainder[:colonIndex]
274− lineText = remainder[colonIndex+1:]
275−
276− if _, err := strconv.Atoi(lineNumStr); err != nil {
277− return "", "", "", false
278− }
279−
280− return filePath, lineNumStr, lineText, true
259+type ripgrepMatch struct {
260+ Type string `json:"type"`
261+ Data struct {
262+ Path struct {
263+ Text string `json:"text"`
264+ } `json:"path"`
265+ Lines struct {
266+ Text string `json:"text"`
267+ } `json:"lines"`
268+ LineNumber int `json:"line_number"`
269+ Submatches []struct {
270+ Start int `json:"start"`
271+ } `json:"submatches"`
272+ } `json:"data"`
281273}
282274
283275func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) {
@@ -329,7 +321,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
329321 return nil
330322 }
331323
332− match, lineNum, lineText, err := fileContainsPattern(path, regex)
324+ match, lineNum, charNum, lineText, err := fileContainsPattern(path, regex)
333325 if err != nil {
334326 return nil // Skip files we can't read
335327 }
@@ -339,6 +331,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
339331 path: path,
340332 modTime: info.ModTime(),
341333 lineNum: lineNum,
334+ charNum: charNum,
342335 lineText: lineText,
343336 })
344337
@@ -356,15 +349,15 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
356349 return matches, nil
357350}
358351
359−func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, string, error) {
360− // Quick binary file detection
361− if isBinaryFile(filePath) {
362− return false, 0, "", nil
352+func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, int, string, error) {
353+ // Only search text files.
354+ if !isTextFile(filePath) {
355+ return false, 0, 0, "", nil
363356 }
364357
365358 file, err := os.Open(filePath)
366359 if err != nil {
367− return false, 0, "", err
360+ return false, 0, 0, "", err
368361 }
369362 defer file.Close()
370363
@@ -373,53 +366,39 @@ func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, st
373366 for scanner.Scan() {
374367 lineNum++
375368 line := scanner.Text()
376− if pattern.MatchString(line) {
377− return true, lineNum, line, nil
369+ if loc := pattern.FindStringIndex(line); loc != nil {
370+ charNum := loc[0] + 1
371+ return true, lineNum, charNum, line, nil
378372 }
379373 }
380374
381− return false, 0, "", scanner.Err()
375+ return false, 0, 0, "", scanner.Err()
382376}
383377
384−var binaryExts = map[string]struct{}{
385− ".exe": {}, ".dll": {}, ".so": {}, ".dylib": {},
386− ".bin": {}, ".obj": {}, ".o": {}, ".a": {},
387− ".zip": {}, ".tar": {}, ".gz": {}, ".bz2": {},
388− ".jpg": {}, ".jpeg": {}, ".png": {}, ".gif": {},
389− ".pdf": {}, ".doc": {}, ".docx": {}, ".xls": {},
390− ".mp3": {}, ".mp4": {}, ".avi": {}, ".mov": {},
391−}
392−
393−// isBinaryFile performs a quick check to determine if a file is binary
394−func isBinaryFile(filePath string) bool {
395− // Check file extension first (fastest)
396− ext := strings.ToLower(filepath.Ext(filePath))
397− if _, isBinary := binaryExts[ext]; isBinary {
398− return true
399− }
400−
401− // Quick content check for files without clear extensions
378+// isTextFile checks if a file is a text file by examining its MIME type.
379+func isTextFile(filePath string) bool {
402380 file, err := os.Open(filePath)
403381 if err != nil {
404− return false // If we can't open it, let the caller handle the error
382+ return false
405383 }
406384 defer file.Close()
407385
408− // Read first 512 bytes to check for null bytes
386+ // Read first 512 bytes for MIME type detection.
409387 buffer := make([]byte, 512)
410388 n, err := file.Read(buffer)
411389 if err != nil && err != io.EOF {
412390 return false
413391 }
414392
415− // Check for null bytes (common in binary files)
416− for i := range n {
417− if buffer[i] == 0 {
418− return true
419− }
420− }
393+ // Detect content type.
394+ contentType := http.DetectContentType(buffer[:n])
421395
422− return false
396+ // Check if it's a text MIME type.
397+ return strings.HasPrefix(contentType, "text/") ||
398+ contentType == "application/json" ||
399+ contentType == "application/xml" ||
400+ contentType == "application/javascript" ||
401+ contentType == "application/x-sh"
423402}
424403
425404func globToRegex(glob string) string {
internal/agent/tools/grep_test.go+221
@@ -198,3 +198,224 @@ func BenchmarkRegexCacheVsCompile(b *testing.B) {
198198 }
199199 })
200200}
201+
202+func TestIsTextFile(t *testing.T) {
203+ t.Parallel()
204+ tempDir := t.TempDir()
205+
206+ tests := []struct {
207+ name string
208+ filename string
209+ content []byte
210+ wantText bool
211+ }{
212+ {
213+ name: "go file",
214+ filename: "test.go",
215+ content: []byte("package main\n\nfunc main() {}\n"),
216+ wantText: true,
217+ },
218+ {
219+ name: "yaml file",
220+ filename: "config.yaml",
221+ content: []byte("key: value\nlist:\n - item1\n - item2\n"),
222+ wantText: true,
223+ },
224+ {
225+ name: "yml file",
226+ filename: "config.yml",
227+ content: []byte("key: value\n"),
228+ wantText: true,
229+ },
230+ {
231+ name: "json file",
232+ filename: "data.json",
233+ content: []byte(`{"key": "value"}`),
234+ wantText: true,
235+ },
236+ {
237+ name: "javascript file",
238+ filename: "script.js",
239+ content: []byte("console.log('hello');\n"),
240+ wantText: true,
241+ },
242+ {
243+ name: "typescript file",
244+ filename: "script.ts",
245+ content: []byte("const x: string = 'hello';\n"),
246+ wantText: true,
247+ },
248+ {
249+ name: "markdown file",
250+ filename: "README.md",
251+ content: []byte("# Title\n\nSome content\n"),
252+ wantText: true,
253+ },
254+ {
255+ name: "shell script",
256+ filename: "script.sh",
257+ content: []byte("#!/bin/bash\necho 'hello'\n"),
258+ wantText: true,
259+ },
260+ {
261+ name: "python file",
262+ filename: "script.py",
263+ content: []byte("print('hello')\n"),
264+ wantText: true,
265+ },
266+ {
267+ name: "xml file",
268+ filename: "data.xml",
269+ content: []byte("<?xml version=\"1.0\"?>\n<root></root>\n"),
270+ wantText: true,
271+ },
272+ {
273+ name: "plain text",
274+ filename: "file.txt",
275+ content: []byte("plain text content\n"),
276+ wantText: true,
277+ },
278+ {
279+ name: "css file",
280+ filename: "style.css",
281+ content: []byte("body { color: red; }\n"),
282+ wantText: true,
283+ },
284+ {
285+ name: "scss file",
286+ filename: "style.scss",
287+ content: []byte("$primary: blue;\nbody { color: $primary; }\n"),
288+ wantText: true,
289+ },
290+ {
291+ name: "sass file",
292+ filename: "style.sass",
293+ content: []byte("$primary: blue\nbody\n color: $primary\n"),
294+ wantText: true,
295+ },
296+ {
297+ name: "rust file",
298+ filename: "main.rs",
299+ content: []byte("fn main() {\n println!(\"Hello, world!\");\n}\n"),
300+ wantText: true,
301+ },
302+ {
303+ name: "zig file",
304+ filename: "main.zig",
305+ content: []byte("const std = @import(\"std\");\npub fn main() void {}\n"),
306+ wantText: true,
307+ },
308+ {
309+ name: "java file",
310+ filename: "Main.java",
311+ content: []byte("public class Main {\n public static void main(String[] args) {}\n}\n"),
312+ wantText: true,
313+ },
314+ {
315+ name: "c file",
316+ filename: "main.c",
317+ content: []byte("#include <stdio.h>\nint main() { return 0; }\n"),
318+ wantText: true,
319+ },
320+ {
321+ name: "cpp file",
322+ filename: "main.cpp",
323+ content: []byte("#include <iostream>\nint main() { return 0; }\n"),
324+ wantText: true,
325+ },
326+ {
327+ name: "fish shell",
328+ filename: "script.fish",
329+ content: []byte("#!/usr/bin/env fish\necho 'hello'\n"),
330+ wantText: true,
331+ },
332+ {
333+ name: "powershell file",
334+ filename: "script.ps1",
335+ content: []byte("Write-Host 'Hello, World!'\n"),
336+ wantText: true,
337+ },
338+ {
339+ name: "cmd batch file",
340+ filename: "script.bat",
341+ content: []byte("@echo off\necho Hello, World!\n"),
342+ wantText: true,
343+ },
344+ {
345+ name: "cmd file",
346+ filename: "script.cmd",
347+ content: []byte("@echo off\necho Hello, World!\n"),
348+ wantText: true,
349+ },
350+ {
351+ name: "binary exe",
352+ filename: "binary.exe",
353+ content: []byte{0x4D, 0x5A, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00},
354+ wantText: false,
355+ },
356+ {
357+ name: "png image",
358+ filename: "image.png",
359+ content: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A},
360+ wantText: false,
361+ },
362+ {
363+ name: "jpeg image",
364+ filename: "image.jpg",
365+ content: []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46},
366+ wantText: false,
367+ },
368+ {
369+ name: "zip archive",
370+ filename: "archive.zip",
371+ content: []byte{0x50, 0x4B, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00},
372+ wantText: false,
373+ },
374+ {
375+ name: "pdf file",
376+ filename: "document.pdf",
377+ content: []byte("%PDF-1.4\n%âãÏÓ\n"),
378+ wantText: false,
379+ },
380+ }
381+
382+ for _, tt := range tests {
383+ t.Run(tt.name, func(t *testing.T) {
384+ t.Parallel()
385+ filePath := filepath.Join(tempDir, tt.filename)
386+ require.NoError(t, os.WriteFile(filePath, tt.content, 0o644))
387+
388+ got := isTextFile(filePath)
389+ require.Equal(t, tt.wantText, got, "isTextFile(%s) = %v, want %v", tt.filename, got, tt.wantText)
390+ })
391+ }
392+}
393+
394+func TestColumnMatch(t *testing.T) {
395+ t.Parallel()
396+
397+ // Test both implementations
398+ for name, fn := range map[string]func(pattern, path, include string) ([]grepMatch, error){
399+ "regex": searchFilesWithRegex,
400+ "rg": func(pattern, path, include string) ([]grepMatch, error) {
401+ return searchWithRipgrep(t.Context(), pattern, path, include)
402+ },
403+ } {
404+ t.Run(name, func(t *testing.T) {
405+ t.Parallel()
406+
407+ if name == "rg" && getRg() == "" {
408+ t.Skip("rg is not in $PATH")
409+ }
410+
411+ matches, err := fn("THIS", "./testdata/", "")
412+ require.NoError(t, err)
413+ require.Len(t, matches, 1)
414+ match := matches[0]
415+ require.Equal(t, 2, match.lineNum)
416+ require.Equal(t, 14, match.charNum)
417+ require.Equal(t, "I wanna grep THIS particular word", match.lineText)
418+ require.Equal(t, "testdata/grep.txt", filepath.ToSlash(filepath.Clean(match.path)))
419+ })
420+ }
421+}
internal/agent/tools/ls.go+1−1
@@ -115,7 +115,7 @@ func ListDirectoryTree(searchPath string, params LSParams, lsConfig config.ToolL
115115 }
116116
117117 depth, limit := lsConfig.Limits()
118− maxFiles := min(limit, maxLSFiles)
118+ maxFiles := cmp.Or(limit, maxLSFiles)
119119 files, truncated, err := fsext.ListDirectory(
120120 searchPath,
121121 params.Ignore,
internal/agent/tools/mcp-tools.go+62−9
@@ -10,6 +10,7 @@ import (
1010 "log/slog"
1111 "maps"
1212 "net/http"
13+ "os"
1314 "os/exec"
1415 "slices"
1516 "strings"
@@ -116,15 +117,26 @@ func (m *McpTool) MCPToolName() string {
116117}
117118
118119func (b *McpTool) Info() fantasy.ToolInfo {
119− input := b.tool.InputSchema.(map[string]any)
120− required, _ := input["required"].([]string)
121− if required == nil {
122− required = make([]string, 0)
123− }
124− parameters, _ := input["properties"].(map[string]any)
125− if parameters == nil {
126− parameters = make(map[string]any)
120+ parameters := make(map[string]any)
121+ required := make([]string, 0)
122+
123+ if input, ok := b.tool.InputSchema.(map[string]any); ok {
124+ if props, ok := input["properties"].(map[string]any); ok {
125+ parameters = props
126+ }
127+ if req, ok := input["required"].([]any); ok {
128+ // Convert []any -> []string when elements are strings
129+ for _, v := range req {
130+ if s, ok := v.(string); ok {
131+ required = append(required, s)
132+ }
133+ }
134+ } else if reqStr, ok := input["required"].([]string); ok {
135+ // Handle case where it's already []string
136+ required = reqStr
137+ }
127138 }
139+
128140 return fantasy.ToolInfo{
129141 Name: fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name),
130142 Description: b.tool.Description,
@@ -322,6 +334,10 @@ func GetMCPTools(ctx context.Context, permissions permission.Service, cfg *confi
322334 slog.Error("panic in mcp client initialization", "error", err, "name", name)
323335 }
324336 }()
337+
338+ ctx, cancel := context.WithTimeout(ctx, mcpTimeout(m))
339+ defer cancel()
340+
325341 c, err := createMCPSession(ctx, name, m, cfg.Resolver())
326342 if err != nil {
327343 return
@@ -370,6 +386,8 @@ func createMCPSession(ctx context.Context, name string, m config.MCPConfig, reso
370386 if err != nil {
371387 updateMCPState(name, MCPStateError, err, nil, 0)
372388 slog.Error("error creating mcp client", "error", err, "name", name)
389+ cancel()
390+ cancelTimer.Stop()
373391 return nil, err
374392 }
375393
@@ -392,9 +410,11 @@ func createMCPSession(ctx context.Context, name string, m config.MCPConfig, reso
392410
393411 session, err := client.Connect(mcpCtx, transport, nil)
394412 if err != nil {
413+ err = maybeStdioErr(err, transport)
395414 updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0)
396415 slog.Error("error starting mcp client", "error", err, "name", name)
397416 cancel()
417+ cancelTimer.Stop()
398418 return nil, err
399419 }
400420
@@ -403,6 +423,27 @@ func createMCPSession(ctx context.Context, name string, m config.MCPConfig, reso
403423 return session, nil
404424}
405425
426+// maybeStdioErr if a stdio mcp prints an error in non-json format, it'll fail
427+// to parse, and the cli will then close it, causing the EOF error.
428+// so, if we got an EOF err, and the transport is STDIO, we try to exec it
429+// again with a timeout and collect the output so we can add details to the
430+// error.
431+// this happens particularly when starting things with npx, e.g. if node can't
432+// be found or some other error like that.
433+func maybeStdioErr(err error, transport mcp.Transport) error {
434+ if !errors.Is(err, io.EOF) {
435+ return err
436+ }
437+ ct, ok := transport.(*mcp.CommandTransport)
438+ if !ok {
439+ return err
440+ }
441+ if err2 := stdioMCPCheck(ct.Command); err2 != nil {
442+ err = errors.Join(err, err2)
443+ }
444+ return err
445+}
446+
406447func maybeTimeoutErr(err error, timeout time.Duration) error {
407448 if errors.Is(err, context.Canceled) {
408449 return fmt.Errorf("timed out after %s", timeout)
@@ -421,7 +462,7 @@ func createMCPTransport(ctx context.Context, m config.MCPConfig, resolver config
421462 return nil, fmt.Errorf("mcp stdio config requires a non-empty 'command' field")
422463 }
423464 cmd := exec.CommandContext(ctx, home.Long(command), m.Args...)
424− cmd.Env = m.ResolvedEnv()
465+ cmd.Env = append(os.Environ(), m.ResolvedEnv()...)
425466 return &mcp.CommandTransport{
426467 Command: cmd,
427468 }, nil
@@ -470,3 +511,15 @@ func (rt headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error
470511func mcpTimeout(m config.MCPConfig) time.Duration {
471512 return time.Duration(cmp.Or(m.Timeout, 15)) * time.Second
472513}
514+
515+func stdioMCPCheck(old *exec.Cmd) error {
516+ ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
517+ defer cancel()
518+ cmd := exec.CommandContext(ctx, old.Path, old.Args...)
519+ cmd.Env = old.Env
520+ out, err := cmd.CombinedOutput()
521+ if err == nil || errors.Is(ctx.Err(), context.DeadlineExceeded) {
522+ return nil
523+ }
524+ return fmt.Errorf("%w: %s", err, string(out))
525+}
internal/agent/tools/references.gonew file+190
@@ -0,0 +1,190 @@
1+package tools
2+
3+import (
4+ "cmp"
5+ "context"
6+ _ "embed"
7+ "errors"
8+ "fmt"
9+ "log/slog"
10+ "maps"
11+ "path/filepath"
12+ "regexp"
13+ "slices"
14+ "sort"
15+ "strings"
16+
17+ "charm.land/fantasy"
18+ "github.com/charmbracelet/crush/internal/csync"
19+ "github.com/charmbracelet/crush/internal/lsp"
20+ "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
21+)
22+
23+type ReferencesParams struct {
24+ Symbol string `json:"symbol" description:"The symbol name to search for (e.g., function name, variable name, type name)"`
25+ Path string `json:"path,omitempty" description:"The directory to search in. Use a directory/file to narrow down the symbol search. Defaults to the current working directory."`
26+}
27+
28+type referencesTool struct {
29+ lspClients *csync.Map[string, *lsp.Client]
30+}
31+
32+const ReferencesToolName = "lsp_references"
33+
34+//go:embed references.md
35+var referencesDescription []byte
36+
37+func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool {
38+ return fantasy.NewAgentTool(
39+ ReferencesToolName,
40+ string(referencesDescription),
41+ func(ctx context.Context, params ReferencesParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
42+ if params.Symbol == "" {
43+ return fantasy.NewTextErrorResponse("symbol is required"), nil
44+ }
45+
46+ if lspClients.Len() == 0 {
47+ return fantasy.NewTextErrorResponse("no LSP clients available"), nil
48+ }
49+
50+ workingDir := cmp.Or(params.Path, ".")
51+
52+ matches, _, err := searchFiles(ctx, regexp.QuoteMeta(params.Symbol), workingDir, "", 100)
53+ if err != nil {
54+ return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to search for symbol: %s", err)), nil
55+ }
56+
57+ if len(matches) == 0 {
58+ return fantasy.NewTextResponse(fmt.Sprintf("Symbol '%s' not found", params.Symbol)), nil
59+ }
60+
61+ var allLocations []protocol.Location
62+ var allErrs error
63+ for _, match := range matches {
64+ locations, err := find(ctx, lspClients, params.Symbol, match)
65+ if err != nil {
66+ if strings.Contains(err.Error(), "no identifier found") {
67+ // grep probably matched a comment, string value, or something else that's irrelevant
68+ continue
69+ }
70+ slog.Error("Failed to find references", "error", err, "symbol", params.Symbol, "path", match.path, "line", match.lineNum, "char", match.charNum)
71+ allErrs = errors.Join(allErrs, err)
72+ continue
73+ }
74+ allLocations = append(allLocations, locations...)
75+ // XXX: should we break here or look for all results?
76+ }
77+
78+ if len(allLocations) > 0 {
79+ output := formatReferences(cleanupLocations(allLocations))
80+ return fantasy.NewTextResponse(output), nil
81+ }
82+
83+ if allErrs != nil {
84+ return fantasy.NewTextErrorResponse(allErrs.Error()), nil
85+ }
86+ return fantasy.NewTextResponse(fmt.Sprintf("No references found for symbol '%s'", params.Symbol)), nil
87+ })
88+}
89+
90+func (r *referencesTool) Name() string {
91+ return ReferencesToolName
92+}
93+
94+func find(ctx context.Context, lspClients *csync.Map[string, *lsp.Client], symbol string, match grepMatch) ([]protocol.Location, error) {
95+ absPath, err := filepath.Abs(match.path)
96+ if err != nil {
97+ return nil, fmt.Errorf("failed to get absolute path: %s", err)
98+ }
99+
100+ var client *lsp.Client
101+ for c := range lspClients.Seq() {
102+ if c.HandlesFile(absPath) {
103+ client = c
104+ break
105+ }
106+ }
107+
108+ if client == nil {
109+ slog.Warn("No LSP clients to handle", "path", match.path)
110+ return nil, nil
111+ }
112+
113+ return client.FindReferences(
114+ ctx,
115+ absPath,
116+ match.lineNum,
117+ match.charNum+getSymbolOffset(symbol),
118+ true,
119+ )
120+}
121+
122+// getSymbolOffset returns the character offset to the actual symbol name
123+// in a qualified symbol (e.g., "Bar" in "foo.Bar" or "method" in "Class::method").
124+func getSymbolOffset(symbol string) int {
125+ // Check for :: separator (Rust, C++, Ruby modules/classes, PHP static).
126+ if idx := strings.LastIndex(symbol, "::"); idx != -1 {
127+ return idx + 2
128+ }
129+ // Check for . separator (Go, Python, JavaScript, Java, C#, Ruby methods).
130+ if idx := strings.LastIndex(symbol, "."); idx != -1 {
131+ return idx + 1
132+ }
133+ // Check for \ separator (PHP namespaces).
134+ if idx := strings.LastIndex(symbol, "\\"); idx != -1 {
135+ return idx + 1
136+ }
137+ return 0
138+}
139+
140+func cleanupLocations(locations []protocol.Location) []protocol.Location {
141+ slices.SortFunc(locations, func(a, b protocol.Location) int {
142+ if a.URI != b.URI {
143+ return strings.Compare(string(a.URI), string(b.URI))
144+ }
145+ if a.Range.Start.Line != b.Range.Start.Line {
146+ return cmp.Compare(a.Range.Start.Line, b.Range.Start.Line)
147+ }
148+ return cmp.Compare(a.Range.Start.Character, b.Range.Start.Character)
149+ })
150+ return slices.CompactFunc(locations, func(a, b protocol.Location) bool {
151+ return a.URI == b.URI &&
152+ a.Range.Start.Line == b.Range.Start.Line &&
153+ a.Range.Start.Character == b.Range.Start.Character
154+ })
155+}
156+
157+func groupByFilename(locations []protocol.Location) map[string][]protocol.Location {
158+ files := make(map[string][]protocol.Location)
159+ for _, loc := range locations {
160+ path, err := loc.URI.Path()
161+ if err != nil {
162+ slog.Error("Failed to convert location URI to path", "uri", loc.URI, "error", err)
163+ continue
164+ }
165+ files[path] = append(files[path], loc)
166+ }
167+ return files
168+}
169+
170+func formatReferences(locations []protocol.Location) string {
171+ fileRefs := groupByFilename(locations)
172+ files := slices.Collect(maps.Keys(fileRefs))
173+ sort.Strings(files)
174+
175+ var output strings.Builder
176+ output.WriteString(fmt.Sprintf("Found %d reference(s) in %d file(s):\n\n", len(locations), len(files)))
177+
178+ for _, file := range files {
179+ refs := fileRefs[file]
180+ output.WriteString(fmt.Sprintf("%s (%d reference(s)):\n", file, len(refs)))
181+ for _, ref := range refs {
182+ line := ref.Range.Start.Line + 1
183+ char := ref.Range.Start.Character + 1
184+ output.WriteString(fmt.Sprintf(" Line %d, Column %d\n", line, char))
185+ }
186+ output.WriteString("\n")
187+ }
188+
189+ return output.String()
190+}
internal/agent/tools/references.mdnew file+36
@@ -0,0 +1,36 @@
1+Find all references to/usage of a symbol by name using the Language Server Protocol (LSP).
2+
3+WHEN TO USE THIS TOOL:
4+
5+- **ALWAYS USE THIS FIRST** when searching for where a function, method, variable, type, or constant is used
6+- **DO NOT use grep/glob for symbol searches** - this tool is semantic-aware and much more accurate
7+- Use when you need to find all usages of a specific symbol (function, variable, type, class, method, etc.)
8+- More accurate than grep because it understands code semantics and scope
9+- Finds only actual references, not string matches in comments or unrelated code
10+- Helpful for understanding where a symbol is used throughout the codebase
11+- Useful for refactoring or analyzing code dependencies
12+- Good for finding all call sites of a function, method, type, package, constant, variable, etc.
13+
14+HOW TO USE:
15+
16+- Provide the symbol name (e.g., "MyFunction", "myVariable", "MyType")
17+- Optionally specify a path to narrow the search to a specific directory
18+- The tool will automatically find the symbol and locate all references
19+
20+FEATURES:
21+
22+- Returns all references grouped by file
23+- Shows line and column numbers for each reference
24+- Supports multiple programming languages through LSP
25+- Automatically finds the symbol without needing exact position
26+
27+LIMITATIONS:
28+
29+- May not find references in files that haven't been opened or indexed
30+- Results depend on the LSP server's capabilities
31+
32+TIPS:
33+
34+- **Use this tool instead of grep when looking for symbol references** - it's more accurate and semantic-aware
35+- Simply provide the symbol name and let the tool find it for you
36+- This tool understands code structure, so it won't match unrelated strings or comments
internal/agent/tools/rg.go+1−1
@@ -43,7 +43,7 @@ func getRgSearchCmd(ctx context.Context, pattern, path, include string) *exec.Cm
4343 return nil
4444 }
4545 // Use -n to show line numbers, -0 for null separation to handle Windows paths
46− args := []string{"-H", "-n", "-0", pattern}
46+ args := []string{"--json", "-H", "-n", "-0", pattern}
4747 if include != "" {
4848 args = append(args, "--glob", include)
4949 }
internal/agent/tools/testdata/grep.txtnew file+3
@@ -0,0 +1,3 @@
1+test file for grep
2+I wanna grep THIS particular word
3+and nothing else
internal/cmd/dirs_test.gonew file+46
@@ -0,0 +1,46 @@
1+package cmd
2+
3+import (
4+ "bytes"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func init() {
13+ os.Setenv("XDG_CONFIG_HOME", "/tmp/fakeconfig")
14+ os.Setenv("XDG_DATA_HOME", "/tmp/fakedata")
15+}
16+
17+func TestDirs(t *testing.T) {
18+ var b bytes.Buffer
19+ dirsCmd.SetOut(&b)
20+ dirsCmd.SetErr(&b)
21+ dirsCmd.SetIn(bytes.NewReader(nil))
22+ dirsCmd.Run(dirsCmd, nil)
23+ expected := filepath.FromSlash("/tmp/fakeconfig/crush") + "\n" +
24+ filepath.FromSlash("/tmp/fakedata/crush") + "\n"
25+ require.Equal(t, expected, b.String())
26+}
27+
28+func TestConfigDir(t *testing.T) {
29+ var b bytes.Buffer
30+ configDirCmd.SetOut(&b)
31+ configDirCmd.SetErr(&b)
32+ configDirCmd.SetIn(bytes.NewReader(nil))
33+ configDirCmd.Run(configDirCmd, nil)
34+ expected := filepath.FromSlash("/tmp/fakeconfig/crush") + "\n"
35+ require.Equal(t, expected, b.String())
36+}
37+
38+func TestDataDir(t *testing.T) {
39+ var b bytes.Buffer
40+ dataDirCmd.SetOut(&b)
41+ dataDirCmd.SetErr(&b)
42+ dataDirCmd.SetIn(bytes.NewReader(nil))
43+ dataDirCmd.Run(dataDirCmd, nil)
44+ expected := filepath.FromSlash("/tmp/fakedata/crush") + "\n"
45+ require.Equal(t, expected, b.String())
46+}
internal/config/config.go+6−4
@@ -156,7 +156,7 @@ type Completions struct {
156156}
157157
158158func (c Completions) Limits() (depth, items int) {
159− return ptrValOr(c.MaxDepth, -1), ptrValOr(c.MaxItems, -1)
159+ return ptrValOr(c.MaxDepth, 0), ptrValOr(c.MaxItems, 0)
160160}
161161
162162type Permissions struct {
@@ -278,7 +278,7 @@ type ToolLs struct {
278278}
279279
280280func (t ToolLs) Limits() (depth, items int) {
281− return ptrValOr(t.MaxDepth, -1), ptrValOr(t.MaxItems, -1)
281+ return ptrValOr(t.MaxDepth, 0), ptrValOr(t.MaxItems, 0)
282282}
283283
284284// Config holds the configuration for crush.
@@ -301,10 +301,10 @@ type Config struct {
301301
302302 Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"`
303303
304+ Agents map[string]Agent `json:"-"`
305+
304306 // Internal
305307 workingDir string `json:"-"`
306− // TODO: most likely remove this concept when I come back to it
307− Agents map[string]Agent `json:"-"`
308308 // TODO: find a better way to do this this should probably not be part of the config
309309 resolver VariableResolver
310310 dataConfigDir string `json:"-"`
@@ -470,6 +470,8 @@ func allToolNames() []string {
470470 "download",
471471 "edit",
472472 "multiedit",
473+ "lsp_diagnostics",
474+ "lsp_references",
473475 "fetch",
474476 "glob",
475477 "grep",
internal/config/load.go+9
@@ -607,6 +607,10 @@ func hasVertexCredentials(env env.Env) bool {
607607}
608608
609609func hasAWSCredentials(env env.Env) bool {
610+ if env.Get("AWS_BEARER_TOKEN_BEDROCK") != "" {
611+ return true
612+ }
613+
610614 if env.Get("AWS_ACCESS_KEY_ID") != "" && env.Get("AWS_SECRET_ACCESS_KEY") != "" {
611615 return true
612616 }
@@ -623,6 +627,11 @@ func hasAWSCredentials(env env.Env) bool {
623627 env.Get("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
624628 return true
625629 }
630+
631+ if _, err := os.Stat(filepath.Join(home.Dir(), ".aws/credentials")); err == nil {
632+ return true
633+ }
634+
626635 return false
627636}
628637
internal/config/provider.go+1−33
@@ -126,7 +126,7 @@ func Providers(cfg *Config) ([]catwalk.Provider, error) {
126126}
127127
128128func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string) ([]catwalk.Provider, error) {
129− cacheIsStale, cacheExists := isCacheStale(path)
129+ _, cacheExists := isCacheStale(path)
130130
131131 catwalkGetAndSave := func() ([]catwalk.Provider, error) {
132132 providers, err := client.GetProviders()
@@ -142,25 +142,6 @@ func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string)
142142 return providers, nil
143143 }
144144
145− backgroundCacheUpdate := func() {
146− go func() {
147− slog.Info("Updating providers cache in background", "path", path)
148−
149− providers, err := client.GetProviders()
150− if err != nil {
151− slog.Error("Failed to fetch providers in background from Catwalk", "error", err)
152− return
153− }
154− if len(providers) == 0 {
155− slog.Error("Empty providers list from Catwalk")
156− return
157− }
158− if err := saveProvidersInCache(path, providers); err != nil {
159− slog.Error("Failed to update providers.json in background", "error", err)
160− }
161− }()
162− }
163−
164145 switch {
165146 case autoUpdateDisabled:
166147 slog.Warn("Providers auto-update is disabled")
@@ -177,19 +158,6 @@ func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string)
177158 }
178159 return providers, nil
179160
180− case cacheExists && !cacheIsStale:
181− slog.Info("Recent providers cache is available.", "path", path)
182−
183− providers, err := loadProvidersFromCache(path)
184− if err != nil {
185− return nil, err
186− }
187− if len(providers) == 0 {
188− return catwalkGetAndSave()
189− }
190− backgroundCacheUpdate()
191− return providers, nil
192−
193161 default:
194162 slog.Info("Cache is not available or is stale. Fetching providers from Catwalk.", "path", path)
195163
internal/config/provider_test.go+1−1
@@ -57,7 +57,7 @@ func TestProvider_loadProvidersWithIssues(t *testing.T) {
5757 if err != nil {
5858 t.Fatalf("Failed to write old providers to file: %v", err)
5959 }
60− providers, err := loadProviders(false, client, tmpPath)
60+ providers, err := loadProviders(true, client, tmpPath)
6161 require.NoError(t, err)
6262 require.NotNil(t, providers)
6363 require.Len(t, providers, 1)
internal/log/http.go+21−17
@@ -39,12 +39,14 @@ func (h *HTTPRoundTripLogger) RoundTrip(req *http.Request) (*http.Response, erro
3939 return nil, err
4040 }
4141
42− slog.Debug(
43− "HTTP Request",
44− "method", req.Method,
45− "url", req.URL,
46− "body", bodyToString(save),
47− )
42+ if slog.Default().Enabled(req.Context(), slog.LevelDebug) {
43+ slog.Debug(
44+ "HTTP Request",
45+ "method", req.Method,
46+ "url", req.URL,
47+ "body", bodyToString(save),
48+ )
49+ }
4850
4951 start := time.Now()
5052 resp, err := h.Transport.RoundTrip(req)
@@ -61,16 +63,18 @@ func (h *HTTPRoundTripLogger) RoundTrip(req *http.Request) (*http.Response, erro
6163 }
6264
6365 save, resp.Body, err = drainBody(resp.Body)
64− slog.Debug(
65− "HTTP Response",
66− "status_code", resp.StatusCode,
67− "status", resp.Status,
68− "headers", formatHeaders(resp.Header),
69− "body", bodyToString(save),
70− "content_length", resp.ContentLength,
71− "duration_ms", duration.Milliseconds(),
72− "error", err,
73− )
66+ if slog.Default().Enabled(req.Context(), slog.LevelDebug) {
67+ slog.Debug(
68+ "HTTP Response",
69+ "status_code", resp.StatusCode,
70+ "status", resp.Status,
71+ "headers", formatHeaders(resp.Header),
72+ "body", bodyToString(save),
73+ "content_length", resp.ContentLength,
74+ "duration_ms", duration.Milliseconds(),
75+ "error", err,
76+ )
77+ }
7478 return resp, err
7579}
7680
@@ -84,7 +88,7 @@ func bodyToString(body io.ReadCloser) string {
8488 return ""
8589 }
8690 var b bytes.Buffer
87− if json.Compact(&b, bytes.TrimSpace(src)) != nil {
91+ if json.Indent(&b, bytes.TrimSpace(src), "", " ") != nil {
8892 // not json probably
8993 return string(src)
9094 }
internal/lsp/client.go+10
@@ -445,6 +445,16 @@ func (c *Client) WaitForDiagnostics(ctx context.Context, d time.Duration) {
445445 }
446446}
447447
448+// FindReferences finds all references to the symbol at the given position.
449+func (c *Client) FindReferences(ctx context.Context, filepath string, line, character int, includeDeclaration bool) ([]protocol.Location, error) {
450+ if err := c.OpenFileOnDemand(ctx, filepath); err != nil {
451+ return nil, err
452+ }
453+ // NOTE: line and character should be 0-based.
454+ // See: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position
455+ return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration)
456+}
457+
448458// HasRootMarkers checks if any of the specified root marker patterns exist in the given directory.
449459// Uses glob patterns to match files, allowing for more flexible matching.
450460func HasRootMarkers(dir string, rootMarkers []string) bool {
internal/tui/components/chat/editor/editor.go+2
@@ -86,6 +86,7 @@ var DeleteKeyMaps = DeleteAttachmentKeyMaps{
8686
8787const (
8888 maxAttachments = 5
89+ maxFileResults = 25
8990)
9091
9192type OpenEditorMsg struct {
@@ -500,6 +501,7 @@ func (m *editorCmp) startCompletions() tea.Msg {
500501 Completions: completionItems,
501502 X: x,
502503 Y: y,
504+ MaxResults: maxFileResults,
503505 }
504506}
505507
internal/tui/components/chat/messages/messages.go+3−2
@@ -12,6 +12,7 @@ import (
1212 "github.com/charmbracelet/catwalk/pkg/catwalk"
1313 "github.com/charmbracelet/lipgloss/v2"
1414 "github.com/charmbracelet/x/ansi"
15+ "github.com/charmbracelet/x/exp/ordered"
1516 "github.com/google/uuid"
1617
1718 "github.com/atotto/clipboard"
@@ -274,7 +275,7 @@ func (m *messageCmp) renderThinkingContent() string {
274275 }
275276 }
276277 fullContent := content.String()
277− height := util.Clamp(lipgloss.Height(fullContent), 1, 10)
278+ height := ordered.Clamp(lipgloss.Height(fullContent), 1, 10)
278279 m.thinkingViewport.SetHeight(height)
279280 m.thinkingViewport.SetWidth(m.textWidth())
280281 m.thinkingViewport.SetContent(fullContent)
@@ -347,7 +348,7 @@ func (m *messageCmp) GetSize() (int, int) {
347348
348349// SetSize updates the width of the message component for text wrapping
349350func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
350− m.width = util.Clamp(width, 1, 120)
351+ m.width = ordered.Clamp(width, 1, 120)
351352 m.thinkingViewport.SetWidth(m.width - 4)
352353 return nil
353354}
internal/tui/components/completions/completions.go+2
@@ -22,6 +22,7 @@ type OpenCompletionsMsg struct {
2222 Completions []Completion
2323 X int // X position for the completions popup
2424 Y int // Y position for the completions popup
25+ MaxResults int // Maximum number of results to render, 0 for no limit
2526}
2627
2728type FilterCompletionsMsg struct {
@@ -192,6 +193,7 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
192193 }
193194 c.width = width
194195 c.height = max(min(maxCompletionsHeight, len(items)), 1) // Ensure at least 1 item height
196+ c.list.SetResultsSize(msg.MaxResults)
195197 return c, tea.Batch(
196198 c.list.SetItems(items),
197199 c.list.SetSize(c.width, c.height),
internal/tui/components/dialogs/commands/arguments.go+6−1
@@ -128,12 +128,17 @@ func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
128128 c.inputs[c.focusIndex].Blur()
129129 c.focusIndex = (c.focusIndex - 1 + len(c.inputs)) % len(c.inputs)
130130 c.inputs[c.focusIndex].Focus()
131−
131+ case key.Matches(msg, c.keys.Close):
132+ return c, util.CmdHandler(dialogs.CloseDialogMsg{})
132133 default:
133134 var cmd tea.Cmd
134135 c.inputs[c.focusIndex], cmd = c.inputs[c.focusIndex].Update(msg)
135136 return c, cmd
136137 }
138+ case tea.PasteMsg:
139+ var cmd tea.Cmd
140+ c.inputs[c.focusIndex], cmd = c.inputs[c.focusIndex].Update(msg)
141+ return c, cmd
137142 }
138143 return c, nil
139144}
internal/tui/components/dialogs/commands/keys.go+7
@@ -76,6 +76,7 @@ type ArgumentsDialogKeyMap struct {
7676 Confirm key.Binding
7777 Next key.Binding
7878 Previous key.Binding
79+ Close key.Binding
7980}
8081
8182func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap {
@@ -93,6 +94,10 @@ func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap {
9394 key.WithKeys("shift+tab", "up"),
9495 key.WithHelp("shift+tab/↑", "previous"),
9596 ),
97+ Close: key.NewBinding(
98+ key.WithKeys("esc", "alt+esc"),
99+ key.WithHelp("esc", "cancel"),
100+ ),
96101 }
97102}
98103
@@ -102,6 +107,7 @@ func (k ArgumentsDialogKeyMap) KeyBindings() []key.Binding {
102107 k.Confirm,
103108 k.Next,
104109 k.Previous,
110+ k.Close,
105111 }
106112}
107113
@@ -122,5 +128,6 @@ func (k ArgumentsDialogKeyMap) ShortHelp() []key.Binding {
122128 k.Confirm,
123129 k.Next,
124130 k.Previous,
131+ k.Close,
125132 }
126133}
internal/tui/components/dialogs/permissions/keys.go+1−1
@@ -42,7 +42,7 @@ func DefaultKeyMap() KeyMap {
4242 key.WithHelp("s", "allow session"),
4343 ),
4444 Deny: key.NewBinding(
45− key.WithKeys("d", "D", "ctrl+d", "esc"),
45+ key.WithKeys("d", "D", "esc"),
4646 key.WithHelp("d", "deny"),
4747 ),
4848 Select: key.NewBinding(
internal/tui/exp/list/filterable.go+28−19
@@ -3,8 +3,6 @@ package list
33import (
44 "regexp"
55 "slices"
6− "sort"
7− "strings"
86
97 "github.com/charmbracelet/bubbles/v2/key"
108 "github.com/charmbracelet/bubbles/v2/textinput"
@@ -28,7 +26,9 @@ type FilterableList[T FilterableItem] interface {
2826 Cursor() *tea.Cursor
2927 SetInputWidth(int)
3028 SetInputPlaceholder(string)
29+ SetResultsSize(int)
3130 Filter(q string) tea.Cmd
31+ fuzzy.Source
3232}
3333
3434type HasMatchIndexes interface {
@@ -47,10 +47,11 @@ type filterableList[T FilterableItem] struct {
4747 *filterableOptions
4848 width, height int
4949 // stores all available items
50− items []T
51− input textinput.Model
52− inputWidth int
53− query string
50+ items []T
51+ resultsSize int
52+ input textinput.Model
53+ inputWidth int
54+ query string
5455}
5556
5657type filterableListOption func(*filterableOptions)
@@ -246,22 +247,18 @@ func (f *filterableList[T]) Filter(query string) tea.Cmd {
246247 return f.list.SetItems(f.items)
247248 }
248249
249− words := make([]string, len(f.items))
250− for i, item := range f.items {
251− words[i] = strings.ToLower(item.FilterValue())
252− }
253−
254− matches := fuzzy.Find(query, words)
255−
256− sort.SliceStable(matches, func(i, j int) bool {
257− return matches[i].Score > matches[j].Score
258− })
250+ matches := fuzzy.FindFrom(query, f)
259251
260252 var matchedItems []T
261− for _, match := range matches {
253+ resultSize := len(matches)
254+ if f.resultsSize > 0 && resultSize > f.resultsSize {
255+ resultSize = f.resultsSize
256+ }
257+ for i := range resultSize {
258+ match := matches[i]
262259 item := f.items[match.Index]
263− if i, ok := any(item).(HasMatchIndexes); ok {
264− i.MatchIndexes(match.MatchedIndexes)
260+ if it, ok := any(item).(HasMatchIndexes); ok {
261+ it.MatchIndexes(match.MatchedIndexes)
265262 }
266263 matchedItems = append(matchedItems, item)
267264 }
@@ -307,3 +304,15 @@ func (f *filterableList[T]) SetInputWidth(w int) {
307304func (f *filterableList[T]) SetInputPlaceholder(ph string) {
308305 f.placeholder = ph
309306}
307+
308+func (f *filterableList[T]) SetResultsSize(size int) {
309+ f.resultsSize = size
310+}
311+
312+func (f *filterableList[T]) String(i int) string {
313+ return f.items[i].FilterValue()
314+}
315+
316+func (f *filterableList[T]) Len() int {
317+ return len(f.items)
318+}
internal/tui/exp/list/list.go+3−2
@@ -15,6 +15,7 @@ import (
1515 "github.com/charmbracelet/lipgloss/v2"
1616 uv "github.com/charmbracelet/ultraviolet"
1717 "github.com/charmbracelet/x/ansi"
18+ "github.com/charmbracelet/x/exp/ordered"
1819 "github.com/rivo/uniseg"
1920)
2021
@@ -1283,14 +1284,14 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
12831284 newItem, ok := l.renderedItems.Get(item.ID())
12841285 if ok {
12851286 newLines := newItem.height - oldItem.height
1286− l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
1287+ l.offset = ordered.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
12871288 }
12881289 }
12891290 } else if hasOldItem && l.offset > oldItem.start {
12901291 newItem, ok := l.renderedItems.Get(item.ID())
12911292 if ok {
12921293 newLines := newItem.height - oldItem.height
1293− l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
1294+ l.offset = ordered.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
12941295 }
12951296 }
12961297 }
internal/tui/util/util.go−7
@@ -60,10 +60,3 @@ type (
6060 }
6161 ClearStatusMsg struct{}
6262)
63−
64−func Clamp(v, low, high int) int {
65− if high < low {
66− low, high = high, low
67− }
68− return min(high, max(low, v))
69−}