From 9ffa58723de56761baef45b595f0a9e48266aa6d Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 14 Oct 2025 16:15:51 -0300 Subject: [PATCH 02/36] fix(ls): properly handle limits (#1230) Signed-off-by: Carlos Alexandro Becker --- internal/config/config.go | 4 ++-- internal/llm/tools/ls.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index b37b98cad717e789ad16237b3ca250a2f1555ba9..ff948b874ea1613ca126053547dcf9b7d4cc3297 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -143,7 +143,7 @@ type Completions struct { } func (c Completions) Limits() (depth, items int) { - return ptrValOr(c.MaxDepth, -1), ptrValOr(c.MaxItems, -1) + return ptrValOr(c.MaxDepth, 0), ptrValOr(c.MaxItems, 0) } type Permissions struct { @@ -269,7 +269,7 @@ type ToolLs struct { } func (t ToolLs) Limits() (depth, items int) { - return ptrValOr(t.MaxDepth, -1), ptrValOr(t.MaxItems, -1) + return ptrValOr(t.MaxDepth, 0), ptrValOr(t.MaxItems, 0) } // Config holds the configuration for crush. diff --git a/internal/llm/tools/ls.go b/internal/llm/tools/ls.go index 305f7f10249594ff06ac008a8bf81145d7d834de..af25259dd8c69ff8d52d467e20532612681b51b1 100644 --- a/internal/llm/tools/ls.go +++ b/internal/llm/tools/ls.go @@ -157,7 +157,7 @@ func ListDirectoryTree(searchPath string, params LSParams) (string, LSResponseMe ls := config.Get().Tools.Ls depth, limit := ls.Limits() - maxFiles := min(limit, maxLSFiles) + maxFiles := cmp.Or(limit, maxLSFiles) files, truncated, err := fsext.ListDirectory( searchPath, params.Ignore, From 02cd9ab3861d611cf20870b645983ee9fe073874 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 14 Oct 2025 15:25:18 -0400 Subject: [PATCH 03/36] chore(task): also push empty named commit in release (#1231) Co-authored-by: Andrey Nering --- Taskfile.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index 1c4225158fc21508e8dccac8d6f47610f7d81faf..540cb24bff123664a42eb86afcea593513c4f25f 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -101,7 +101,7 @@ tasks: - git commit --allow-empty -m "{{.NEXT}}" - git tag --annotate --sign {{.NEXT}} {{.CLI_ARGS}} - echo "Pushing {{.NEXT}}..." - - git push origin --tags + - git push origin main --follow-tags fetch-tags: cmds: From 595c9401bb70818601dda9a483bc96402408d950 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 14 Oct 2025 16:28:55 -0300 Subject: [PATCH 04/36] chore(task): set commit desc automatically --- Taskfile.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index 540cb24bff123664a42eb86afcea593513c4f25f..92b162dfbb847356e09eb17ea5996e6093a305b2 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -99,7 +99,7 @@ tasks: cmds: - task: fetch-tags - git commit --allow-empty -m "{{.NEXT}}" - - git tag --annotate --sign {{.NEXT}} {{.CLI_ARGS}} + - git tag --annotate -m "{{.NEXT}}" {{.NEXT}} {{.CLI_ARGS}} - echo "Pushing {{.NEXT}}..." - git push origin main --follow-tags From 8c9ce8e765d0b0db1e6cf8065983db19d2997a03 Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 15 Oct 2025 02:26:30 -0600 Subject: [PATCH 06/36] feat: paste/close bindings in user cmd dialog (#1221) Co-authored-by: Crush --- .../tui/components/dialogs/commands/arguments.go | 9 ++++++++- internal/tui/components/dialogs/commands/keys.go | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/internal/tui/components/dialogs/commands/arguments.go b/internal/tui/components/dialogs/commands/arguments.go index 03110eeaf2b8fbb909f1f9e4fbd57344699732e3..b1a274319719b9f550179b35aa98fd8310e0bb7b 100644 --- a/internal/tui/components/dialogs/commands/arguments.go +++ b/internal/tui/components/dialogs/commands/arguments.go @@ -128,12 +128,19 @@ func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { c.inputs[c.focusIndex].Blur() c.focusIndex = (c.focusIndex - 1 + len(c.inputs)) % len(c.inputs) c.inputs[c.focusIndex].Focus() - + case key.Matches(msg, c.keys.Paste): + return c, textinput.Paste + case key.Matches(msg, c.keys.Close): + return c, util.CmdHandler(dialogs.CloseDialogMsg{}) default: var cmd tea.Cmd c.inputs[c.focusIndex], cmd = c.inputs[c.focusIndex].Update(msg) return c, cmd } + case tea.PasteMsg: + var cmd tea.Cmd + c.inputs[c.focusIndex], cmd = c.inputs[c.focusIndex].Update(msg) + return c, cmd } return c, nil } diff --git a/internal/tui/components/dialogs/commands/keys.go b/internal/tui/components/dialogs/commands/keys.go index 7b79a29c28a024154a3b4d8c763969585409fd00..b704f227fe6f183a430bd25d3af62f4ef50b3365 100644 --- a/internal/tui/components/dialogs/commands/keys.go +++ b/internal/tui/components/dialogs/commands/keys.go @@ -76,6 +76,8 @@ type ArgumentsDialogKeyMap struct { Confirm key.Binding Next key.Binding Previous key.Binding + Paste key.Binding + Close key.Binding } func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap { @@ -93,6 +95,14 @@ func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap { key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "previous"), ), + Paste: key.NewBinding( + key.WithKeys("ctrl+v"), + key.WithHelp("ctrl+v", "paste"), + ), + Close: key.NewBinding( + key.WithKeys("esc", "alt+esc"), + key.WithHelp("esc", "cancel"), + ), } } @@ -102,6 +112,8 @@ func (k ArgumentsDialogKeyMap) KeyBindings() []key.Binding { k.Confirm, k.Next, k.Previous, + k.Paste, + k.Close, } } @@ -122,5 +134,7 @@ func (k ArgumentsDialogKeyMap) ShortHelp() []key.Binding { k.Confirm, k.Next, k.Previous, + k.Paste, + k.Close, } } From 69be8c20e2152d3a0c053d005013286d8bfe57c6 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 15 Oct 2025 06:57:21 -0300 Subject: [PATCH 07/36] fix(bedrock): detect credentials set by `aws configure` (#1232) --- internal/config/load.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/config/load.go b/internal/config/load.go index 9fb45028d6936a652f2657f51707b6cde73f4084..c63a9663613bdfdea6a9c9ccef9f53d375e35c74 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -605,6 +605,11 @@ func hasAWSCredentials(env env.Env) bool { env.Get("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" { return true } + + if _, err := os.Stat(filepath.Join(home.Dir(), ".aws/credentials")); err == nil { + return true + } + return false } From 1a40fbabbd8cfce50824072aa3b52bafc9a56a1c Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 15 Oct 2025 09:18:43 -0300 Subject: [PATCH 08/36] fix(grep): check mime type (#1228) Signed-off-by: Carlos Alexandro Becker --- internal/llm/tools/grep.go | 44 +++----- internal/llm/tools/grep_test.go | 192 ++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 29 deletions(-) diff --git a/internal/llm/tools/grep.go b/internal/llm/tools/grep.go index cbf50360b9355c05797690678a99d1310b19556f..237d4e18dab0bc518b9d4b6e2c73ef5035d2b348 100644 --- a/internal/llm/tools/grep.go +++ b/internal/llm/tools/grep.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "net/http" "os" "os/exec" "path/filepath" @@ -390,8 +391,8 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error } func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, string, error) { - // Quick binary file detection - if isBinaryFile(filePath) { + // Only search text files. + if !isTextFile(filePath) { return false, 0, "", nil } @@ -414,45 +415,30 @@ func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, st return false, 0, "", scanner.Err() } -var binaryExts = map[string]struct{}{ - ".exe": {}, ".dll": {}, ".so": {}, ".dylib": {}, - ".bin": {}, ".obj": {}, ".o": {}, ".a": {}, - ".zip": {}, ".tar": {}, ".gz": {}, ".bz2": {}, - ".jpg": {}, ".jpeg": {}, ".png": {}, ".gif": {}, - ".pdf": {}, ".doc": {}, ".docx": {}, ".xls": {}, - ".mp3": {}, ".mp4": {}, ".avi": {}, ".mov": {}, -} - -// isBinaryFile performs a quick check to determine if a file is binary -func isBinaryFile(filePath string) bool { - // Check file extension first (fastest) - ext := strings.ToLower(filepath.Ext(filePath)) - if _, isBinary := binaryExts[ext]; isBinary { - return true - } - - // Quick content check for files without clear extensions +// isTextFile checks if a file is a text file by examining its MIME type. +func isTextFile(filePath string) bool { file, err := os.Open(filePath) if err != nil { - return false // If we can't open it, let the caller handle the error + return false } defer file.Close() - // Read first 512 bytes to check for null bytes + // Read first 512 bytes for MIME type detection. buffer := make([]byte, 512) n, err := file.Read(buffer) if err != nil && err != io.EOF { return false } - // Check for null bytes (common in binary files) - for i := range n { - if buffer[i] == 0 { - return true - } - } + // Detect content type. + contentType := http.DetectContentType(buffer[:n]) - return false + // Check if it's a text MIME type. + return strings.HasPrefix(contentType, "text/") || + contentType == "application/json" || + contentType == "application/xml" || + contentType == "application/javascript" || + contentType == "application/x-sh" } func globToRegex(glob string) string { diff --git a/internal/llm/tools/grep_test.go b/internal/llm/tools/grep_test.go index 53c96b22df444adfba59c6b13995a104411a57be..435b3045b93a8e1297ff2aaeff9ee8977b974b56 100644 --- a/internal/llm/tools/grep_test.go +++ b/internal/llm/tools/grep_test.go @@ -198,3 +198,195 @@ func BenchmarkRegexCacheVsCompile(b *testing.B) { } }) } + +func TestIsTextFile(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + + tests := []struct { + name string + filename string + content []byte + wantText bool + }{ + { + name: "go file", + filename: "test.go", + content: []byte("package main\n\nfunc main() {}\n"), + wantText: true, + }, + { + name: "yaml file", + filename: "config.yaml", + content: []byte("key: value\nlist:\n - item1\n - item2\n"), + wantText: true, + }, + { + name: "yml file", + filename: "config.yml", + content: []byte("key: value\n"), + wantText: true, + }, + { + name: "json file", + filename: "data.json", + content: []byte(`{"key": "value"}`), + wantText: true, + }, + { + name: "javascript file", + filename: "script.js", + content: []byte("console.log('hello');\n"), + wantText: true, + }, + { + name: "typescript file", + filename: "script.ts", + content: []byte("const x: string = 'hello';\n"), + wantText: true, + }, + { + name: "markdown file", + filename: "README.md", + content: []byte("# Title\n\nSome content\n"), + wantText: true, + }, + { + name: "shell script", + filename: "script.sh", + content: []byte("#!/bin/bash\necho 'hello'\n"), + wantText: true, + }, + { + name: "python file", + filename: "script.py", + content: []byte("print('hello')\n"), + wantText: true, + }, + { + name: "xml file", + filename: "data.xml", + content: []byte("\n\n"), + wantText: true, + }, + { + name: "plain text", + filename: "file.txt", + content: []byte("plain text content\n"), + wantText: true, + }, + { + name: "css file", + filename: "style.css", + content: []byte("body { color: red; }\n"), + wantText: true, + }, + { + name: "scss file", + filename: "style.scss", + content: []byte("$primary: blue;\nbody { color: $primary; }\n"), + wantText: true, + }, + { + name: "sass file", + filename: "style.sass", + content: []byte("$primary: blue\nbody\n color: $primary\n"), + wantText: true, + }, + { + name: "rust file", + filename: "main.rs", + content: []byte("fn main() {\n println!(\"Hello, world!\");\n}\n"), + wantText: true, + }, + { + name: "zig file", + filename: "main.zig", + content: []byte("const std = @import(\"std\");\npub fn main() void {}\n"), + wantText: true, + }, + { + name: "java file", + filename: "Main.java", + content: []byte("public class Main {\n public static void main(String[] args) {}\n}\n"), + wantText: true, + }, + { + name: "c file", + filename: "main.c", + content: []byte("#include \nint main() { return 0; }\n"), + wantText: true, + }, + { + name: "cpp file", + filename: "main.cpp", + content: []byte("#include \nint main() { return 0; }\n"), + wantText: true, + }, + { + name: "fish shell", + filename: "script.fish", + content: []byte("#!/usr/bin/env fish\necho 'hello'\n"), + wantText: true, + }, + { + name: "powershell file", + filename: "script.ps1", + content: []byte("Write-Host 'Hello, World!'\n"), + wantText: true, + }, + { + name: "cmd batch file", + filename: "script.bat", + content: []byte("@echo off\necho Hello, World!\n"), + wantText: true, + }, + { + name: "cmd file", + filename: "script.cmd", + content: []byte("@echo off\necho Hello, World!\n"), + wantText: true, + }, + { + name: "binary exe", + filename: "binary.exe", + content: []byte{0x4D, 0x5A, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00}, + wantText: false, + }, + { + name: "png image", + filename: "image.png", + content: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, + wantText: false, + }, + { + name: "jpeg image", + filename: "image.jpg", + content: []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46}, + wantText: false, + }, + { + name: "zip archive", + filename: "archive.zip", + content: []byte{0x50, 0x4B, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00}, + wantText: false, + }, + { + name: "pdf file", + filename: "document.pdf", + content: []byte("%PDF-1.4\n%âãÏÓ\n"), + wantText: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + filePath := filepath.Join(tempDir, tt.filename) + require.NoError(t, os.WriteFile(filePath, tt.content, 0o644)) + + got := isTextFile(filePath) + require.Equal(t, tt.wantText, got, "isTextFile(%s) = %v, want %v", tt.filename, got, tt.wantText) + }) + } +} From 23e0fd441709aa040dda95d00de19ff2770ab037 Mon Sep 17 00:00:00 2001 From: Evan Date: Wed, 15 Oct 2025 15:58:56 -0400 Subject: [PATCH 09/36] fix(mcp): add type assertion guards (#1239) Signed-off-by: Evan Wies --- internal/llm/agent/mcp-tools.go | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 67f0b39ccfb6eb8aad3abd337e7545a59766d872..038cd43f4469953779799b70850355ef5dcda45f 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -98,9 +98,26 @@ func (b *McpTool) Name() string { } func (b *McpTool) Info() tools.ToolInfo { - input := b.tool.InputSchema.(map[string]any) - required, _ := input["required"].([]string) - parameters, _ := input["properties"].(map[string]any) + var parameters map[string]any + var required []string + + if input, ok := b.tool.InputSchema.(map[string]any); ok { + if props, ok := input["properties"].(map[string]any); ok { + parameters = props + } + if req, ok := input["required"].([]any); ok { + // Convert []any -> []string when elements are strings + for _, v := range req { + if s, ok := v.(string); ok { + required = append(required, s) + } + } + } else if reqStr, ok := input["required"].([]string); ok { + // Handle case where it's already []string + required = reqStr + } + } + return tools.ToolInfo{ Name: fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name), Description: b.tool.Description, From 05457d52a5467a1168676b8bdff802bac9cddd54 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 15 Oct 2025 16:00:50 -0400 Subject: [PATCH 10/36] refactor: use clamp from /x/exp/ordered (#1236) --- go.mod | 2 ++ go.sum | 2 ++ internal/tui/components/chat/messages/messages.go | 5 +++-- internal/tui/exp/list/list.go | 5 +++-- internal/tui/util/util.go | 7 ------- 5 files changed, 10 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index 5f32e148b92ac8e6c456157465061c759d267dd9..15ad5f22d82ad649decda39907f0911650a8b5f5 100644 --- a/go.mod +++ b/go.mod @@ -46,6 +46,8 @@ require ( mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5 ) +require github.com/charmbracelet/x/exp/ordered v0.1.0 + require ( cloud.google.com/go v0.116.0 // indirect cloud.google.com/go/auth v0.13.0 // indirect diff --git a/go.sum b/go.sum index 2d53e85a40001ea9241e4c7ee728baa734a889d9..d1c0349a66d5e8c0e9bf6968d849cb0cbf6d26c5 100644 --- a/go.sum +++ b/go.sum @@ -102,6 +102,8 @@ github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw= github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d h1:H2oh4WlSsXy8qwLd7I3eAvPd/X3S40aM9l+h47WF1eA= github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms= github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4 h1:ZhDGU688EHQXslD9KphRpXwK0pKP03egUoZAATUDlV0= diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 296b02478a7d0738fef2f60ae6b2211d44424a2f..d931ba7e179255d6639db78ebea5e82b57af1504 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -12,6 +12,7 @@ import ( "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/ordered" "github.com/google/uuid" "github.com/atotto/clipboard" @@ -271,7 +272,7 @@ func (m *messageCmp) renderThinkingContent() string { } } fullContent := content.String() - height := util.Clamp(lipgloss.Height(fullContent), 1, 10) + height := ordered.Clamp(lipgloss.Height(fullContent), 1, 10) m.thinkingViewport.SetHeight(height) m.thinkingViewport.SetWidth(m.textWidth()) m.thinkingViewport.SetContent(fullContent) @@ -344,7 +345,7 @@ func (m *messageCmp) GetSize() (int, int) { // SetSize updates the width of the message component for text wrapping func (m *messageCmp) SetSize(width int, height int) tea.Cmd { - m.width = util.Clamp(width, 1, 120) + m.width = ordered.Clamp(width, 1, 120) m.thinkingViewport.SetWidth(m.width - 4) return nil } diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index fd789f90b89b016abb9b9fb5c79227da7ef30fd9..e18b88348959c59190f1741698f76c33f04571db 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -15,6 +15,7 @@ import ( "github.com/charmbracelet/lipgloss/v2" uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/ordered" "github.com/rivo/uniseg" ) @@ -1283,14 +1284,14 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd { newItem, ok := l.renderedItems.Get(item.ID()) if ok { newLines := newItem.height - oldItem.height - l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1) + l.offset = ordered.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1) } } } else if hasOldItem && l.offset > oldItem.start { newItem, ok := l.renderedItems.Get(item.ID()) if ok { newLines := newItem.height - oldItem.height - l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1) + l.offset = ordered.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1) } } } diff --git a/internal/tui/util/util.go b/internal/tui/util/util.go index 1f4ea30c49c8fb0517a5068d3b7f05970638743a..eb19ad89544b281af2e836f667ac63aaa6414e01 100644 --- a/internal/tui/util/util.go +++ b/internal/tui/util/util.go @@ -60,10 +60,3 @@ type ( } ClearStatusMsg struct{} ) - -func Clamp(v, low, high int) int { - if high < low { - low, high = high, low - } - return min(high, max(low, v)) -} From 1932bcebd41811e8605dc626158d6ec2e3e9118b Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 15 Oct 2025 17:01:29 -0300 Subject: [PATCH 11/36] chore: go mod tidy Signed-off-by: Carlos Alexandro Becker --- go.mod | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 15ad5f22d82ad649decda39907f0911650a8b5f5..e0b92a9380af54233306de80c826a5191878298a 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/charmbracelet/x/ansi v0.10.2 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a + github.com/charmbracelet/x/exp/ordered v0.1.0 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec github.com/google/uuid v1.6.0 github.com/invopop/jsonschema v0.13.0 @@ -46,8 +47,6 @@ require ( mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5 ) -require github.com/charmbracelet/x/exp/ordered v0.1.0 - require ( cloud.google.com/go v0.116.0 // indirect cloud.google.com/go/auth v0.13.0 // indirect From 4b1001cf3391473faf43233a10cc4240ec147eb9 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 15 Oct 2025 17:13:14 -0300 Subject: [PATCH 12/36] fix(tui): paste on arguments input (#1240) Signed-off-by: Carlos Alexandro Becker --- internal/tui/components/dialogs/commands/arguments.go | 2 -- internal/tui/components/dialogs/commands/keys.go | 7 ------- 2 files changed, 9 deletions(-) diff --git a/internal/tui/components/dialogs/commands/arguments.go b/internal/tui/components/dialogs/commands/arguments.go index b1a274319719b9f550179b35aa98fd8310e0bb7b..72677bc934864970c2cbded87b31853ad702a6ed 100644 --- a/internal/tui/components/dialogs/commands/arguments.go +++ b/internal/tui/components/dialogs/commands/arguments.go @@ -128,8 +128,6 @@ func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { c.inputs[c.focusIndex].Blur() c.focusIndex = (c.focusIndex - 1 + len(c.inputs)) % len(c.inputs) c.inputs[c.focusIndex].Focus() - case key.Matches(msg, c.keys.Paste): - return c, textinput.Paste case key.Matches(msg, c.keys.Close): return c, util.CmdHandler(dialogs.CloseDialogMsg{}) default: diff --git a/internal/tui/components/dialogs/commands/keys.go b/internal/tui/components/dialogs/commands/keys.go index b704f227fe6f183a430bd25d3af62f4ef50b3365..65d4af84c22c87117bf5a08427027da5ee0e244f 100644 --- a/internal/tui/components/dialogs/commands/keys.go +++ b/internal/tui/components/dialogs/commands/keys.go @@ -76,7 +76,6 @@ type ArgumentsDialogKeyMap struct { Confirm key.Binding Next key.Binding Previous key.Binding - Paste key.Binding Close key.Binding } @@ -95,10 +94,6 @@ func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap { key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "previous"), ), - Paste: key.NewBinding( - key.WithKeys("ctrl+v"), - key.WithHelp("ctrl+v", "paste"), - ), Close: key.NewBinding( key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "cancel"), @@ -112,7 +107,6 @@ func (k ArgumentsDialogKeyMap) KeyBindings() []key.Binding { k.Confirm, k.Next, k.Previous, - k.Paste, k.Close, } } @@ -134,7 +128,6 @@ func (k ArgumentsDialogKeyMap) ShortHelp() []key.Binding { k.Confirm, k.Next, k.Previous, - k.Paste, k.Close, } } From ce72a48378780d5fc76da7333353348aa05502fe Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 16 Oct 2025 08:58:56 -0300 Subject: [PATCH 13/36] fix(mcp): append to os.Environ() (#1242) Signed-off-by: Carlos Alexandro Becker --- internal/llm/agent/mcp-tools.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 038cd43f4469953779799b70850355ef5dcda45f..6209efa29fb7d9a3a488ca730f6f8175e3b08a60 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -10,6 +10,7 @@ import ( "log/slog" "maps" "net/http" + "os" "os/exec" "strings" "sync" @@ -415,7 +416,7 @@ func createMCPTransport(ctx context.Context, m config.MCPConfig, resolver config return nil, fmt.Errorf("mcp stdio config requires a non-empty 'command' field") } cmd := exec.CommandContext(ctx, home.Long(command), m.Args...) - cmd.Env = m.ResolvedEnv() + cmd.Env = append(os.Environ(), m.ResolvedEnv()...) return &mcp.CommandTransport{ Command: cmd, }, nil From 2aa53614d2970fba5674d439a0fec0e531fd6d93 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 16 Oct 2025 10:11:52 -0300 Subject: [PATCH 14/36] test: add tests for the dirs cmd (#1243) Signed-off-by: Carlos Alexandro Becker --- internal/cmd/dirs_test.go | 46 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 internal/cmd/dirs_test.go diff --git a/internal/cmd/dirs_test.go b/internal/cmd/dirs_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2d68f45481a61b4ee9cf9ddc31b8d86d8a69a51f --- /dev/null +++ b/internal/cmd/dirs_test.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func init() { + os.Setenv("XDG_CONFIG_HOME", "/tmp/fakeconfig") + os.Setenv("XDG_DATA_HOME", "/tmp/fakedata") +} + +func TestDirs(t *testing.T) { + var b bytes.Buffer + dirsCmd.SetOut(&b) + dirsCmd.SetErr(&b) + dirsCmd.SetIn(bytes.NewReader(nil)) + dirsCmd.Run(dirsCmd, nil) + expected := filepath.FromSlash("/tmp/fakeconfig/crush") + "\n" + + filepath.FromSlash("/tmp/fakedata/crush") + "\n" + require.Equal(t, expected, b.String()) +} + +func TestConfigDir(t *testing.T) { + var b bytes.Buffer + configDirCmd.SetOut(&b) + configDirCmd.SetErr(&b) + configDirCmd.SetIn(bytes.NewReader(nil)) + configDirCmd.Run(configDirCmd, nil) + expected := filepath.FromSlash("/tmp/fakeconfig/crush") + "\n" + require.Equal(t, expected, b.String()) +} + +func TestDataDir(t *testing.T) { + var b bytes.Buffer + dataDirCmd.SetOut(&b) + dataDirCmd.SetErr(&b) + dataDirCmd.SetIn(bytes.NewReader(nil)) + dataDirCmd.Run(dataDirCmd, nil) + expected := filepath.FromSlash("/tmp/fakedata/crush") + "\n" + require.Equal(t, expected, b.String()) +} From 6166fc6fc9a57b9b69a447560563d61abe7e8d46 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Thu, 16 Oct 2025 14:30:16 -0300 Subject: [PATCH 15/36] chore(legal): @BrunoKrugel has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 61a8b03447ae07a5dc775ca59a5eef7aacfe9c2b..12b86fb4c2936e871a1a022a150385b3744b23cf 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -711,6 +711,14 @@ "created_at": "2025-10-13T05:56:20Z", "repoId": 987670088, "pullRequestNo": 1223 + }, + { + "name": "BrunoKrugel", + "id": 30608179, + "comment_id": 3411978929, + "created_at": "2025-10-16T17:30:07Z", + "repoId": 987670088, + "pullRequestNo": 1245 } ] } \ No newline at end of file From b896a2584775d3b4f4179a2774350fee4b6313cf Mon Sep 17 00:00:00 2001 From: Bruno Krugel Date: Thu, 16 Oct 2025 14:34:50 -0300 Subject: [PATCH 16/36] fix(mcp): avoid nil errors for tool parameters (#1245) --- CRUSH.md | 2 +- internal/llm/agent/mcp-tools.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CRUSH.md b/CRUSH.md index 102ad43ca5758beee6515ab9da4054ddc92b9a9f..dee2e7ba62baeb2af691828ed67dddf3446d4525 100644 --- a/CRUSH.md +++ b/CRUSH.md @@ -54,7 +54,7 @@ func TestYourFunction(t *testing.T) { ## Formatting - ALWAYS format any Go code you write. - - First, try `goftumpt -w .`. + - First, try `gofumpt -w .`. - If `gofumpt` is not available, use `goimports`. - If `goimports` is not available, use `gofmt`. - You can also use `task fmt` to run `gofumpt -w .` on the entire project, diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 6209efa29fb7d9a3a488ca730f6f8175e3b08a60..041cff490a59f1de51505e833cc7ee7866aa7644 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -99,8 +99,8 @@ func (b *McpTool) Name() string { } func (b *McpTool) Info() tools.ToolInfo { - var parameters map[string]any - var required []string + parameters := make(map[string]any) + required := make([]string, 0) if input, ok := b.tool.InputSchema.(map[string]any); ok { if props, ok := input["properties"].(map[string]any); ok { From 3a9954297f6d5c20e53d4a64335d5bccb9323792 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 16 Oct 2025 14:36:58 -0300 Subject: [PATCH 17/36] fix(mcp): improve STDIO error handling (#1244) Signed-off-by: Carlos Alexandro Becker --- internal/llm/agent/mcp-tools.go | 34 +++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 041cff490a59f1de51505e833cc7ee7866aa7644..d2ff6454e9a7135ee9404ef665495772b90ba86c 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -387,6 +387,7 @@ func createMCPSession(ctx context.Context, name string, m config.MCPConfig, reso session, err := client.Connect(mcpCtx, transport, nil) if err != nil { + err = maybeStdioErr(err, transport) updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0) slog.Error("error starting mcp client", "error", err, "name", name) cancel() @@ -398,6 +399,27 @@ func createMCPSession(ctx context.Context, name string, m config.MCPConfig, reso return session, nil } +// maybeStdioErr if a stdio mcp prints an error in non-json format, it'll fail +// to parse, and the cli will then close it, causing the EOF error. +// so, if we got an EOF err, and the transport is STDIO, we try to exec it +// again with a timeout and collect the output so we can add details to the +// error. +// this happens particularly when starting things with npx, e.g. if node can't +// be found or some other error like that. +func maybeStdioErr(err error, transport mcp.Transport) error { + if !errors.Is(err, io.EOF) { + return err + } + ct, ok := transport.(*mcp.CommandTransport) + if !ok { + return err + } + if err2 := stdioMCPCheck(ct.Command); err2 != nil { + err = errors.Join(err, err2) + } + return err +} + func maybeTimeoutErr(err error, timeout time.Duration) error { if errors.Is(err, context.Canceled) { return fmt.Errorf("timed out after %s", timeout) @@ -465,3 +487,15 @@ func (rt headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error func mcpTimeout(m config.MCPConfig) time.Duration { return time.Duration(cmp.Or(m.Timeout, 15)) * time.Second } + +func stdioMCPCheck(old *exec.Cmd) error { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + cmd := exec.CommandContext(ctx, old.Path, old.Args...) + cmd.Env = old.Env + out, err := cmd.CombinedOutput() + if err == nil || errors.Is(ctx.Err(), context.DeadlineExceeded) { + return nil + } + return fmt.Errorf("%w: %s", err, string(out)) +} From 015632a146db8ce0d04c989b4394f67490ad0c21 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 16 Oct 2025 19:36:29 -0300 Subject: [PATCH 19/36] fix(mcp): make sure to cancel context on error (#1246) Signed-off-by: Carlos Alexandro Becker --- Taskfile.yaml | 2 +- internal/llm/agent/mcp-tools.go | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index 92b162dfbb847356e09eb17ea5996e6093a305b2..65a7e2d42fe8dcb307bced7c1fa9a0326b35ccc1 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -99,7 +99,7 @@ tasks: cmds: - task: fetch-tags - git commit --allow-empty -m "{{.NEXT}}" - - git tag --annotate -m "{{.NEXT}}" {{.NEXT}} {{.CLI_ARGS}} + - git tag --annotate --sign -m "{{.NEXT}}" {{.NEXT}} {{.CLI_ARGS}} - echo "Pushing {{.NEXT}}..." - git push origin main --follow-tags diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index d2ff6454e9a7135ee9404ef665495772b90ba86c..6838c54ab4dc8cface0eb311e0fb933a5c18aae6 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -365,6 +365,8 @@ func createMCPSession(ctx context.Context, name string, m config.MCPConfig, reso if err != nil { updateMCPState(name, MCPStateError, err, nil, 0) slog.Error("error creating mcp client", "error", err, "name", name) + cancel() + cancelTimer.Stop() return nil, err } @@ -391,6 +393,7 @@ func createMCPSession(ctx context.Context, name string, m config.MCPConfig, reso updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0) slog.Error("error starting mcp client", "error", err, "name", name) cancel() + cancelTimer.Stop() return nil, err } From a64a4def3ea855ac2c84cd0c12d165fe5098b1a5 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 17 Oct 2025 09:56:58 -0300 Subject: [PATCH 20/36] feat(lsp): find references tool (#1233) Signed-off-by: Carlos Alexandro Becker --- go.mod | 2 +- go.sum | 4 +- internal/llm/agent/agent.go | 2 +- internal/llm/tools/diagnostics.go | 2 +- internal/llm/tools/grep.go | 108 +++++++------- internal/llm/tools/grep_test.go | 29 ++++ internal/llm/tools/references.go | 214 +++++++++++++++++++++++++++ internal/llm/tools/references.md | 36 +++++ internal/llm/tools/rg.go | 2 +- internal/llm/tools/testdata/grep.txt | 3 + internal/lsp/client.go | 10 ++ 11 files changed, 348 insertions(+), 64 deletions(-) create mode 100644 internal/llm/tools/references.go create mode 100644 internal/llm/tools/references.md create mode 100644 internal/llm/tools/testdata/grep.txt diff --git a/go.mod b/go.mod index e0b92a9380af54233306de80c826a5191878298a..c0bc32fe29ac100f98c589edf7697f104aa854a5 100644 --- a/go.mod +++ b/go.mod @@ -77,7 +77,7 @@ require ( github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d - github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4 + github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 github.com/charmbracelet/x/term v0.2.1 github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect diff --git a/go.sum b/go.sum index d1c0349a66d5e8c0e9bf6968d849cb0cbf6d26c5..0fa4e9f695cf5d60a60be753aaee9a0b2e14c192 100644 --- a/go.sum +++ b/go.sum @@ -106,8 +106,8 @@ github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sB github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d h1:H2oh4WlSsXy8qwLd7I3eAvPd/X3S40aM9l+h47WF1eA= github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms= -github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4 h1:ZhDGU688EHQXslD9KphRpXwK0pKP03egUoZAATUDlV0= -github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4= +github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc= +github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index e338eef782912bdfea48ca72ebfd33c4cd981f62..b2b222db1a481b1eb4c7e945467bd5c74506d5ab 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -525,7 +525,7 @@ func (a *agent) getAllTools() ([]tools.BaseTool, error) { if a.agentCfg.ID == "coder" { allTools = slices.AppendSeq(allTools, a.mcpTools.Seq()) if a.lspClients.Len() > 0 { - allTools = append(allTools, tools.NewDiagnosticsTool(a.lspClients)) + allTools = append(allTools, tools.NewDiagnosticsTool(a.lspClients), tools.NewReferencesTool(a.lspClients)) } } if a.agentToolFn != nil { diff --git a/internal/llm/tools/diagnostics.go b/internal/llm/tools/diagnostics.go index 8e0c332cef76e40d5e24e74ed3260b95aab8b04b..c2625e9495963f1de467656b2d74e71e0b3c78fa 100644 --- a/internal/llm/tools/diagnostics.go +++ b/internal/llm/tools/diagnostics.go @@ -23,7 +23,7 @@ type diagnosticsTool struct { lspClients *csync.Map[string, *lsp.Client] } -const DiagnosticsToolName = "diagnostics" +const DiagnosticsToolName = "lsp_diagnostics" //go:embed diagnostics.md var diagnosticsDescription []byte diff --git a/internal/llm/tools/grep.go b/internal/llm/tools/grep.go index 237d4e18dab0bc518b9d4b6e2c73ef5035d2b348..ed844b6c10081deab6a314f380da72e0893102ca 100644 --- a/internal/llm/tools/grep.go +++ b/internal/llm/tools/grep.go @@ -2,6 +2,7 @@ package tools import ( "bufio" + "bytes" "context" _ "embed" "encoding/json" @@ -13,7 +14,6 @@ import ( "path/filepath" "regexp" "sort" - "strconv" "strings" "sync" "time" @@ -82,6 +82,7 @@ type grepMatch struct { path string modTime time.Time lineNum int + charNum int lineText string } @@ -189,7 +190,11 @@ func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) fmt.Fprintf(&output, "%s:\n", match.path) } if match.lineNum > 0 { - fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, match.lineText) + if match.charNum > 0 { + fmt.Fprintf(&output, " Line %d, Char %d: %s\n", match.lineNum, match.charNum, match.lineText) + } else { + fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, match.lineText) + } } else { fmt.Fprintf(&output, " %s\n", match.path) } @@ -252,66 +257,51 @@ func searchWithRipgrep(ctx context.Context, pattern, path, include string) ([]gr return nil, err } - lines := strings.Split(strings.TrimSpace(string(output)), "\n") - matches := make([]grepMatch, 0, len(lines)) - - for _, line := range lines { - if line == "" { + var matches []grepMatch + for line := range bytes.SplitSeq(bytes.TrimSpace(output), []byte{'\n'}) { + if len(line) == 0 { continue } - - // Parse ripgrep output using null separation - filePath, lineNumStr, lineText, ok := parseRipgrepLine(line) - if !ok { + var match ripgrepMatch + if err := json.Unmarshal(line, &match); err != nil { continue } - - lineNum, err := strconv.Atoi(lineNumStr) - if err != nil { + if match.Type != "match" { continue } - - fileInfo, err := os.Stat(filePath) - if err != nil { - continue // Skip files we can't access + for _, m := range match.Data.Submatches { + fi, err := os.Stat(match.Data.Path.Text) + if err != nil { + continue // Skip files we can't access + } + matches = append(matches, grepMatch{ + path: match.Data.Path.Text, + modTime: fi.ModTime(), + lineNum: match.Data.LineNumber, + charNum: m.Start + 1, // ensure 1-based + lineText: strings.TrimSpace(match.Data.Lines.Text), + }) + // only get the first match of each line + break } - - matches = append(matches, grepMatch{ - path: filePath, - modTime: fileInfo.ModTime(), - lineNum: lineNum, - lineText: lineText, - }) } - return matches, nil } -// parseRipgrepLine parses ripgrep output with null separation to handle Windows paths -func parseRipgrepLine(line string) (filePath, lineNum, lineText string, ok bool) { - // Split on null byte first to separate filename from rest - parts := strings.SplitN(line, "\x00", 2) - if len(parts) != 2 { - return "", "", "", false - } - - filePath = parts[0] - remainder := parts[1] - - // Now split the remainder on first colon: "linenum:content" - colonIndex := strings.Index(remainder, ":") - if colonIndex == -1 { - return "", "", "", false - } - - lineNumStr := remainder[:colonIndex] - lineText = remainder[colonIndex+1:] - - if _, err := strconv.Atoi(lineNumStr); err != nil { - return "", "", "", false - } - - return filePath, lineNumStr, lineText, true +type ripgrepMatch struct { + Type string `json:"type"` + Data struct { + Path struct { + Text string `json:"text"` + } `json:"path"` + Lines struct { + Text string `json:"text"` + } `json:"lines"` + LineNumber int `json:"line_number"` + Submatches []struct { + Start int `json:"start"` + } `json:"submatches"` + } `json:"data"` } func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) { @@ -363,7 +353,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error return nil } - match, lineNum, lineText, err := fileContainsPattern(path, regex) + match, lineNum, charNum, lineText, err := fileContainsPattern(path, regex) if err != nil { return nil // Skip files we can't read } @@ -373,6 +363,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error path: path, modTime: info.ModTime(), lineNum: lineNum, + charNum: charNum, lineText: lineText, }) @@ -390,15 +381,15 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error return matches, nil } -func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, string, error) { +func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, int, string, error) { // Only search text files. if !isTextFile(filePath) { - return false, 0, "", nil + return false, 0, 0, "", nil } file, err := os.Open(filePath) if err != nil { - return false, 0, "", err + return false, 0, 0, "", err } defer file.Close() @@ -407,12 +398,13 @@ func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, st for scanner.Scan() { lineNum++ line := scanner.Text() - if pattern.MatchString(line) { - return true, lineNum, line, nil + if loc := pattern.FindStringIndex(line); loc != nil { + charNum := loc[0] + 1 + return true, lineNum, charNum, line, nil } } - return false, 0, "", scanner.Err() + return false, 0, 0, "", scanner.Err() } // isTextFile checks if a file is a text file by examining its MIME type. diff --git a/internal/llm/tools/grep_test.go b/internal/llm/tools/grep_test.go index 435b3045b93a8e1297ff2aaeff9ee8977b974b56..753ee05942b78578fd2e9170384cac3fd5d9496e 100644 --- a/internal/llm/tools/grep_test.go +++ b/internal/llm/tools/grep_test.go @@ -390,3 +390,32 @@ func TestIsTextFile(t *testing.T) { }) } } + +func TestColumnMatch(t *testing.T) { + t.Parallel() + + // Test both implementations + for name, fn := range map[string]func(pattern, path, include string) ([]grepMatch, error){ + "regex": searchFilesWithRegex, + "rg": func(pattern, path, include string) ([]grepMatch, error) { + return searchWithRipgrep(t.Context(), pattern, path, include) + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if name == "rg" && getRg() == "" { + t.Skip("rg is not in $PATH") + } + + matches, err := fn("THIS", "./testdata/", "") + require.NoError(t, err) + require.Len(t, matches, 1) + match := matches[0] + require.Equal(t, 2, match.lineNum) + require.Equal(t, 14, match.charNum) + require.Equal(t, "I wanna grep THIS particular word", match.lineText) + require.Equal(t, "testdata/grep.txt", filepath.ToSlash(filepath.Clean(match.path))) + }) + } +} diff --git a/internal/llm/tools/references.go b/internal/llm/tools/references.go new file mode 100644 index 0000000000000000000000000000000000000000..a1bc393cd5d28755f5f0b694c1b2df40bee1a39e --- /dev/null +++ b/internal/llm/tools/references.go @@ -0,0 +1,214 @@ +package tools + +import ( + "cmp" + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "log/slog" + "maps" + "path/filepath" + "regexp" + "slices" + "sort" + "strings" + + "github.com/charmbracelet/crush/internal/csync" + "github.com/charmbracelet/crush/internal/lsp" + "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" +) + +type ReferencesParams struct { + Symbol string `json:"symbol"` + Path string `json:"path"` +} + +type referencesTool struct { + lspClients *csync.Map[string, *lsp.Client] +} + +const ReferencesToolName = "lsp_references" + +//go:embed references.md +var referencesDescription []byte + +func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) BaseTool { + return &referencesTool{ + lspClients, + } +} + +func (r *referencesTool) Name() string { + return ReferencesToolName +} + +func (r *referencesTool) Info() ToolInfo { + return ToolInfo{ + Name: ReferencesToolName, + Description: string(referencesDescription), + Parameters: map[string]any{ + "symbol": map[string]any{ + "type": "string", + "description": "The symbol name to search for (e.g., function name, variable name, type name).", + }, + "path": map[string]any{ + "type": "string", + "description": "The directory to search in. Should be the entire project most of the time. Defaults to the current working directory.", + }, + }, + Required: []string{"symbol"}, + } +} + +func (r *referencesTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) { + var params ReferencesParams + if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil { + return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil + } + + if params.Symbol == "" { + return NewTextErrorResponse("symbol is required"), nil + } + + if r.lspClients.Len() == 0 { + return NewTextErrorResponse("no LSP clients available"), nil + } + + workingDir := cmp.Or(params.Path, ".") + + matches, _, err := searchFiles(ctx, regexp.QuoteMeta(params.Symbol), workingDir, "", 100) + if err != nil { + return NewTextErrorResponse(fmt.Sprintf("failed to search for symbol: %s", err)), nil + } + + if len(matches) == 0 { + return NewTextResponse(fmt.Sprintf("Symbol '%s' not found", params.Symbol)), nil + } + + var allLocations []protocol.Location + var allErrs error + for _, match := range matches { + locations, err := r.find(ctx, params.Symbol, match) + if err != nil { + if strings.Contains(err.Error(), "no identifier found") { + // grep probably matched a comment, string value, or something else that's irrelevant + continue + } + slog.Error("Failed to find references", "error", err, "symbol", params.Symbol, "path", match.path, "line", match.lineNum, "char", match.charNum) + allErrs = errors.Join(allErrs, err) + continue + } + allLocations = append(allLocations, locations...) + // XXX: should we break here or look for all results? + } + + if len(allLocations) > 0 { + output := formatReferences(cleanupLocations(allLocations)) + return NewTextResponse(output), nil + } + + if allErrs != nil { + return NewTextErrorResponse(allErrs.Error()), nil + } + return NewTextResponse(fmt.Sprintf("No references found for symbol '%s'", params.Symbol)), nil +} + +func (r *referencesTool) find(ctx context.Context, symbol string, match grepMatch) ([]protocol.Location, error) { + absPath, err := filepath.Abs(match.path) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path: %s", err) + } + + var client *lsp.Client + for c := range r.lspClients.Seq() { + if c.HandlesFile(absPath) { + client = c + break + } + } + + if client == nil { + slog.Warn("No LSP clients to handle", "path", match.path) + return nil, nil + } + + return client.FindReferences( + ctx, + absPath, + match.lineNum, + match.charNum+getSymbolOffset(symbol), + true, + ) +} + +// getSymbolOffset returns the character offset to the actual symbol name +// in a qualified symbol (e.g., "Bar" in "foo.Bar" or "method" in "Class::method"). +func getSymbolOffset(symbol string) int { + // Check for :: separator (Rust, C++, Ruby modules/classes, PHP static). + if idx := strings.LastIndex(symbol, "::"); idx != -1 { + return idx + 2 + } + // Check for . separator (Go, Python, JavaScript, Java, C#, Ruby methods). + if idx := strings.LastIndex(symbol, "."); idx != -1 { + return idx + 1 + } + // Check for \ separator (PHP namespaces). + if idx := strings.LastIndex(symbol, "\\"); idx != -1 { + return idx + 1 + } + return 0 +} + +func cleanupLocations(locations []protocol.Location) []protocol.Location { + slices.SortFunc(locations, func(a, b protocol.Location) int { + if a.URI != b.URI { + return strings.Compare(string(a.URI), string(b.URI)) + } + if a.Range.Start.Line != b.Range.Start.Line { + return cmp.Compare(a.Range.Start.Line, b.Range.Start.Line) + } + return cmp.Compare(a.Range.Start.Character, b.Range.Start.Character) + }) + return slices.CompactFunc(locations, func(a, b protocol.Location) bool { + return a.URI == b.URI && + a.Range.Start.Line == b.Range.Start.Line && + a.Range.Start.Character == b.Range.Start.Character + }) +} + +func groupByFilename(locations []protocol.Location) map[string][]protocol.Location { + files := make(map[string][]protocol.Location) + for _, loc := range locations { + path, err := loc.URI.Path() + if err != nil { + slog.Error("Failed to convert location URI to path", "uri", loc.URI, "error", err) + continue + } + files[path] = append(files[path], loc) + } + return files +} + +func formatReferences(locations []protocol.Location) string { + fileRefs := groupByFilename(locations) + files := slices.Collect(maps.Keys(fileRefs)) + sort.Strings(files) + + var output strings.Builder + output.WriteString(fmt.Sprintf("Found %d reference(s) in %d file(s):\n\n", len(locations), len(files))) + + for _, file := range files { + refs := fileRefs[file] + output.WriteString(fmt.Sprintf("%s (%d reference(s)):\n", file, len(refs))) + for _, ref := range refs { + line := ref.Range.Start.Line + 1 + char := ref.Range.Start.Character + 1 + output.WriteString(fmt.Sprintf(" Line %d, Column %d\n", line, char)) + } + output.WriteString("\n") + } + + return output.String() +} diff --git a/internal/llm/tools/references.md b/internal/llm/tools/references.md new file mode 100644 index 0000000000000000000000000000000000000000..951ce71a68b9d62060649cda999107ab9243f42a --- /dev/null +++ b/internal/llm/tools/references.md @@ -0,0 +1,36 @@ +Find all references to/usage of a symbol by name using the Language Server Protocol (LSP). + +WHEN TO USE THIS TOOL: + +- **ALWAYS USE THIS FIRST** when searching for where a function, method, variable, type, or constant is used +- **DO NOT use grep/glob for symbol searches** - this tool is semantic-aware and much more accurate +- Use when you need to find all usages of a specific symbol (function, variable, type, class, method, etc.) +- More accurate than grep because it understands code semantics and scope +- Finds only actual references, not string matches in comments or unrelated code +- Helpful for understanding where a symbol is used throughout the codebase +- Useful for refactoring or analyzing code dependencies +- Good for finding all call sites of a function, method, type, package, constant, variable, etc. + +HOW TO USE: + +- Provide the symbol name (e.g., "MyFunction", "myVariable", "MyType") +- Optionally specify a path to narrow the search to a specific directory +- The tool will automatically find the symbol and locate all references + +FEATURES: + +- Returns all references grouped by file +- Shows line and column numbers for each reference +- Supports multiple programming languages through LSP +- Automatically finds the symbol without needing exact position + +LIMITATIONS: + +- May not find references in files that haven't been opened or indexed +- Results depend on the LSP server's capabilities + +TIPS: + +- **Use this tool instead of grep when looking for symbol references** - it's more accurate and semantic-aware +- Simply provide the symbol name and let the tool find it for you +- This tool understands code structure, so it won't match unrelated strings or comments diff --git a/internal/llm/tools/rg.go b/internal/llm/tools/rg.go index 8809b57c8db30b4ac1ed6c070df5a7218c59e233..76dbb5daf2234669ac3d90552cbbc5af5cc003d0 100644 --- a/internal/llm/tools/rg.go +++ b/internal/llm/tools/rg.go @@ -43,7 +43,7 @@ func getRgSearchCmd(ctx context.Context, pattern, path, include string) *exec.Cm return nil } // Use -n to show line numbers, -0 for null separation to handle Windows paths - args := []string{"-H", "-n", "-0", pattern} + args := []string{"--json", "-H", "-n", "-0", pattern} if include != "" { args = append(args, "--glob", include) } diff --git a/internal/llm/tools/testdata/grep.txt b/internal/llm/tools/testdata/grep.txt new file mode 100644 index 0000000000000000000000000000000000000000..edac9ec894634e3b924fb9a0928a272ac4f29e7e --- /dev/null +++ b/internal/llm/tools/testdata/grep.txt @@ -0,0 +1,3 @@ +test file for grep +I wanna grep THIS particular word +and nothing else diff --git a/internal/lsp/client.go b/internal/lsp/client.go index ff9a3ac9b5249663c151fb2df04a4acb168e4de4..afbe95cc2deb1c37b64c9e9b68fb705a4a0a59f9 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -445,6 +445,16 @@ func (c *Client) WaitForDiagnostics(ctx context.Context, d time.Duration) { } } +// FindReferences finds all references to the symbol at the given position. +func (c *Client) FindReferences(ctx context.Context, filepath string, line, character int, includeDeclaration bool) ([]protocol.Location, error) { + if err := c.OpenFileOnDemand(ctx, filepath); err != nil { + return nil, err + } + // NOTE: line and character should be 0-based. + // See: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position + return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration) +} + // HasRootMarkers checks if any of the specified root marker patterns exist in the given directory. // Uses glob patterns to match files, allowing for more flexible matching. func HasRootMarkers(dir string, rootMarkers []string) bool { From 29f9dbbb368ee074bc507a96e4da8c7d49f1d354 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Sat, 18 Oct 2025 16:24:11 -0300 Subject: [PATCH 21/36] chore(legal): @dpolishuk has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 12b86fb4c2936e871a1a022a150385b3744b23cf..e71c57185db1ec3540a5082a7d9ec7daf33a3379 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -719,6 +719,14 @@ "created_at": "2025-10-16T17:30:07Z", "repoId": 987670088, "pullRequestNo": 1245 + }, + { + "name": "dpolishuk", + "id": 466424, + "comment_id": 3418756045, + "created_at": "2025-10-18T19:24:00Z", + "repoId": 987670088, + "pullRequestNo": 1254 } ] } \ No newline at end of file From 7fd91ba6d1556bd334e83c6e82f76c4c5dc9e9b3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Oct 2025 10:19:31 +0000 Subject: [PATCH 22/36] chore(deps): bump the all group with 3 updates (#1265) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index c0bc32fe29ac100f98c589edf7697f104aa854a5..b73666f15b444b4ab3b671e868090b7f02c75701 100644 --- a/go.mod +++ b/go.mod @@ -7,14 +7,14 @@ require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/PuerkitoBio/goquery v1.10.3 github.com/alecthomas/chroma/v2 v2.20.0 - github.com/anthropics/anthropic-sdk-go v1.13.0 + github.com/anthropics/anthropic-sdk-go v1.14.0 github.com/atotto/clipboard v0.1.4 github.com/aymanbagabas/go-udiff v0.3.1 github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/charlievieth/fastwalk v1.0.14 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619 - github.com/charmbracelet/catwalk v0.6.4 + github.com/charmbracelet/catwalk v0.7.0 github.com/charmbracelet/fang v0.4.3 github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea @@ -153,7 +153,7 @@ require ( golang.org/x/text v0.30.0 golang.org/x/time v0.8.0 // indirect google.golang.org/api v0.211.0 // indirect - google.golang.org/genai v1.30.0 + google.golang.org/genai v1.31.0 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect google.golang.org/grpc v1.71.0 // indirect google.golang.org/protobuf v1.36.8 // indirect diff --git a/go.sum b/go.sum index 0fa4e9f695cf5d60a60be753aaee9a0b2e14c192..fc96feb71bd75fe1b0f07b7ac739d3550ed1be4e 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,8 @@ github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW5 github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= -github.com/anthropics/anthropic-sdk-go v1.13.0 h1:Bhbe8sRoDPtipttg8bQYrMCKe2b79+q6rFW1vOKEUKI= -github.com/anthropics/anthropic-sdk-go v1.13.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +github.com/anthropics/anthropic-sdk-go v1.14.0 h1:EzNQvnZlaDHe2UPkoUySDz3ixRgNbwKdH8KtFpv7pi4= +github.com/anthropics/anthropic-sdk-go v1.14.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY= @@ -80,8 +80,8 @@ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619 h1:hjOhtqsxa+LVuCAkzhfA43wtusOaUPyQdSTg/wbRscw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619/go.mod h1:5IzIGXU1n0foRc8bRAherC8ZuQCQURPlwx3ANLq1138= -github.com/charmbracelet/catwalk v0.6.4 h1:zFHtuP94mSDE48nST3DS3a37wfsQqNcVnsFkS3v6N6E= -github.com/charmbracelet/catwalk v0.6.4/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY= +github.com/charmbracelet/catwalk v0.7.0 h1:qhLv56aeel5Q+2G/YFh9k5FhTqsozsn4HYViuAQ/Rio= +github.com/charmbracelet/catwalk v0.7.0/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY= github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= github.com/charmbracelet/fang v0.4.3 h1:qXeMxnL4H6mSKBUhDefHu8NfikFbP/MBNTfqTrXvzmY= @@ -427,8 +427,8 @@ golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.211.0 h1:IUpLjq09jxBSV1lACO33CGY3jsRcbctfGzhj+ZSE/Bg= google.golang.org/api v0.211.0/go.mod h1:XOloB4MXFH4UTlQSGuNUxw0UT74qdENK8d6JNsXKLi0= -google.golang.org/genai v1.30.0 h1:7021aneIvl24nEBLbtQFEWleHsMbjzpcQvkT4WcJ1dc= -google.golang.org/genai v1.30.0/go.mod h1:7pAilaICJlQBonjKKJNhftDFv3SREhZcTe9F6nRcjbg= +google.golang.org/genai v1.31.0 h1:R7xDt/Dosz11vcXbZ4IgisGnzUGGau2PZOIOAnXsYjw= +google.golang.org/genai v1.31.0/go.mod h1:7pAilaICJlQBonjKKJNhftDFv3SREhZcTe9F6nRcjbg= google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g= google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= From 5e3155381308f5dbb39ebdc3804d9c8c6d6e41d2 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 20 Oct 2025 16:26:09 -0300 Subject: [PATCH 23/36] chore(deps): pin `anthropic-sdk-go` to our branch with fixes --- go.mod | 4 ++++ go.sum | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index b73666f15b444b4ab3b671e868090b7f02c75701..344e0835b91133c506216098c6aaac94b171b758 100644 --- a/go.mod +++ b/go.mod @@ -161,3 +161,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5 ) + +// NOTE(@andreynering): Temporarily pinning branch with fixes: +// https://github.com/charmbracelet/anthropic-sdk-go/commits/fantasy/ +replace github.com/anthropics/anthropic-sdk-go => github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251020200929-fdb68318b7af diff --git a/go.sum b/go.sum index fc96feb71bd75fe1b0f07b7ac739d3550ed1be4e..9411b6803932f87185b9e8122daf034c0d9d6bbc 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,6 @@ github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW5 github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= -github.com/anthropics/anthropic-sdk-go v1.14.0 h1:EzNQvnZlaDHe2UPkoUySDz3ixRgNbwKdH8KtFpv7pi4= -github.com/anthropics/anthropic-sdk-go v1.14.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY= @@ -76,6 +74,8 @@ github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMU github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICgnWlhAyg= github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY= +github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251020200929-fdb68318b7af h1:iPwFVe5v46OfhqxKXSJ4J0YWf8XzthTnWyrim2yGFnU= +github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251020200929-fdb68318b7af/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 h1:973OHYuq2Jx9deyuPwe/6lsuQrDCatOsjP8uCd02URE= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619 h1:hjOhtqsxa+LVuCAkzhfA43wtusOaUPyQdSTg/wbRscw= From 2708121abecf192a1c7b5e2d38ba3cdd51d3de08 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 20 Oct 2025 16:27:38 -0300 Subject: [PATCH 24/36] feat(bedrock): add support for `AWS_BEARER_TOKEN_BEDROCK` for bedrock This adds an alternative authorization method vs. `aws configure`. --- internal/config/load.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/config/load.go b/internal/config/load.go index c63a9663613bdfdea6a9c9ccef9f53d375e35c74..a219b7d1c848b3eea76809fca96e8e4049838365 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -589,6 +589,10 @@ func hasVertexCredentials(env env.Env) bool { } func hasAWSCredentials(env env.Env) bool { + if env.Get("AWS_BEARER_TOKEN_BEDROCK") != "" { + return true + } + if env.Get("AWS_ACCESS_KEY_ID") != "" && env.Get("AWS_SECRET_ACCESS_KEY") != "" { return true } From f478c4c009aa01f42c41e614ccb5463248c7825e Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 20 Oct 2025 16:29:11 -0300 Subject: [PATCH 25/36] docs: update aws bedrock docs on readme * Removed duplicated `AWS_REGION` in table. * Add documentation for `AWS_BEARER_TOKEN_BEDROCK`. --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7f28c5c049cdb6c45bc83ec59f94f4310c13b7c5..6ed20ff1acbf2384d9daaaa184b2feffa939ceaf 100644 --- a/README.md +++ b/README.md @@ -189,8 +189,8 @@ That said, you can also set environment variables for preferred providers. | `AWS_ACCESS_KEY_ID` | AWS Bedrock (Claude) | | `AWS_SECRET_ACCESS_KEY` | AWS Bedrock (Claude) | | `AWS_REGION` | AWS Bedrock (Claude) | -| `AWS_PROFILE` | Custom AWS Profile | -| `AWS_REGION` | AWS Region | +| `AWS_PROFILE` | AWS Bedrock (Custom Profile) | +| `AWS_BEARER_TOKEN_BEDROCK` | AWS Bedrock | | `AZURE_OPENAI_API_ENDPOINT` | Azure OpenAI models | | `AZURE_OPENAI_API_KEY` | Azure OpenAI models (optional when using Entra ID) | | `AZURE_OPENAI_API_VERSION` | Azure OpenAI models | @@ -479,6 +479,7 @@ Crush currently supports running Anthropic models through Bedrock, with caching - A Bedrock provider will appear once you have AWS configured, i.e. `aws configure` - Crush also expects the `AWS_REGION` or `AWS_DEFAULT_REGION` to be set - To use a specific AWS profile set `AWS_PROFILE` in your environment, i.e. `AWS_PROFILE=myprofile crush` +- Alternatively to `aws configure`, you can also just set `AWS_BEARER_TOKEN_BEDROCK` ### Vertex AI Platform From 4519e198cc50fc8950198a19b8e6f39d68e33930 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 20 Oct 2025 21:27:09 -0300 Subject: [PATCH 26/36] fix(mcp): improve cache hits when using MCPs (#1271) --- internal/llm/agent/agent.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index b2b222db1a481b1eb4c7e945467bd5c74506d5ab..6825da22ac13dd107731abb1a506b49bec8a5271 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -535,6 +535,10 @@ func (a *agent) getAllTools() ([]tools.BaseTool, error) { } allTools = append(allTools, agentTool) } + + slices.SortFunc(allTools, func(a, b tools.BaseTool) int { + return strings.Compare(a.Name(), b.Name()) + }) return allTools, nil } From beb3bc0d8a751bdcf3a0782e118222ddc62e96bd Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 21 Oct 2025 04:45:56 -0300 Subject: [PATCH 27/36] fix(tui): remove ctrl+d deny keybind (#1269) Signed-off-by: Carlos Alexandro Becker --- internal/tui/components/dialogs/permissions/keys.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/components/dialogs/permissions/keys.go b/internal/tui/components/dialogs/permissions/keys.go index 4b7660ceb2310595fc0ad7d1ce51dade83169035..fc1810fc582dc4c25cada280b00b3f9515e43008 100644 --- a/internal/tui/components/dialogs/permissions/keys.go +++ b/internal/tui/components/dialogs/permissions/keys.go @@ -42,7 +42,7 @@ func DefaultKeyMap() KeyMap { key.WithHelp("s", "allow session"), ), Deny: key.NewBinding( - key.WithKeys("d", "D", "ctrl+d", "esc"), + key.WithKeys("d", "D", "esc"), key.WithHelp("d", "deny"), ), Select: key.NewBinding( From 6dbb5b9b3596f53cb7386a043e3244333bdf31d6 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 21 Oct 2025 14:06:20 -0300 Subject: [PATCH 29/36] chore(deps): use our anthropic's fork directly instead of `replace` (#1277) --- go.mod | 6 +----- go.sum | 4 ++-- internal/llm/provider/anthropic.go | 8 ++++---- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index 344e0835b91133c506216098c6aaac94b171b758..7c1cf2b1b5033a3e4fb404fa0a09c83f77db8367 100644 --- a/go.mod +++ b/go.mod @@ -7,11 +7,11 @@ require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/PuerkitoBio/goquery v1.10.3 github.com/alecthomas/chroma/v2 v2.20.0 - github.com/anthropics/anthropic-sdk-go v1.14.0 github.com/atotto/clipboard v0.1.4 github.com/aymanbagabas/go-udiff v0.3.1 github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/charlievieth/fastwalk v1.0.14 + github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251021163913-d29170d047bf github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619 github.com/charmbracelet/catwalk v0.7.0 @@ -161,7 +161,3 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5 ) - -// NOTE(@andreynering): Temporarily pinning branch with fixes: -// https://github.com/charmbracelet/anthropic-sdk-go/commits/fantasy/ -replace github.com/anthropics/anthropic-sdk-go => github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251020200929-fdb68318b7af diff --git a/go.sum b/go.sum index 9411b6803932f87185b9e8122daf034c0d9d6bbc..bbb776c23ee5a38737e960af5f17764ea977aaab 100644 --- a/go.sum +++ b/go.sum @@ -74,8 +74,8 @@ github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMU github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICgnWlhAyg= github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY= -github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251020200929-fdb68318b7af h1:iPwFVe5v46OfhqxKXSJ4J0YWf8XzthTnWyrim2yGFnU= -github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251020200929-fdb68318b7af/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251021163913-d29170d047bf h1:toCE1GpniOr8JPJII2GH1AffivFVOzq8Rs2S0FUrkNU= +github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251021163913-d29170d047bf/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 h1:973OHYuq2Jx9deyuPwe/6lsuQrDCatOsjP8uCd02URE= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619 h1:hjOhtqsxa+LVuCAkzhfA43wtusOaUPyQdSTg/wbRscw= diff --git a/internal/llm/provider/anthropic.go b/internal/llm/provider/anthropic.go index 981ff4590fd7db92288ff11b3d8f607e594cb0fd..1f1965fde3fd04dad759f368ce2e232543f86c8e 100644 --- a/internal/llm/provider/anthropic.go +++ b/internal/llm/provider/anthropic.go @@ -13,10 +13,10 @@ import ( "strings" "time" - "github.com/anthropics/anthropic-sdk-go" - "github.com/anthropics/anthropic-sdk-go/bedrock" - "github.com/anthropics/anthropic-sdk-go/option" - "github.com/anthropics/anthropic-sdk-go/vertex" + "github.com/charmbracelet/anthropic-sdk-go" + "github.com/charmbracelet/anthropic-sdk-go/bedrock" + "github.com/charmbracelet/anthropic-sdk-go/option" + "github.com/charmbracelet/anthropic-sdk-go/vertex" "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/llm/tools" From 941b8e9fd19a94f53ecdadb64035adc2dd16b99c Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 21 Oct 2025 15:17:22 -0300 Subject: [PATCH 30/36] ci: remove vx.y.z from the release notes (#1276) Signed-off-by: Carlos Alexandro Becker --- .goreleaser.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.goreleaser.yml b/.goreleaser.yml index aabf2f7606462ebb540fd6ebe9efb302a6855e5f..28539bc1681353065ea542a1e4de711a2d425585 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -303,6 +303,7 @@ changelog: - "^docs: update$" - "^test:" - "^test\\(" + - "^v\\d.*" - "merge conflict" - "merge conflict" - Merge branch From 092b3515ce644dbb4fe09f5bea12414e13e4832a Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 21 Oct 2025 18:10:30 -0300 Subject: [PATCH 31/36] fix: always fetch providers live and not in background (#1281) --- internal/config/provider.go | 34 +------------------------------- internal/config/provider_test.go | 2 +- 2 files changed, 2 insertions(+), 34 deletions(-) diff --git a/internal/config/provider.go b/internal/config/provider.go index 671c348f71da3a79f65c14c624bdaf2adc011411..108d6a667794e4f8f1beebe6997848a65d8fd6e6 100644 --- a/internal/config/provider.go +++ b/internal/config/provider.go @@ -126,7 +126,7 @@ func Providers(cfg *Config) ([]catwalk.Provider, error) { } func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string) ([]catwalk.Provider, error) { - cacheIsStale, cacheExists := isCacheStale(path) + _, cacheExists := isCacheStale(path) catwalkGetAndSave := func() ([]catwalk.Provider, error) { providers, err := client.GetProviders() @@ -142,25 +142,6 @@ func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string) return providers, nil } - backgroundCacheUpdate := func() { - go func() { - slog.Info("Updating providers cache in background", "path", path) - - providers, err := client.GetProviders() - if err != nil { - slog.Error("Failed to fetch providers in background from Catwalk", "error", err) - return - } - if len(providers) == 0 { - slog.Error("Empty providers list from Catwalk") - return - } - if err := saveProvidersInCache(path, providers); err != nil { - slog.Error("Failed to update providers.json in background", "error", err) - } - }() - } - switch { case autoUpdateDisabled: slog.Warn("Providers auto-update is disabled") @@ -177,19 +158,6 @@ func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string) } return providers, nil - case cacheExists && !cacheIsStale: - slog.Info("Recent providers cache is available.", "path", path) - - providers, err := loadProvidersFromCache(path) - if err != nil { - return nil, err - } - if len(providers) == 0 { - return catwalkGetAndSave() - } - backgroundCacheUpdate() - return providers, nil - default: slog.Info("Cache is not available or is stale. Fetching providers from Catwalk.", "path", path) diff --git a/internal/config/provider_test.go b/internal/config/provider_test.go index 8b499919bca666915a89d38c1e5014a911f4d2d1..1262b60ef42050b9061c9f7c6be4dc431efe3548 100644 --- a/internal/config/provider_test.go +++ b/internal/config/provider_test.go @@ -57,7 +57,7 @@ func TestProvider_loadProvidersWithIssues(t *testing.T) { if err != nil { t.Fatalf("Failed to write old providers to file: %v", err) } - providers, err := loadProviders(false, client, tmpPath) + providers, err := loadProviders(true, client, tmpPath) require.NoError(t, err) require.NotNil(t, providers) require.Len(t, providers, 1) From 23417aaec66ce592b6f47982df54ca3f1d61a402 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 21 Oct 2025 18:39:13 -0300 Subject: [PATCH 32/36] fix: only debug if enabled (#1279) --- internal/log/http.go | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/internal/log/http.go b/internal/log/http.go index 46c4b42af599f1809478a5c3f083c6249a3e13d0..a4564ffdc50335e3944c44ccf0a9a562e2f6454a 100644 --- a/internal/log/http.go +++ b/internal/log/http.go @@ -39,12 +39,14 @@ func (h *HTTPRoundTripLogger) RoundTrip(req *http.Request) (*http.Response, erro return nil, err } - slog.Debug( - "HTTP Request", - "method", req.Method, - "url", req.URL, - "body", bodyToString(save), - ) + if slog.Default().Enabled(req.Context(), slog.LevelDebug) { + slog.Debug( + "HTTP Request", + "method", req.Method, + "url", req.URL, + "body", bodyToString(save), + ) + } start := time.Now() resp, err := h.Transport.RoundTrip(req) @@ -61,16 +63,18 @@ func (h *HTTPRoundTripLogger) RoundTrip(req *http.Request) (*http.Response, erro } save, resp.Body, err = drainBody(resp.Body) - slog.Debug( - "HTTP Response", - "status_code", resp.StatusCode, - "status", resp.Status, - "headers", formatHeaders(resp.Header), - "body", bodyToString(save), - "content_length", resp.ContentLength, - "duration_ms", duration.Milliseconds(), - "error", err, - ) + if slog.Default().Enabled(req.Context(), slog.LevelDebug) { + slog.Debug( + "HTTP Response", + "status_code", resp.StatusCode, + "status", resp.Status, + "headers", formatHeaders(resp.Header), + "body", bodyToString(save), + "content_length", resp.ContentLength, + "duration_ms", duration.Milliseconds(), + "error", err, + ) + } return resp, err } @@ -84,7 +88,7 @@ func bodyToString(body io.ReadCloser) string { return "" } var b bytes.Buffer - if json.Compact(&b, bytes.TrimSpace(src)) != nil { + if json.Indent(&b, bytes.TrimSpace(src), "", " ") != nil { // not json probably return string(src) } From 7eef0b18d2548c1db74550914d38e2541d6b36d7 Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 22 Oct 2025 08:23:25 -0600 Subject: [PATCH 33/36] chore: embed version in build/install tasks (#1278) --- Taskfile.yaml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index 65a7e2d42fe8dcb307bced7c1fa9a0326b35ccc1..9e0f214fb1f7081ffb90d53e7a62eab120950b0d 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -2,6 +2,10 @@ version: "3" +vars: + VERSION: + sh: git describe --long 2>/dev/null || echo "" + env: CGO_ENABLED: 0 GOEXPERIMENT: greenteagc @@ -30,8 +34,10 @@ tasks: build: desc: Run build + vars: + LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}' cmds: - - go build . + - go build {{.LDFLAGS}} . generates: - crush @@ -59,8 +65,10 @@ tasks: install: desc: Install the application + vars: + LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}' cmds: - - go install -v . + - go install {{.LDFLAGS}} -v . profile:cpu: desc: 10s CPU profile From 1f004dcb22e77aa41b3a670de85375929b5d096c Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 22 Oct 2025 11:47:37 -0400 Subject: [PATCH 34/36] docs(readme): move slack url --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6ed20ff1acbf2384d9daaaa184b2feffa939ceaf..435c999d334a71187464670373b00effd23a8e1a 100644 --- a/README.md +++ b/README.md @@ -650,8 +650,8 @@ See the [contributing guide](https://github.com/charmbracelet/crush?tab=contribu We’d love to hear your thoughts on this project. Need help? We gotchu. You can find us on: - [Twitter](https://twitter.com/charmcli) -- [Discord][discord] - [Slack](https://charm.land/slack) +- [Discord][discord] - [The Fediverse](https://mastodon.social/@charmcli) - [Bluesky](https://bsky.app/profile/charm.land) From a292b299e74ba280154a8eac5b5fb660bf4ff124 Mon Sep 17 00:00:00 2001 From: James Trew <66286082+jamestrew@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:18:01 -0400 Subject: [PATCH 35/36] perf(list): optimize filter performance and limit results (#1193) --- internal/tui/components/chat/editor/editor.go | 2 + .../tui/components/completions/completions.go | 2 + internal/tui/exp/list/filterable.go | 47 +++++++++++-------- 3 files changed, 32 insertions(+), 19 deletions(-) diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index f70a0a3dbe63a9473f552efa233e03bd4efc0ee1..9ccd453d3f6f200c43012b61a7545fb3c08a4e6a 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -86,6 +86,7 @@ var DeleteKeyMaps = DeleteAttachmentKeyMaps{ const ( maxAttachments = 5 + maxFileResults = 25 ) type OpenEditorMsg struct { @@ -500,6 +501,7 @@ func (m *editorCmp) startCompletions() tea.Msg { Completions: completionItems, X: x, Y: y, + MaxResults: maxFileResults, } } diff --git a/internal/tui/components/completions/completions.go b/internal/tui/components/completions/completions.go index ae3c233e4f21b089f59b7effb88ddc3300277d16..1d8a0a854197d3b7ba9e26426bde8a2679f79573 100644 --- a/internal/tui/components/completions/completions.go +++ b/internal/tui/components/completions/completions.go @@ -22,6 +22,7 @@ type OpenCompletionsMsg struct { Completions []Completion X int // X position for the completions popup Y int // Y position for the completions popup + MaxResults int // Maximum number of results to render, 0 for no limit } type FilterCompletionsMsg struct { @@ -192,6 +193,7 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } c.width = width c.height = max(min(maxCompletionsHeight, len(items)), 1) // Ensure at least 1 item height + c.list.SetResultsSize(msg.MaxResults) return c, tea.Batch( c.list.SetItems(items), c.list.SetSize(c.width, c.height), diff --git a/internal/tui/exp/list/filterable.go b/internal/tui/exp/list/filterable.go index e639786db5777aaeda237e959dffe36d9c6a7583..d5c47b01083cdc1becaed9aac4fb8a5d3e9f3b47 100644 --- a/internal/tui/exp/list/filterable.go +++ b/internal/tui/exp/list/filterable.go @@ -3,8 +3,6 @@ package list import ( "regexp" "slices" - "sort" - "strings" "github.com/charmbracelet/bubbles/v2/key" "github.com/charmbracelet/bubbles/v2/textinput" @@ -28,7 +26,9 @@ type FilterableList[T FilterableItem] interface { Cursor() *tea.Cursor SetInputWidth(int) SetInputPlaceholder(string) + SetResultsSize(int) Filter(q string) tea.Cmd + fuzzy.Source } type HasMatchIndexes interface { @@ -47,10 +47,11 @@ type filterableList[T FilterableItem] struct { *filterableOptions width, height int // stores all available items - items []T - input textinput.Model - inputWidth int - query string + items []T + resultsSize int + input textinput.Model + inputWidth int + query string } type filterableListOption func(*filterableOptions) @@ -246,22 +247,18 @@ func (f *filterableList[T]) Filter(query string) tea.Cmd { return f.list.SetItems(f.items) } - words := make([]string, len(f.items)) - for i, item := range f.items { - words[i] = strings.ToLower(item.FilterValue()) - } - - matches := fuzzy.Find(query, words) - - sort.SliceStable(matches, func(i, j int) bool { - return matches[i].Score > matches[j].Score - }) + matches := fuzzy.FindFrom(query, f) var matchedItems []T - for _, match := range matches { + resultSize := len(matches) + if f.resultsSize > 0 && resultSize > f.resultsSize { + resultSize = f.resultsSize + } + for i := range resultSize { + match := matches[i] item := f.items[match.Index] - if i, ok := any(item).(HasMatchIndexes); ok { - i.MatchIndexes(match.MatchedIndexes) + if it, ok := any(item).(HasMatchIndexes); ok { + it.MatchIndexes(match.MatchedIndexes) } matchedItems = append(matchedItems, item) } @@ -307,3 +304,15 @@ func (f *filterableList[T]) SetInputWidth(w int) { func (f *filterableList[T]) SetInputPlaceholder(ph string) { f.placeholder = ph } + +func (f *filterableList[T]) SetResultsSize(size int) { + f.resultsSize = size +} + +func (f *filterableList[T]) String(i int) string { + return f.items[i].FilterValue() +} + +func (f *filterableList[T]) Len() int { + return len(f.items) +}