diff --git a/Taskfile.yaml b/Taskfile.yaml index 1c4225158fc21508e8dccac8d6f47610f7d81faf..92b162dfbb847356e09eb17ea5996e6093a305b2 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -99,9 +99,9 @@ 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 --tags + - git push origin main --follow-tags fetch-tags: cmds: diff --git a/go.mod b/go.mod index 5f32e148b92ac8e6c456157465061c759d267dd9..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 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/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/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 } 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, 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) + }) + } +} 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, diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 9eb6b4e582e7f0cce282c1e37729e5b068435ca4..9b87ad811110f37f1f5be587d31ef975666bb8de 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -247,10 +247,6 @@ func isExtOfAllowedImageType(path string) bool { type ResolveAbs func(path string) (string, error) -func onPaste(msg tea.PasteMsg) tea.Msg { - return filepicker.OnPaste(filepicker.ResolveFS, string(msg)) -} - func activeModelHasImageSupport() (bool, string) { agentCfg := config.Get().Agents["coder"] model := config.Get().GetModelByType(agentCfg.Model) @@ -296,7 +292,8 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !model.SupportsImages { return m, util.ReportWarn("File attachments are not supported by the current model: " + model.Name) } - return m, util.CmdHandler(onPaste(msg)) // inject fsys accessible from PWD + return m, filepicker.OnPaste(filepicker.ResolveFS, string(msg)) // inject fsys accessibly from PWD + case commands.ToggleYoloModeMsg: m.setEditorPrompt() return m, nil 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/components/dialogs/commands/arguments.go b/internal/tui/components/dialogs/commands/arguments.go index 03110eeaf2b8fbb909f1f9e4fbd57344699732e3..72677bc934864970c2cbded87b31853ad702a6ed 100644 --- a/internal/tui/components/dialogs/commands/arguments.go +++ b/internal/tui/components/dialogs/commands/arguments.go @@ -128,12 +128,17 @@ 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.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..65d4af84c22c87117bf5a08427027da5ee0e244f 100644 --- a/internal/tui/components/dialogs/commands/keys.go +++ b/internal/tui/components/dialogs/commands/keys.go @@ -76,6 +76,7 @@ type ArgumentsDialogKeyMap struct { Confirm key.Binding Next key.Binding Previous key.Binding + Close key.Binding } func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap { @@ -93,6 +94,10 @@ func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap { key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "previous"), ), + Close: key.NewBinding( + key.WithKeys("esc", "alt+esc"), + key.WithHelp("esc", "cancel"), + ), } } @@ -102,6 +107,7 @@ func (k ArgumentsDialogKeyMap) KeyBindings() []key.Binding { k.Confirm, k.Next, k.Previous, + k.Close, } } @@ -122,5 +128,6 @@ func (k ArgumentsDialogKeyMap) ShortHelp() []key.Binding { k.Confirm, k.Next, k.Previous, + k.Close, } } 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)) -}