Merge branch 'main' into feat/attach-image-on-path-auto-complete-selection

tauraamui created

Change summary

.github/cla-signatures.json                           |   8 
.github/workflows/schema-update.yml                   |   2 
Taskfile.yaml                                         |   4 
go.mod                                                |  15 
go.sum                                                |  38 +-
internal/app/app.go                                   |   8 
internal/config/config.go                             |   6 
internal/config/load.go                               |   5 
internal/fsext/ls.go                                  |   2 
internal/llm/agent/agent.go                           |   9 
internal/llm/agent/mcp-tools.go                       | 208 ++++++------
internal/llm/provider/vertexai.go                     |   2 
internal/llm/tools/grep.go                            |  44 -
internal/llm/tools/grep_test.go                       | 192 ++++++++++++
internal/llm/tools/ls.go                              |   2 
internal/tui/components/chat/messages/messages.go     |   5 
internal/tui/components/dialogs/commands/arguments.go |   7 
internal/tui/components/dialogs/commands/keys.go      |   7 
internal/tui/exp/list/list.go                         |   5 
internal/tui/tui.go                                   |   7 
internal/tui/util/util.go                             |   7 
21 files changed, 392 insertions(+), 191 deletions(-)

Detailed changes

.github/cla-signatures.json ๐Ÿ”—

@@ -703,6 +703,14 @@
       "created_at": "2025-10-06T19:31:50Z",
       "repoId": 987670088,
       "pullRequestNo": 1200
+    },
+    {
+      "name": "daps94",
+      "id": 35882689,
+      "comment_id": 3395964275,
+      "created_at": "2025-10-13T05:56:20Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1223
     }
   ]
 }

.github/workflows/schema-update.yml ๐Ÿ”—

@@ -17,7 +17,7 @@ jobs:
         with:
           go-version-file: go.mod
       - run: go run . schema > ./schema.json
-      - uses: stefanzweifel/git-auto-commit-action@778341af668090896ca464160c2def5d1d1a3eb0 # v5
+      - uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v5
         with:
           commit_message: "chore: auto-update generated files"
           branch: main

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:

go.mod ๐Ÿ”—

@@ -13,8 +13,8 @@ require (
 	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.20250930175933-4cafc092c5e7
-	github.com/charmbracelet/catwalk v0.6.3
+	github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619
+	github.com/charmbracelet/catwalk v0.6.4
 	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
@@ -22,11 +22,12 @@ 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
 	github.com/joho/godotenv v1.5.1
-	github.com/mark3labs/mcp-go v0.41.1
+	github.com/modelcontextprotocol/go-sdk v1.0.0
 	github.com/muesli/termenv v0.16.0
 	github.com/ncruces/go-sqlite3 v0.29.1
 	github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
@@ -91,6 +92,7 @@ require (
 	github.com/go-logr/logr v1.4.3 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/google/go-cmp v0.7.0 // indirect
+	github.com/google/jsonschema-go v0.3.0 // indirect
 	github.com/google/s2a-go v0.1.8 // indirect
 	github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
 	github.com/googleapis/gax-go/v2 v2.14.1 // indirect
@@ -116,12 +118,11 @@ require (
 	github.com/ncruces/julianday v1.0.0 // indirect
 	github.com/pierrec/lz4/v4 v4.1.22 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
-	github.com/posthog/posthog-go v1.6.10
+	github.com/posthog/posthog-go v1.6.11
 	github.com/rivo/uniseg v0.4.7
 	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
 	github.com/sethvargo/go-retry v0.3.0 // indirect
 	github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect
-	github.com/spf13/cast v1.7.1 // indirect
 	github.com/spf13/pflag v1.0.9 // indirect
 	github.com/tetratelabs/wazero v1.9.0 // indirect
 	github.com/tidwall/gjson v1.18.0 // indirect
@@ -149,10 +150,10 @@ require (
 	golang.org/x/sync v0.17.0 // indirect
 	golang.org/x/sys v0.36.0 // indirect
 	golang.org/x/term v0.35.0 // indirect
-	golang.org/x/text v0.29.0
+	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.28.0
+	google.golang.org/genai v1.30.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

go.sum ๐Ÿ”—

@@ -78,10 +78,10 @@ github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICg
 github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
 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.20250930175933-4cafc092c5e7 h1:wH4F+UvxcZSDOxy8j45tghiRo8amrYHejbE9+1C6xv0=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250930175933-4cafc092c5e7/go.mod h1:5IzIGXU1n0foRc8bRAherC8ZuQCQURPlwx3ANLq1138=
-github.com/charmbracelet/catwalk v0.6.3 h1:RyL8Yqd4QsV3VyvBEsePScv1z2vKaZxPfQQ0XB5L5AA=
-github.com/charmbracelet/catwalk v0.6.3/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY=
+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/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=
@@ -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=
@@ -130,8 +132,6 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
-github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
-github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
 github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
 github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
 github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
@@ -144,13 +144,15 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
 github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
 github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
-github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
-github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
+github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
 github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
 github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
+github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
 github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
 github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -194,8 +196,6 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ
 github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
-github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA=
-github.com/mark3labs/mcp-go v0.41.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ=
@@ -206,6 +206,8 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX
 github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/modelcontextprotocol/go-sdk v1.0.0 h1:Z4MSjLi38bTgLrd/LjSmofqRqyBiVKRyQSJgw8q8V74=
+github.com/modelcontextprotocol/go-sdk v1.0.0/go.mod h1:nYtYQroQ2KQiM0/SbyEPUWQ6xs4B95gJjEalc9AQyOs=
 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
 github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
@@ -237,8 +239,8 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjL
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/posthog/posthog-go v1.6.10 h1:OA6bkiUg89rI7f5cSXbcrH5+wLinyS6hHplnD92Pu/M=
-github.com/posthog/posthog-go v1.6.10/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY=
+github.com/posthog/posthog-go v1.6.11 h1:5G8Y3pxnOpc3S4+PK1z1dCmZRuldiWxBsqqvvSfC2+w=
+github.com/posthog/posthog-go v1.6.11/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY=
 github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
 github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
 github.com/qjebbs/go-jsons v1.0.0-alpha.4 h1:Qsb4ohRUHQODIUAsJKdKJ/SIDbsO7oGOzsfy+h1yQZs=
@@ -265,8 +267,6 @@ github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah
 github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
 github.com/sourcegraph/jsonrpc2 v0.2.1 h1:2GtljixMQYUYCmIg7W9aF2dFmniq/mOr2T9tFRh6zSQ=
 github.com/sourcegraph/jsonrpc2 v0.2.1/go.mod h1:ZafdZgk/axhT1cvZAPOhw+95nz2I/Ra5qMlU4gTRwIo=
-github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
-github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
 github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
 github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
 github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
@@ -412,8 +412,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
-golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
+golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
+golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
 golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
 golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -422,11 +422,13 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
+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.28.0 h1:6qpUWFH3PkHPhxNnu3wjaCVJ6Jri1EIR7ks07f9IpIk=
-google.golang.org/genai v1.28.0/go.mod h1:7pAilaICJlQBonjKKJNhftDFv3SREhZcTe9F6nRcjbg=
+google.golang.org/genai v1.30.0 h1:7021aneIvl24nEBLbtQFEWleHsMbjzpcQvkT4WcJ1dc=
+google.golang.org/genai v1.30.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=

internal/app/app.go ๐Ÿ”—

@@ -107,10 +107,6 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool
 	ctx, cancel := context.WithCancel(ctx)
 	defer cancel()
 
-	// Start progress bar and spinner
-	fmt.Printf(ansi.SetIndeterminateProgressBar)
-	defer fmt.Printf(ansi.ResetProgressBar)
-
 	var spinner *format.Spinner
 	if !quiet {
 		spinner = format.NewSpinner(ctx, cancel, "Generating")
@@ -154,7 +150,11 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool
 	messageEvents := app.Messages.Subscribe(ctx)
 	messageReadBytes := make(map[string]int)
 
+	defer fmt.Printf(ansi.ResetProgressBar)
 	for {
+		// HACK: add it again on every iteration so it doesn't get hidden by
+		// the terminal due to inactivity.
+		fmt.Printf(ansi.SetIndeterminateProgressBar)
 		select {
 		case result := <-done:
 			stopSpinner()

internal/config/config.go ๐Ÿ”—

@@ -99,7 +99,7 @@ type MCPType string
 
 const (
 	MCPStdio MCPType = "stdio"
-	MCPSse   MCPType = "sse"
+	MCPSSE   MCPType = "sse"
 	MCPHttp  MCPType = "http"
 )
 
@@ -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.

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
 }
 

internal/fsext/ls.go ๐Ÿ”—

@@ -227,7 +227,7 @@ func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int
 	found := csync.NewSlice[string]()
 	dl := NewDirectoryLister(initialPath)
 
-	slog.Warn("listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns)
+	slog.Debug("listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns)
 
 	conf := fastwalk.Config{
 		Follow:   true,

internal/llm/agent/agent.go ๐Ÿ”—

@@ -1,3 +1,4 @@
+// Package agent contains the implementation of the AI agent service.
 package agent
 
 import (
@@ -175,9 +176,9 @@ func NewAgent(
 	}
 
 	baseToolsFn := func() map[string]tools.BaseTool {
-		slog.Info("Initializing agent base tools", "agent", agentCfg.ID)
+		slog.Debug("Initializing agent base tools", "agent", agentCfg.ID)
 		defer func() {
-			slog.Info("Initialized agent base tools", "agent", agentCfg.ID)
+			slog.Debug("Initialized agent base tools", "agent", agentCfg.ID)
 		}()
 
 		// Base tools available to all agents
@@ -201,9 +202,9 @@ func NewAgent(
 		return result
 	}
 	mcpToolsFn := func() map[string]tools.BaseTool {
-		slog.Info("Initializing agent mcp tools", "agent", agentCfg.ID)
+		slog.Debug("Initializing agent mcp tools", "agent", agentCfg.ID)
 		defer func() {
-			slog.Info("Initialized agent mcp tools", "agent", agentCfg.ID)
+			slog.Debug("Initialized agent mcp tools", "agent", agentCfg.ID)
 		}()
 
 		mcpToolsOnce.Do(func() {

internal/llm/agent/mcp-tools.go ๐Ÿ”—

@@ -6,8 +6,11 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"io"
 	"log/slog"
 	"maps"
+	"net/http"
+	"os/exec"
 	"strings"
 	"sync"
 	"time"
@@ -19,9 +22,7 @@ import (
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/version"
-	"github.com/mark3labs/mcp-go/client"
-	"github.com/mark3labs/mcp-go/client/transport"
-	"github.com/mark3labs/mcp-go/mcp"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
 )
 
 // MCPState represents the current state of an MCP client
@@ -71,7 +72,7 @@ type MCPClientInfo struct {
 	Name        string
 	State       MCPState
 	Error       error
-	Client      *client.Client
+	Client      *mcp.ClientSession
 	ToolCount   int
 	ConnectedAt time.Time
 }
@@ -80,14 +81,14 @@ var (
 	mcpToolsOnce    sync.Once
 	mcpTools        = csync.NewMap[string, tools.BaseTool]()
 	mcpClient2Tools = csync.NewMap[string, []tools.BaseTool]()
-	mcpClients      = csync.NewMap[string, *client.Client]()
+	mcpClients      = csync.NewMap[string, *mcp.ClientSession]()
 	mcpStates       = csync.NewMap[string, MCPClientInfo]()
 	mcpBroker       = pubsub.NewBroker[MCPEvent]()
 )
 
 type McpTool struct {
 	mcpName     string
-	tool        mcp.Tool
+	tool        *mcp.Tool
 	permissions permission.Service
 	workingDir  string
 }
@@ -97,14 +98,26 @@ func (b *McpTool) Name() string {
 }
 
 func (b *McpTool) Info() tools.ToolInfo {
-	required := b.tool.InputSchema.Required
-	if required == nil {
-		required = make([]string, 0)
-	}
-	parameters := b.tool.InputSchema.Properties
-	if parameters == nil {
-		parameters = make(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,
@@ -123,11 +136,9 @@ func runTool(ctx context.Context, name, toolName string, input string) (tools.To
 	if err != nil {
 		return tools.NewTextErrorResponse(err.Error()), nil
 	}
-	result, err := c.CallTool(ctx, mcp.CallToolRequest{
-		Params: mcp.CallToolParams{
-			Name:      toolName,
-			Arguments: args,
-		},
+	result, err := c.CallTool(ctx, &mcp.CallToolParams{
+		Name:      toolName,
+		Arguments: args,
 	})
 	if err != nil {
 		return tools.NewTextErrorResponse(err.Error()), nil
@@ -135,8 +146,8 @@ func runTool(ctx context.Context, name, toolName string, input string) (tools.To
 
 	output := make([]string, 0, len(result.Content))
 	for _, v := range result.Content {
-		if v, ok := v.(mcp.TextContent); ok {
-			output = append(output, v.Text)
+		if vv, ok := v.(*mcp.TextContent); ok {
+			output = append(output, vv.Text)
 		} else {
 			output = append(output, fmt.Sprintf("%v", v))
 		}
@@ -144,8 +155,8 @@ func runTool(ctx context.Context, name, toolName string, input string) (tools.To
 	return tools.NewTextResponse(strings.Join(output, "\n")), nil
 }
 
-func getOrRenewClient(ctx context.Context, name string) (*client.Client, error) {
-	c, ok := mcpClients.Get(name)
+func getOrRenewClient(ctx context.Context, name string) (*mcp.ClientSession, error) {
+	sess, ok := mcpClients.Get(name)
 	if !ok {
 		return nil, fmt.Errorf("mcp '%s' not available", name)
 	}
@@ -157,20 +168,20 @@ func getOrRenewClient(ctx context.Context, name string) (*client.Client, error)
 	timeout := mcpTimeout(m)
 	pingCtx, cancel := context.WithTimeout(ctx, timeout)
 	defer cancel()
-	err := c.Ping(pingCtx)
+	err := sess.Ping(pingCtx, nil)
 	if err == nil {
-		return c, nil
+		return sess, nil
 	}
 	updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, state.ToolCount)
 
-	c, err = createAndInitializeClient(ctx, name, m, cfg.Resolver())
+	sess, err = createMCPSession(ctx, name, m, cfg.Resolver())
 	if err != nil {
 		return nil, err
 	}
 
-	updateMCPState(name, MCPStateConnected, nil, c, state.ToolCount)
-	mcpClients.Set(name, c)
-	return c, nil
+	updateMCPState(name, MCPStateConnected, nil, sess, state.ToolCount)
+	mcpClients.Set(name, sess)
+	return sess, nil
 }
 
 func (b *McpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolResponse, error) {
@@ -197,8 +208,8 @@ func (b *McpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolRes
 	return runTool(ctx, b.mcpName, b.tool.Name, params.Input)
 }
 
-func getTools(ctx context.Context, name string, permissions permission.Service, c *client.Client, workingDir string) ([]tools.BaseTool, error) {
-	result, err := c.ListTools(ctx, mcp.ListToolsRequest{})
+func getTools(ctx context.Context, name string, permissions permission.Service, c *mcp.ClientSession, workingDir string) ([]tools.BaseTool, error) {
+	result, err := c.ListTools(ctx, &mcp.ListToolsParams{})
 	if err != nil {
 		return nil, err
 	}
@@ -230,7 +241,7 @@ func GetMCPState(name string) (MCPClientInfo, bool) {
 }
 
 // updateMCPState updates the state of an MCP client and publishes an event
-func updateMCPState(name string, state MCPState, err error, client *client.Client, toolCount int) {
+func updateMCPState(name string, state MCPState, err error, client *mcp.ClientSession, toolCount int) {
 	info := MCPClientInfo{
 		Name:      name,
 		State:     state,
@@ -257,19 +268,14 @@ func updateMCPState(name string, state MCPState, err error, client *client.Clien
 	})
 }
 
-// publishMCPEventToolsListChanged publishes a tool list changed event
-func publishMCPEventToolsListChanged(name string) {
-	mcpBroker.Publish(pubsub.UpdatedEvent, MCPEvent{
-		Type: MCPEventToolsListChanged,
-		Name: name,
-	})
-}
-
 // CloseMCPClients closes all MCP clients. This should be called during application shutdown.
 func CloseMCPClients() error {
 	var errs []error
 	for name, c := range mcpClients.Seq2() {
-		if err := c.Close(); err != nil {
+		if err := c.Close(); err != nil &&
+			!errors.Is(err, io.EOF) &&
+			!errors.Is(err, context.Canceled) &&
+			err.Error() != "signal: killed" {
 			errs = append(errs, fmt.Errorf("close mcp: %s: %w", name, err))
 		}
 	}
@@ -277,16 +283,6 @@ func CloseMCPClients() error {
 	return errors.Join(errs...)
 }
 
-var mcpInitRequest = mcp.InitializeRequest{
-	Params: mcp.InitializeParams{
-		ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
-		ClientInfo: mcp.Implementation{
-			Name:    "Crush",
-			Version: version.Version,
-		},
-	},
-}
-
 func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) {
 	var wg sync.WaitGroup
 	// Initialize states for all configured MCPs
@@ -322,7 +318,7 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con
 			ctx, cancel := context.WithTimeout(ctx, mcpTimeout(m))
 			defer cancel()
 
-			c, err := createAndInitializeClient(ctx, name, m, cfg.Resolver())
+			c, err := createMCPSession(ctx, name, m, cfg.Resolver())
 			if err != nil {
 				return
 			}
@@ -359,49 +355,46 @@ func updateMcpTools(mcpName string, tools []tools.BaseTool) {
 	}
 }
 
-func createAndInitializeClient(ctx context.Context, name string, m config.MCPConfig, resolver config.VariableResolver) (*client.Client, error) {
-	c, err := createMcpClient(name, m, resolver)
+func createMCPSession(ctx context.Context, name string, m config.MCPConfig, resolver config.VariableResolver) (*mcp.ClientSession, error) {
+	timeout := mcpTimeout(m)
+	mcpCtx, cancel := context.WithCancel(ctx)
+	cancelTimer := time.AfterFunc(timeout, cancel)
+
+	transport, err := createMCPTransport(mcpCtx, m, resolver)
 	if err != nil {
 		updateMCPState(name, MCPStateError, err, nil, 0)
 		slog.Error("error creating mcp client", "error", err, "name", name)
 		return nil, err
 	}
 
-	c.OnNotification(func(n mcp.JSONRPCNotification) {
-		slog.Debug("Received MCP notification", "name", name, "notification", n)
-		switch n.Method {
-		case "notifications/tools/list_changed":
-			publishMCPEventToolsListChanged(name)
-		default:
-			slog.Debug("Unhandled MCP notification", "name", name, "method", n.Method)
-		}
-	})
-
-	// XXX: ideally we should be able to use context.WithTimeout here, but,
-	// the SSE MCP client will start failing once that context is canceled.
-	timeout := mcpTimeout(m)
-	mcpCtx, cancel := context.WithCancel(ctx)
-	cancelTimer := time.AfterFunc(timeout, cancel)
+	client := mcp.NewClient(
+		&mcp.Implementation{
+			Name:    "crush",
+			Version: version.Version,
+			Title:   "Crush",
+		},
+		&mcp.ClientOptions{
+			ToolListChangedHandler: func(context.Context, *mcp.ToolListChangedRequest) {
+				mcpBroker.Publish(pubsub.UpdatedEvent, MCPEvent{
+					Type: MCPEventToolsListChanged,
+					Name: name,
+				})
+			},
+			KeepAlive: time.Minute * 10,
+		},
+	)
 
-	if err := c.Start(mcpCtx); err != nil {
+	session, err := client.Connect(mcpCtx, transport, nil)
+	if err != nil {
 		updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0)
 		slog.Error("error starting mcp client", "error", err, "name", name)
-		_ = c.Close()
-		cancel()
-		return nil, err
-	}
-
-	if _, err := c.Initialize(mcpCtx, mcpInitRequest); err != nil {
-		updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0)
-		slog.Error("error initializing mcp client", "error", err, "name", name)
-		_ = c.Close()
 		cancel()
 		return nil, err
 	}
 
 	cancelTimer.Stop()
 	slog.Info("Initialized mcp client", "name", name)
-	return c, nil
+	return session, nil
 }
 
 func maybeTimeoutErr(err error, timeout time.Duration) error {
@@ -411,7 +404,7 @@ func maybeTimeoutErr(err error, timeout time.Duration) error {
 	return err
 }
 
-func createMcpClient(name string, m config.MCPConfig, resolver config.VariableResolver) (*client.Client, error) {
+func createMCPTransport(ctx context.Context, m config.MCPConfig, resolver config.VariableResolver) (mcp.Transport, error) {
 	switch m.Type {
 	case config.MCPStdio:
 		command, err := resolver.ResolveValue(m.Command)
@@ -421,44 +414,51 @@ func createMcpClient(name string, m config.MCPConfig, resolver config.VariableRe
 		if strings.TrimSpace(command) == "" {
 			return nil, fmt.Errorf("mcp stdio config requires a non-empty 'command' field")
 		}
-		return client.NewStdioMCPClientWithOptions(
-			home.Long(command),
-			m.ResolvedEnv(),
-			m.Args,
-			transport.WithCommandLogger(mcpLogger{name: name}),
-		)
+		cmd := exec.CommandContext(ctx, home.Long(command), m.Args...)
+		cmd.Env = m.ResolvedEnv()
+		return &mcp.CommandTransport{
+			Command: cmd,
+		}, nil
 	case config.MCPHttp:
 		if strings.TrimSpace(m.URL) == "" {
 			return nil, fmt.Errorf("mcp http config requires a non-empty 'url' field")
 		}
-		return client.NewStreamableHttpClient(
-			m.URL,
-			transport.WithHTTPHeaders(m.ResolvedHeaders()),
-			transport.WithHTTPLogger(mcpLogger{name: name}),
-		)
-	case config.MCPSse:
+		client := &http.Client{
+			Transport: &headerRoundTripper{
+				headers: m.ResolvedHeaders(),
+			},
+		}
+		return &mcp.StreamableClientTransport{
+			Endpoint:   m.URL,
+			HTTPClient: client,
+		}, nil
+	case config.MCPSSE:
 		if strings.TrimSpace(m.URL) == "" {
 			return nil, fmt.Errorf("mcp sse config requires a non-empty 'url' field")
 		}
-		return client.NewSSEMCPClient(
-			m.URL,
-			client.WithHeaders(m.ResolvedHeaders()),
-			transport.WithSSELogger(mcpLogger{name: name}),
-		)
+		client := &http.Client{
+			Transport: &headerRoundTripper{
+				headers: m.ResolvedHeaders(),
+			},
+		}
+		return &mcp.SSEClientTransport{
+			Endpoint:   m.URL,
+			HTTPClient: client,
+		}, nil
 	default:
 		return nil, fmt.Errorf("unsupported mcp type: %s", m.Type)
 	}
 }
 
-// for MCP's clients.
-type mcpLogger struct{ name string }
-
-func (l mcpLogger) Errorf(format string, v ...any) {
-	slog.Error(fmt.Sprintf(format, v...), "name", l.name)
+type headerRoundTripper struct {
+	headers map[string]string
 }
 
-func (l mcpLogger) Infof(format string, v ...any) {
-	slog.Info(fmt.Sprintf(format, v...), "name", l.name)
+func (rt headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
+	for k, v := range rt.headers {
+		req.Header.Set(k, v)
+	}
+	return http.DefaultTransport.RoundTrip(req)
 }
 
 func mcpTimeout(m config.MCPConfig) time.Duration {

internal/llm/provider/vertexai.go ๐Ÿ”—

@@ -30,7 +30,7 @@ func newVertexAIClient(opts providerClientOptions) VertexAIClient {
 	}
 
 	model := opts.model(opts.modelType)
-	if strings.Contains(model.ID, "anthropic") || strings.Contains(model.ID, "claude-sonnet") {
+	if strings.Contains(model.ID, "anthropic") || strings.Contains(model.ID, "claude") || strings.Contains(model.ID, "sonnet") {
 		return newAnthropicClient(opts, AnthropicClientTypeVertex)
 	}
 	return &geminiClient{

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 {

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("<?xml version=\"1.0\"?>\n<root></root>\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 <stdio.h>\nint main() { return 0; }\n"),
+			wantText: true,
+		},
+		{
+			name:     "cpp file",
+			filename: "main.cpp",
+			content:  []byte("#include <iostream>\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)
+		})
+	}
+}

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,

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
 }

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
 }

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,
 	}
 }

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)
 			}
 		}
 	}

internal/tui/tui.go ๐Ÿ”—

@@ -602,10 +602,9 @@ func (a *appModel) View() tea.View {
 
 	view.Layer = canvas
 	view.Cursor = cursor
-	view.ProgressBar = tea.NewProgressBar(tea.ProgressBarNone, 0)
-	if a.app.CoderAgent.IsBusy() {
-		// use a random percentage to prevent the ghostty from hiding it after
-		// a timeout.
+	if a.app != nil && a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() {
+		// HACK: use a random percentage to prevent ghostty from hiding it
+		// after a timeout.
 		view.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
 	}
 	return view

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))
-}