on main help sign in
Merge remote-tracking branch 'origin/main' into crush-fantasy
0cb2b2ckujtimiihoxha
.github/cla-signatures.json+16
@@ -711,6 +711,22 @@
711 "created_at": "2025-10-13T05:56:20Z",
711 "created_at": "2025-10-13T05:56:20Z",
712 "repoId": 987670088,
712 "repoId": 987670088,
713 "pullRequestNo": 1223
713 "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
714 }
730 }
715 ]
731 ]
716}
732}
.goreleaser.yml+1
@@ -303,6 +303,7 @@ changelog:
303 - "^docs: update$"
303 - "^docs: update$"
304 - "^test:"
304 - "^test:"
305 - "^test\\("
305 - "^test\\("
306+ - "^v\\d.*"
306 - "merge conflict"
307 - "merge conflict"
307 - "merge conflict"
308 - "merge conflict"
308 - Merge branch
309 - Merge branch
CRUSH.md+1−1
@@ -54,7 +54,7 @@ func TestYourFunction(t *testing.T) {
54## Formatting
54## Formatting
55
55
56- ALWAYS format any Go code you write.
56- ALWAYS format any Go code you write.
57− - First, try `goftumpt -w .`.
57+ - First, try `gofumpt -w .`.
58 - If `gofumpt` is not available, use `goimports`.
58 - If `gofumpt` is not available, use `goimports`.
59 - If `goimports` is not available, use `gofmt`.
59 - If `goimports` is not available, use `gofmt`.
60 - You can also use `task fmt` to run `gofumpt -w .` on the entire project,
60 - 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.
189| `AWS_ACCESS_KEY_ID` | AWS Bedrock (Claude) |
189| `AWS_ACCESS_KEY_ID` | AWS Bedrock (Claude) |
190| `AWS_SECRET_ACCESS_KEY` | AWS Bedrock (Claude) |
190| `AWS_SECRET_ACCESS_KEY` | AWS Bedrock (Claude) |
191| `AWS_REGION` | AWS Bedrock (Claude) |
191| `AWS_REGION` | AWS Bedrock (Claude) |
192−| `AWS_PROFILE` | Custom AWS Profile |
192+| `AWS_PROFILE` | AWS Bedrock (Custom Profile) |
193−| `AWS_REGION` | AWS Region |
193+| `AWS_BEARER_TOKEN_BEDROCK` | AWS Bedrock |
194| `AZURE_OPENAI_API_ENDPOINT` | Azure OpenAI models |
194| `AZURE_OPENAI_API_ENDPOINT` | Azure OpenAI models |
195| `AZURE_OPENAI_API_KEY` | Azure OpenAI models (optional when using Entra ID) |
195| `AZURE_OPENAI_API_KEY` | Azure OpenAI models (optional when using Entra ID) |
196| `AZURE_OPENAI_API_VERSION` | Azure OpenAI models |
196| `AZURE_OPENAI_API_VERSION` | Azure OpenAI models |
@@ -479,6 +479,7 @@ Crush currently supports running Anthropic models through Bedrock, with caching
479- A Bedrock provider will appear once you have AWS configured, i.e. `aws configure`
479- A Bedrock provider will appear once you have AWS configured, i.e. `aws configure`
480- Crush also expects the `AWS_REGION` or `AWS_DEFAULT_REGION` to be set
480- Crush also expects the `AWS_REGION` or `AWS_DEFAULT_REGION` to be set
481- To use a specific AWS profile set `AWS_PROFILE` in your environment, i.e. `AWS_PROFILE=myprofile crush`
481- 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`
482
483
483### Vertex AI Platform
484### Vertex AI Platform
484
485
@@ -649,8 +650,8 @@ See the [contributing guide](https://github.com/charmbracelet/crush?tab=contribu
649We’d love to hear your thoughts on this project. Need help? We gotchu. You can find us on:
650We’d love to hear your thoughts on this project. Need help? We gotchu. You can find us on:
650
651
651- [Twitter](https://twitter.com/charmcli)
652- [Twitter](https://twitter.com/charmcli)
652−- [Discord][discord]
653- [Slack](https://charm.land/slack)
653- [Slack](https://charm.land/slack)
654+- [Discord][discord]
654- [The Fediverse](https://mastodon.social/@charmcli)
655- [The Fediverse](https://mastodon.social/@charmcli)
655- [Bluesky](https://bsky.app/profile/charm.land)
656- [Bluesky](https://bsky.app/profile/charm.land)
656
657
Taskfile.yaml+12−4
@@ -2,6 +2,10 @@
2
2
3version: "3"
3version: "3"
4
4
5+vars:
6+ VERSION:
7+ sh: git describe --long 2>/dev/null || echo ""
8+
5env:
9env:
6 CGO_ENABLED: 0
10 CGO_ENABLED: 0
7 GOEXPERIMENT: greenteagc
11 GOEXPERIMENT: greenteagc
@@ -30,8 +34,10 @@ tasks:
30
34
31 build:
35 build:
32 desc: Run build
36 desc: Run build
37+ vars:
38+ LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}'
33 cmds:
39 cmds:
34− - go build .
40+ - go build {{.LDFLAGS}} .
35 generates:
41 generates:
36 - crush
42 - crush
37
43
@@ -59,8 +65,10 @@ tasks:
59
65
60 install:
66 install:
61 desc: Install the application
67 desc: Install the application
68+ vars:
69+ LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}'
62 cmds:
70 cmds:
63− - go install -v .
71+ - go install {{.LDFLAGS}} -v .
64
72
65 profile:cpu:
73 profile:cpu:
66 desc: 10s CPU profile
74 desc: 10s CPU profile
@@ -99,9 +107,9 @@ tasks:
99 cmds:
107 cmds:
100 - task: fetch-tags
108 - task: fetch-tags
101 - git commit --allow-empty -m "{{.NEXT}}"
109 - git commit --allow-empty -m "{{.NEXT}}"
102− - git tag --annotate --sign {{.NEXT}} {{.CLI_ARGS}}
110+ - git tag --annotate --sign -m "{{.NEXT}}" {{.NEXT}} {{.CLI_ARGS}}
103 - echo "Pushing {{.NEXT}}..."
111 - echo "Pushing {{.NEXT}}..."
104− - git push origin --tags
112+ - git push origin main --follow-tags
105
113
106 fetch-tags:
114 fetch-tags:
107 cmds:
115 cmds:
go.mod+3−2
@@ -22,6 +22,9 @@ require (
22 github.com/charmbracelet/x/ansi v0.10.2
22 github.com/charmbracelet/x/ansi v0.10.2
23 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
23 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
24 github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a
24 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
25 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
28 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
26 github.com/google/uuid v1.6.0
29 github.com/google/uuid v1.6.0
27 github.com/invopop/jsonschema v0.13.0
30 github.com/invopop/jsonschema v0.13.0
@@ -82,8 +85,6 @@ require (
82 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a // indirect
85 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a // indirect
83 github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5
86 github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5
84 github.com/charmbracelet/x/json v0.2.0 // indirect
87 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
87 github.com/charmbracelet/x/termios v0.1.1 // indirect
88 github.com/charmbracelet/x/termios v0.1.1 // indirect
88 github.com/charmbracelet/x/windows v0.2.2 // indirect
89 github.com/charmbracelet/x/windows v0.2.2 // indirect
89 github.com/davecgh/go-spew v1.1.1 // indirect
90 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
104github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
104github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
105github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
105github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
106github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
106github.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=
107github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 h1:DTSZxdV9qQagD4iGcAt9RgaRBZtJl01bfKgdLzUzUPI=
109github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 h1:DTSZxdV9qQagD4iGcAt9RgaRBZtJl01bfKgdLzUzUPI=
108github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
110github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
109github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
111github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
110github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
112github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
111−github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4 h1:ZhDGU688EHQXslD9KphRpXwK0pKP03egUoZAATUDlV0=
113+github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc=
112−github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
114+github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
113github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
115github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
114github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
116github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
115github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
117github.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
305 tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
305 tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
306 )
306 )
307
307
308+ if len(c.cfg.LSP) > 0 {
309+ allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients))
310+ }
311+
308 var filteredTools []fantasy.AgentTool
312 var filteredTools []fantasy.AgentTool
309 for _, tool := range allTools {
313 for _, tool := range allTools {
310 if slices.Contains(agent.AllowedTools, tool.Info().Name) {
314 if slices.Contains(agent.AllowedTools, tool.Info().Name) {
@@ -337,7 +341,9 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
337 }
341 }
338 }
342 }
339 }
343 }
340−
344+ slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
345+ return strings.Compare(a.Info().Name, b.Info().Name)
346+ })
341 return filteredTools, nil
347 return filteredTools, nil
342}
348}
343
349
internal/agent/tools/diagnostics.go+1−1
@@ -19,7 +19,7 @@ type DiagnosticsParams struct {
19 FilePath string `json:"file_path,omitempty" description:"The path to the file to get diagnostics for (leave w empty for project diagnostics)"`
19 FilePath string `json:"file_path,omitempty" description:"The path to the file to get diagnostics for (leave w empty for project diagnostics)"`
20}
20}
21
21
22−const DiagnosticsToolName = "diagnostics"
22+const DiagnosticsToolName = "lsp_diagnostics"
23
23
24//go:embed diagnostics.md
24//go:embed diagnostics.md
25var diagnosticsDescription []byte
25var diagnosticsDescription []byte
internal/agent/tools/grep.go+78−99
@@ -2,16 +2,18 @@ package tools
2
2
3import (
3import (
4 "bufio"
4 "bufio"
5+ "bytes"
5 "context"
6 "context"
6 _ "embed"
7 _ "embed"
8+ "encoding/json"
7 "fmt"
9 "fmt"
8 "io"
10 "io"
11+ "net/http"
9 "os"
12 "os"
10 "os/exec"
13 "os/exec"
11 "path/filepath"
14 "path/filepath"
12 "regexp"
15 "regexp"
13 "sort"
16 "sort"
14− "strconv"
15 "strings"
17 "strings"
16 "sync"
18 "sync"
17 "time"
19 "time"
@@ -81,6 +83,7 @@ type grepMatch struct {
81 path string
83 path string
82 modTime time.Time
84 modTime time.Time
83 lineNum int
85 lineNum int
86+ charNum int
84 lineText string
87 lineText string
85}
88}
86
89
@@ -97,6 +100,18 @@ const (
97//go:embed grep.md
100//go:embed grep.md
98var grepDescription []byte
101var grepDescription []byte
99
102
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+
100func NewGrepTool(workingDir string) fantasy.AgentTool {
115func NewGrepTool(workingDir string) fantasy.AgentTool {
101 return fantasy.NewAgentTool(
116 return fantasy.NewAgentTool(
102 GrepToolName,
117 GrepToolName,
@@ -142,7 +157,11 @@ func NewGrepTool(workingDir string) fantasy.AgentTool {
142 if len(lineText) > maxGrepContentWidth {
157 if len(lineText) > maxGrepContentWidth {
143 lineText = lineText[:maxGrepContentWidth] + "..."
158 lineText = lineText[:maxGrepContentWidth] + "..."
144 }
159 }
160+ if match.charNum > 0 {
161+ fmt.Fprintf(&output, " Line %d, Char %d: %s\n", match.lineNum, match.charNum, lineText)
162+ } else {
145− fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, lineText)
163+ fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, lineText)
164+ }
146 } else {
165 } else {
147 fmt.Fprintf(&output, " %s\n", match.path)
166 fmt.Fprintf(&output, " %s\n", match.path)
148 }
167 }
@@ -163,18 +182,6 @@ func NewGrepTool(workingDir string) fantasy.AgentTool {
163 })
182 })
164}
183}
165
184
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−
178func searchFiles(ctx context.Context, pattern, rootPath, include string, limit int) ([]grepMatch, bool, error) {
185func searchFiles(ctx context.Context, pattern, rootPath, include string, limit int) ([]grepMatch, bool, error) {
179 matches, err := searchWithRipgrep(ctx, pattern, rootPath, include)
186 matches, err := searchWithRipgrep(ctx, pattern, rootPath, include)
180 if err != nil {
187 if err != nil {
@@ -218,66 +225,51 @@ func searchWithRipgrep(ctx context.Context, pattern, path, include string) ([]gr
218 return nil, err
225 return nil, err
219 }
226 }
220
227
221− lines := strings.Split(strings.TrimSpace(string(output)), "\n")
228+ var matches []grepMatch
222− matches := make([]grepMatch, 0, len(lines))
229+ for line := range bytes.SplitSeq(bytes.TrimSpace(output), []byte{'\n'}) {
223−
224− for _, line := range lines {
225− if line == "" {
230+ if len(line) == 0 {
226 continue
231 continue
227 }
232 }
228−
233+ var match ripgrepMatch
229− // Parse ripgrep output using null separation
234+ if err := json.Unmarshal(line, &match); err != nil {
230− filePath, lineNumStr, lineText, ok := parseRipgrepLine(line)
231− if !ok {
232 continue
235 continue
233 }
236 }
234−
237+ if match.Type != "match" {
235− lineNum, err := strconv.Atoi(lineNumStr)
236− if err != nil {
237 continue
238 continue
238 }
239 }
239−
240+ for _, m := range match.Data.Submatches {
240− fileInfo, err := os.Stat(filePath)
241+ fi, err := os.Stat(match.Data.Path.Text)
241− if err != nil {
242+ if err != nil {
242− continue // Skip files we can't access
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
243 }
254 }
244−
245− matches = append(matches, grepMatch{
246− path: filePath,
247− modTime: fileInfo.ModTime(),
248− lineNum: lineNum,
249− lineText: lineText,
250− })
251 }
255 }
252−
253 return matches, nil
256 return matches, nil
254}
257}
255
258
256−// parseRipgrepLine parses ripgrep output with null separation to handle Windows paths
259+type ripgrepMatch struct {
257−func parseRipgrepLine(line string) (filePath, lineNum, lineText string, ok bool) {
260+ Type string `json:"type"`
258− // Split on null byte first to separate filename from rest
261+ Data struct {
259− parts := strings.SplitN(line, "\x00", 2)
262+ Path struct {
260− if len(parts) != 2 {
263+ Text string `json:"text"`
261− return "", "", "", false
264+ } `json:"path"`
262− }
265+ Lines struct {
263−
266+ Text string `json:"text"`
264− filePath = parts[0]
267+ } `json:"lines"`
265− remainder := parts[1]
268+ LineNumber int `json:"line_number"`
266−
269+ Submatches []struct {
267− // Now split the remainder on first colon: "linenum:content"
270+ Start int `json:"start"`
268− colonIndex := strings.Index(remainder, ":")
271+ } `json:"submatches"`
269− if colonIndex == -1 {
272+ } `json:"data"`
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
281}
273}
282
274
283func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) {
275func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) {
@@ -329,7 +321,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
329 return nil
321 return nil
330 }
322 }
331
323
332− match, lineNum, lineText, err := fileContainsPattern(path, regex)
324+ match, lineNum, charNum, lineText, err := fileContainsPattern(path, regex)
333 if err != nil {
325 if err != nil {
334 return nil // Skip files we can't read
326 return nil // Skip files we can't read
335 }
327 }
@@ -339,6 +331,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
339 path: path,
331 path: path,
340 modTime: info.ModTime(),
332 modTime: info.ModTime(),
341 lineNum: lineNum,
333 lineNum: lineNum,
334+ charNum: charNum,
342 lineText: lineText,
335 lineText: lineText,
343 })
336 })
344
337
@@ -356,15 +349,15 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
356 return matches, nil
349 return matches, nil
357}
350}
358
351
359−func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, string, error) {
352+func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, int, string, error) {
360− // Quick binary file detection
353+ // Only search text files.
361− if isBinaryFile(filePath) {
354+ if !isTextFile(filePath) {
362− return false, 0, "", nil
355+ return false, 0, 0, "", nil
363 }
356 }
364
357
365 file, err := os.Open(filePath)
358 file, err := os.Open(filePath)
366 if err != nil {
359 if err != nil {
367− return false, 0, "", err
360+ return false, 0, 0, "", err
368 }
361 }
369 defer file.Close()
362 defer file.Close()
370
363
@@ -373,53 +366,39 @@ func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, st
373 for scanner.Scan() {
366 for scanner.Scan() {
374 lineNum++
367 lineNum++
375 line := scanner.Text()
368 line := scanner.Text()
376− if pattern.MatchString(line) {
369+ if loc := pattern.FindStringIndex(line); loc != nil {
370+ charNum := loc[0] + 1
377− return true, lineNum, line, nil
371+ return true, lineNum, charNum, line, nil
378 }
372 }
379 }
373 }
380
374
381− return false, 0, "", scanner.Err()
375+ return false, 0, 0, "", scanner.Err()
382}
376}
383
377
384−var binaryExts = map[string]struct{}{
378+// isTextFile checks if a file is a text file by examining its MIME type.
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 {
379+func isTextFile(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
402 file, err := os.Open(filePath)
380 file, err := os.Open(filePath)
403 if err != nil {
381 if err != nil {
404− return false // If we can't open it, let the caller handle the error
382+ return false
405 }
383 }
406 defer file.Close()
384 defer file.Close()
407
385
408− // Read first 512 bytes to check for null bytes
386+ // Read first 512 bytes for MIME type detection.
409 buffer := make([]byte, 512)
387 buffer := make([]byte, 512)
410 n, err := file.Read(buffer)
388 n, err := file.Read(buffer)
411 if err != nil && err != io.EOF {
389 if err != nil && err != io.EOF {
412 return false
390 return false
413 }
391 }
414
392
415− // Check for null bytes (common in binary files)
393+ // Detect content type.
416− for i := range n {
394+ contentType := http.DetectContentType(buffer[:n])
417− if buffer[i] == 0 {
418− return true
419− }
420− }
421
395
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"
423}
402}
424
403
425func globToRegex(glob string) string {
404func globToRegex(glob string) string {
internal/agent/tools/grep_test.go+221
@@ -198,3 +198,224 @@ func BenchmarkRegexCacheVsCompile(b *testing.B) {
198 }
198 }
199 })
199 })
200}
200}
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
115 }
115 }
116
116
117 depth, limit := lsConfig.Limits()
117 depth, limit := lsConfig.Limits()
118− maxFiles := min(limit, maxLSFiles)
118+ maxFiles := cmp.Or(limit, maxLSFiles)
119 files, truncated, err := fsext.ListDirectory(
119 files, truncated, err := fsext.ListDirectory(
120 searchPath,
120 searchPath,
121 params.Ignore,
121 params.Ignore,
internal/agent/tools/mcp-tools.go+62−9
@@ -10,6 +10,7 @@ import (
10 "log/slog"
10 "log/slog"
11 "maps"
11 "maps"
12 "net/http"
12 "net/http"
13+ "os"
13 "os/exec"
14 "os/exec"
14 "slices"
15 "slices"
15 "strings"
16 "strings"
@@ -116,15 +117,26 @@ func (m *McpTool) MCPToolName() string {
116}
117}
117
118
118func (b *McpTool) Info() fantasy.ToolInfo {
119func (b *McpTool) Info() fantasy.ToolInfo {
120+ parameters := make(map[string]any)
121+ required := make([]string, 0)
122+
119− input := b.tool.InputSchema.(map[string]any)
123+ if input, ok := b.tool.InputSchema.(map[string]any); ok {
120− required, _ := input["required"].([]string)
121− if required == nil {
122− required = make([]string, 0)
123− }
124− parameters, _ := input["properties"].(map[string]any)
124+ if props, ok := input["properties"].(map[string]any); ok {
125− if parameters == nil {
125+ parameters = props
126− parameters = make(map[string]any)
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+ }
127 }
138 }
139+
128 return fantasy.ToolInfo{
140 return fantasy.ToolInfo{
129 Name: fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name),
141 Name: fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name),
130 Description: b.tool.Description,
142 Description: b.tool.Description,
@@ -322,6 +334,10 @@ func GetMCPTools(ctx context.Context, permissions permission.Service, cfg *confi
322 slog.Error("panic in mcp client initialization", "error", err, "name", name)
334 slog.Error("panic in mcp client initialization", "error", err, "name", name)
323 }
335 }
324 }()
336 }()
337+
338+ ctx, cancel := context.WithTimeout(ctx, mcpTimeout(m))
339+ defer cancel()
340+
325 c, err := createMCPSession(ctx, name, m, cfg.Resolver())
341 c, err := createMCPSession(ctx, name, m, cfg.Resolver())
326 if err != nil {
342 if err != nil {
327 return
343 return
@@ -370,6 +386,8 @@ func createMCPSession(ctx context.Context, name string, m config.MCPConfig, reso
370 if err != nil {
386 if err != nil {
371 updateMCPState(name, MCPStateError, err, nil, 0)
387 updateMCPState(name, MCPStateError, err, nil, 0)
372 slog.Error("error creating mcp client", "error", err, "name", name)
388 slog.Error("error creating mcp client", "error", err, "name", name)
389+ cancel()
390+ cancelTimer.Stop()
373 return nil, err
391 return nil, err
374 }
392 }
375
393
@@ -392,9 +410,11 @@ func createMCPSession(ctx context.Context, name string, m config.MCPConfig, reso
392
410
393 session, err := client.Connect(mcpCtx, transport, nil)
411 session, err := client.Connect(mcpCtx, transport, nil)
394 if err != nil {
412 if err != nil {
413+ err = maybeStdioErr(err, transport)
395 updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0)
414 updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0)
396 slog.Error("error starting mcp client", "error", err, "name", name)
415 slog.Error("error starting mcp client", "error", err, "name", name)
397 cancel()
416 cancel()
417+ cancelTimer.Stop()
398 return nil, err
418 return nil, err
399 }
419 }
400
420
@@ -403,6 +423,27 @@ func createMCPSession(ctx context.Context, name string, m config.MCPConfig, reso
403 return session, nil
423 return session, nil
404}
424}
405
425
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+
406func maybeTimeoutErr(err error, timeout time.Duration) error {
447func maybeTimeoutErr(err error, timeout time.Duration) error {
407 if errors.Is(err, context.Canceled) {
448 if errors.Is(err, context.Canceled) {
408 return fmt.Errorf("timed out after %s", timeout)
449 return fmt.Errorf("timed out after %s", timeout)
@@ -421,7 +462,7 @@ func createMCPTransport(ctx context.Context, m config.MCPConfig, resolver config
421 return nil, fmt.Errorf("mcp stdio config requires a non-empty 'command' field")
462 return nil, fmt.Errorf("mcp stdio config requires a non-empty 'command' field")
422 }
463 }
423 cmd := exec.CommandContext(ctx, home.Long(command), m.Args...)
464 cmd := exec.CommandContext(ctx, home.Long(command), m.Args...)
424− cmd.Env = m.ResolvedEnv()
465+ cmd.Env = append(os.Environ(), m.ResolvedEnv()...)
425 return &mcp.CommandTransport{
466 return &mcp.CommandTransport{
426 Command: cmd,
467 Command: cmd,
427 }, nil
468 }, nil
@@ -470,3 +511,15 @@ func (rt headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error
470func mcpTimeout(m config.MCPConfig) time.Duration {
511func mcpTimeout(m config.MCPConfig) time.Duration {
471 return time.Duration(cmp.Or(m.Timeout, 15)) * time.Second
512 return time.Duration(cmp.Or(m.Timeout, 15)) * time.Second
472}
513}
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
43 return nil
43 return nil
44 }
44 }
45 // Use -n to show line numbers, -0 for null separation to handle Windows paths
45 // 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}
47 if include != "" {
47 if include != "" {
48 args = append(args, "--glob", include)
48 args = append(args, "--glob", include)
49 }
49 }
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 {
156}
156}
157
157
158func (c Completions) Limits() (depth, items int) {
158func (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)
160}
160}
161
161
162type Permissions struct {
162type Permissions struct {
@@ -278,7 +278,7 @@ type ToolLs struct {
278}
278}
279
279
280func (t ToolLs) Limits() (depth, items int) {
280func (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)
282}
282}
283
283
284// Config holds the configuration for crush.
284// Config holds the configuration for crush.
@@ -301,10 +301,10 @@ type Config struct {
301
301
302 Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"`
302 Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"`
303
303
304+ Agents map[string]Agent `json:"-"`
305+
304 // Internal
306 // Internal
305 workingDir string `json:"-"`
307 workingDir string `json:"-"`
306− // TODO: most likely remove this concept when I come back to it
307− Agents map[string]Agent `json:"-"`
308 // TODO: find a better way to do this this should probably not be part of the config
308 // TODO: find a better way to do this this should probably not be part of the config
309 resolver VariableResolver
309 resolver VariableResolver
310 dataConfigDir string `json:"-"`
310 dataConfigDir string `json:"-"`
@@ -470,6 +470,8 @@ func allToolNames() []string {
470 "download",
470 "download",
471 "edit",
471 "edit",
472 "multiedit",
472 "multiedit",
473+ "lsp_diagnostics",
474+ "lsp_references",
473 "fetch",
475 "fetch",
474 "glob",
476 "glob",
475 "grep",
477 "grep",
internal/config/load.go+9
@@ -607,6 +607,10 @@ func hasVertexCredentials(env env.Env) bool {
607}
607}
608
608
609func hasAWSCredentials(env env.Env) bool {
609func hasAWSCredentials(env env.Env) bool {
610+ if env.Get("AWS_BEARER_TOKEN_BEDROCK") != "" {
611+ return true
612+ }
613+
610 if env.Get("AWS_ACCESS_KEY_ID") != "" && env.Get("AWS_SECRET_ACCESS_KEY") != "" {
614 if env.Get("AWS_ACCESS_KEY_ID") != "" && env.Get("AWS_SECRET_ACCESS_KEY") != "" {
611 return true
615 return true
612 }
616 }
@@ -623,6 +627,11 @@ func hasAWSCredentials(env env.Env) bool {
623 env.Get("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
627 env.Get("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
624 return true
628 return true
625 }
629 }
630+
631+ if _, err := os.Stat(filepath.Join(home.Dir(), ".aws/credentials")); err == nil {
632+ return true
633+ }
634+
626 return false
635 return false
627}
636}
628
637
internal/config/provider.go+1−33
@@ -126,7 +126,7 @@ func Providers(cfg *Config) ([]catwalk.Provider, error) {
126}
126}
127
127
128func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string) ([]catwalk.Provider, error) {
128func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string) ([]catwalk.Provider, error) {
129− cacheIsStale, cacheExists := isCacheStale(path)
129+ _, cacheExists := isCacheStale(path)
130
130
131 catwalkGetAndSave := func() ([]catwalk.Provider, error) {
131 catwalkGetAndSave := func() ([]catwalk.Provider, error) {
132 providers, err := client.GetProviders()
132 providers, err := client.GetProviders()
@@ -142,25 +142,6 @@ func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string)
142 return providers, nil
142 return providers, nil
143 }
143 }
144
144
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−
164 switch {
145 switch {
165 case autoUpdateDisabled:
146 case autoUpdateDisabled:
166 slog.Warn("Providers auto-update is disabled")
147 slog.Warn("Providers auto-update is disabled")
@@ -177,19 +158,6 @@ func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string)
177 }
158 }
178 return providers, nil
159 return providers, nil
179
160
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−
193 default:
161 default:
194 slog.Info("Cache is not available or is stale. Fetching providers from Catwalk.", "path", path)
162 slog.Info("Cache is not available or is stale. Fetching providers from Catwalk.", "path", path)
195
163
internal/config/provider_test.go+1−1
@@ -57,7 +57,7 @@ func TestProvider_loadProvidersWithIssues(t *testing.T) {
57 if err != nil {
57 if err != nil {
58 t.Fatalf("Failed to write old providers to file: %v", err)
58 t.Fatalf("Failed to write old providers to file: %v", err)
59 }
59 }
60− providers, err := loadProviders(false, client, tmpPath)
60+ providers, err := loadProviders(true, client, tmpPath)
61 require.NoError(t, err)
61 require.NoError(t, err)
62 require.NotNil(t, providers)
62 require.NotNil(t, providers)
63 require.Len(t, providers, 1)
63 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
39 return nil, err
39 return nil, err
40 }
40 }
41
41
42+ if slog.Default().Enabled(req.Context(), slog.LevelDebug) {
42− slog.Debug(
43+ slog.Debug(
43− "HTTP Request",
44+ "HTTP Request",
44− "method", req.Method,
45+ "method", req.Method,
45− "url", req.URL,
46+ "url", req.URL,
46− "body", bodyToString(save),
47+ "body", bodyToString(save),
47− )
48+ )
49+ }
48
50
49 start := time.Now()
51 start := time.Now()
50 resp, err := h.Transport.RoundTrip(req)
52 resp, err := h.Transport.RoundTrip(req)
@@ -61,16 +63,18 @@ func (h *HTTPRoundTripLogger) RoundTrip(req *http.Request) (*http.Response, erro
61 }
63 }
62
64
63 save, resp.Body, err = drainBody(resp.Body)
65 save, resp.Body, err = drainBody(resp.Body)
66+ if slog.Default().Enabled(req.Context(), slog.LevelDebug) {
64− slog.Debug(
67+ slog.Debug(
65− "HTTP Response",
68+ "HTTP Response",
66− "status_code", resp.StatusCode,
69+ "status_code", resp.StatusCode,
67− "status", resp.Status,
70+ "status", resp.Status,
68− "headers", formatHeaders(resp.Header),
71+ "headers", formatHeaders(resp.Header),
69− "body", bodyToString(save),
72+ "body", bodyToString(save),
70− "content_length", resp.ContentLength,
73+ "content_length", resp.ContentLength,
71− "duration_ms", duration.Milliseconds(),
74+ "duration_ms", duration.Milliseconds(),
72− "error", err,
75+ "error", err,
73− )
76+ )
77+ }
74 return resp, err
78 return resp, err
75}
79}
76
80
@@ -84,7 +88,7 @@ func bodyToString(body io.ReadCloser) string {
84 return ""
88 return ""
85 }
89 }
86 var b bytes.Buffer
90 var b bytes.Buffer
87− if json.Compact(&b, bytes.TrimSpace(src)) != nil {
91+ if json.Indent(&b, bytes.TrimSpace(src), "", " ") != nil {
88 // not json probably
92 // not json probably
89 return string(src)
93 return string(src)
90 }
94 }
internal/lsp/client.go+10
@@ -445,6 +445,16 @@ func (c *Client) WaitForDiagnostics(ctx context.Context, d time.Duration) {
445 }
445 }
446}
446}
447
447
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+
448// HasRootMarkers checks if any of the specified root marker patterns exist in the given directory.
458// HasRootMarkers checks if any of the specified root marker patterns exist in the given directory.
449// Uses glob patterns to match files, allowing for more flexible matching.
459// Uses glob patterns to match files, allowing for more flexible matching.
450func HasRootMarkers(dir string, rootMarkers []string) bool {
460func HasRootMarkers(dir string, rootMarkers []string) bool {
internal/tui/components/chat/editor/editor.go+2
@@ -86,6 +86,7 @@ var DeleteKeyMaps = DeleteAttachmentKeyMaps{
86
86
87const (
87const (
88 maxAttachments = 5
88 maxAttachments = 5
89+ maxFileResults = 25
89)
90)
90
91
91type OpenEditorMsg struct {
92type OpenEditorMsg struct {
@@ -500,6 +501,7 @@ func (m *editorCmp) startCompletions() tea.Msg {
500 Completions: completionItems,
501 Completions: completionItems,
501 X: x,
502 X: x,
502 Y: y,
503 Y: y,
504+ MaxResults: maxFileResults,
503 }
505 }
504}
506}
505
507
internal/tui/components/chat/messages/messages.go+3−2
@@ -12,6 +12,7 @@ import (
12 "github.com/charmbracelet/catwalk/pkg/catwalk"
12 "github.com/charmbracelet/catwalk/pkg/catwalk"
13 "github.com/charmbracelet/lipgloss/v2"
13 "github.com/charmbracelet/lipgloss/v2"
14 "github.com/charmbracelet/x/ansi"
14 "github.com/charmbracelet/x/ansi"
15+ "github.com/charmbracelet/x/exp/ordered"
15 "github.com/google/uuid"
16 "github.com/google/uuid"
16
17
17 "github.com/atotto/clipboard"
18 "github.com/atotto/clipboard"
@@ -274,7 +275,7 @@ func (m *messageCmp) renderThinkingContent() string {
274 }
275 }
275 }
276 }
276 fullContent := content.String()
277 fullContent := content.String()
277− height := util.Clamp(lipgloss.Height(fullContent), 1, 10)
278+ height := ordered.Clamp(lipgloss.Height(fullContent), 1, 10)
278 m.thinkingViewport.SetHeight(height)
279 m.thinkingViewport.SetHeight(height)
279 m.thinkingViewport.SetWidth(m.textWidth())
280 m.thinkingViewport.SetWidth(m.textWidth())
280 m.thinkingViewport.SetContent(fullContent)
281 m.thinkingViewport.SetContent(fullContent)
@@ -347,7 +348,7 @@ func (m *messageCmp) GetSize() (int, int) {
347
348
348// SetSize updates the width of the message component for text wrapping
349// SetSize updates the width of the message component for text wrapping
349func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
350func (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)
351 m.thinkingViewport.SetWidth(m.width - 4)
352 m.thinkingViewport.SetWidth(m.width - 4)
352 return nil
353 return nil
353}
354}
internal/tui/components/completions/completions.go+2
@@ -22,6 +22,7 @@ type OpenCompletionsMsg struct {
22 Completions []Completion
22 Completions []Completion
23 X int // X position for the completions popup
23 X int // X position for the completions popup
24 Y int // Y position for the completions popup
24 Y int // Y position for the completions popup
25+ MaxResults int // Maximum number of results to render, 0 for no limit
25}
26}
26
27
27type FilterCompletionsMsg struct {
28type FilterCompletionsMsg struct {
@@ -192,6 +193,7 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
192 }
193 }
193 c.width = width
194 c.width = width
194 c.height = max(min(maxCompletionsHeight, len(items)), 1) // Ensure at least 1 item height
195 c.height = max(min(maxCompletionsHeight, len(items)), 1) // Ensure at least 1 item height
196+ c.list.SetResultsSize(msg.MaxResults)
195 return c, tea.Batch(
197 return c, tea.Batch(
196 c.list.SetItems(items),
198 c.list.SetItems(items),
197 c.list.SetSize(c.width, c.height),
199 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) {
128 c.inputs[c.focusIndex].Blur()
128 c.inputs[c.focusIndex].Blur()
129 c.focusIndex = (c.focusIndex - 1 + len(c.inputs)) % len(c.inputs)
129 c.focusIndex = (c.focusIndex - 1 + len(c.inputs)) % len(c.inputs)
130 c.inputs[c.focusIndex].Focus()
130 c.inputs[c.focusIndex].Focus()
131−
131+ case key.Matches(msg, c.keys.Close):
132+ return c, util.CmdHandler(dialogs.CloseDialogMsg{})
132 default:
133 default:
133 var cmd tea.Cmd
134 var cmd tea.Cmd
134 c.inputs[c.focusIndex], cmd = c.inputs[c.focusIndex].Update(msg)
135 c.inputs[c.focusIndex], cmd = c.inputs[c.focusIndex].Update(msg)
135 return c, cmd
136 return c, cmd
136 }
137 }
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
137 }
142 }
138 return c, nil
143 return c, nil
139}
144}
internal/tui/components/dialogs/commands/keys.go+7
@@ -76,6 +76,7 @@ type ArgumentsDialogKeyMap struct {
76 Confirm key.Binding
76 Confirm key.Binding
77 Next key.Binding
77 Next key.Binding
78 Previous key.Binding
78 Previous key.Binding
79+ Close key.Binding
79}
80}
80
81
81func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap {
82func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap {
@@ -93,6 +94,10 @@ func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap {
93 key.WithKeys("shift+tab", "up"),
94 key.WithKeys("shift+tab", "up"),
94 key.WithHelp("shift+tab/↑", "previous"),
95 key.WithHelp("shift+tab/↑", "previous"),
95 ),
96 ),
97+ Close: key.NewBinding(
98+ key.WithKeys("esc", "alt+esc"),
99+ key.WithHelp("esc", "cancel"),
100+ ),
96 }
101 }
97}
102}
98
103
@@ -102,6 +107,7 @@ func (k ArgumentsDialogKeyMap) KeyBindings() []key.Binding {
102 k.Confirm,
107 k.Confirm,
103 k.Next,
108 k.Next,
104 k.Previous,
109 k.Previous,
110+ k.Close,
105 }
111 }
106}
112}
107
113
@@ -122,5 +128,6 @@ func (k ArgumentsDialogKeyMap) ShortHelp() []key.Binding {
122 k.Confirm,
128 k.Confirm,
123 k.Next,
129 k.Next,
124 k.Previous,
130 k.Previous,
131+ k.Close,
125 }
132 }
126}
133}
internal/tui/components/dialogs/permissions/keys.go+1−1
@@ -42,7 +42,7 @@ func DefaultKeyMap() KeyMap {
42 key.WithHelp("s", "allow session"),
42 key.WithHelp("s", "allow session"),
43 ),
43 ),
44 Deny: key.NewBinding(
44 Deny: key.NewBinding(
45− key.WithKeys("d", "D", "ctrl+d", "esc"),
45+ key.WithKeys("d", "D", "esc"),
46 key.WithHelp("d", "deny"),
46 key.WithHelp("d", "deny"),
47 ),
47 ),
48 Select: key.NewBinding(
48 Select: key.NewBinding(
internal/tui/exp/list/filterable.go+28−19
@@ -3,8 +3,6 @@ package list
3import (
3import (
4 "regexp"
4 "regexp"
5 "slices"
5 "slices"
6− "sort"
7− "strings"
8
6
9 "github.com/charmbracelet/bubbles/v2/key"
7 "github.com/charmbracelet/bubbles/v2/key"
10 "github.com/charmbracelet/bubbles/v2/textinput"
8 "github.com/charmbracelet/bubbles/v2/textinput"
@@ -28,7 +26,9 @@ type FilterableList[T FilterableItem] interface {
28 Cursor() *tea.Cursor
26 Cursor() *tea.Cursor
29 SetInputWidth(int)
27 SetInputWidth(int)
30 SetInputPlaceholder(string)
28 SetInputPlaceholder(string)
29+ SetResultsSize(int)
31 Filter(q string) tea.Cmd
30 Filter(q string) tea.Cmd
31+ fuzzy.Source
32}
32}
33
33
34type HasMatchIndexes interface {
34type HasMatchIndexes interface {
@@ -47,10 +47,11 @@ type filterableList[T FilterableItem] struct {
47 *filterableOptions
47 *filterableOptions
48 width, height int
48 width, height int
49 // stores all available items
49 // stores all available items
50− items []T
50+ items []T
51+ resultsSize int
51− input textinput.Model
52+ input textinput.Model
52− inputWidth int
53+ inputWidth int
53− query string
54+ query string
54}
55}
55
56
56type filterableListOption func(*filterableOptions)
57type filterableListOption func(*filterableOptions)
@@ -246,22 +247,18 @@ func (f *filterableList[T]) Filter(query string) tea.Cmd {
246 return f.list.SetItems(f.items)
247 return f.list.SetItems(f.items)
247 }
248 }
248
249
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)
250+ matches := fuzzy.FindFrom(query, f)
255−
256− sort.SliceStable(matches, func(i, j int) bool {
257− return matches[i].Score > matches[j].Score
258− })
259
251
260 var matchedItems []T
252 var matchedItems []T
253+ resultSize := len(matches)
254+ if f.resultsSize > 0 && resultSize > f.resultsSize {
255+ resultSize = f.resultsSize
256+ }
261− for _, match := range matches {
257+ for i := range resultSize {
258+ match := matches[i]
262 item := f.items[match.Index]
259 item := f.items[match.Index]
263− if i, ok := any(item).(HasMatchIndexes); ok {
260+ if it, ok := any(item).(HasMatchIndexes); ok {
264− i.MatchIndexes(match.MatchedIndexes)
261+ it.MatchIndexes(match.MatchedIndexes)
265 }
262 }
266 matchedItems = append(matchedItems, item)
263 matchedItems = append(matchedItems, item)
267 }
264 }
@@ -307,3 +304,15 @@ func (f *filterableList[T]) SetInputWidth(w int) {
307func (f *filterableList[T]) SetInputPlaceholder(ph string) {
304func (f *filterableList[T]) SetInputPlaceholder(ph string) {
308 f.placeholder = ph
305 f.placeholder = ph
309}
306}
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 (
15 "github.com/charmbracelet/lipgloss/v2"
15 "github.com/charmbracelet/lipgloss/v2"
16 uv "github.com/charmbracelet/ultraviolet"
16 uv "github.com/charmbracelet/ultraviolet"
17 "github.com/charmbracelet/x/ansi"
17 "github.com/charmbracelet/x/ansi"
18+ "github.com/charmbracelet/x/exp/ordered"
18 "github.com/rivo/uniseg"
19 "github.com/rivo/uniseg"
19)
20)
20
21
@@ -1283,14 +1284,14 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
1283 newItem, ok := l.renderedItems.Get(item.ID())
1284 newItem, ok := l.renderedItems.Get(item.ID())
1284 if ok {
1285 if ok {
1285 newLines := newItem.height - oldItem.height
1286 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)
1287 }
1288 }
1288 }
1289 }
1289 } else if hasOldItem && l.offset > oldItem.start {
1290 } else if hasOldItem && l.offset > oldItem.start {
1290 newItem, ok := l.renderedItems.Get(item.ID())
1291 newItem, ok := l.renderedItems.Get(item.ID())
1291 if ok {
1292 if ok {
1292 newLines := newItem.height - oldItem.height
1293 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)
1294 }
1295 }
1295 }
1296 }
1296 }
1297 }
internal/tui/util/util.go−7
@@ -60,10 +60,3 @@ type (
60 }
60 }
61 ClearStatusMsg struct{}
61 ClearStatusMsg struct{}
62)
62)
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−}