Merge remote-tracking branch 'origin/main' into autoupdater

Carlos Alexandro Becker created

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

Change summary

.github/cla-signatures.json                                              |  32 
.github/workflows/schema-update.yml                                      |   2 
.goreleaser.yml                                                          |  20 
Taskfile.yaml                                                            |  15 
go.mod                                                                   |  32 
go.sum                                                                   |  68 
internal/app/app.go                                                      |   9 
internal/cmd/dirs.go                                                     |  66 
internal/cmd/logs.go                                                     |   1 
internal/cmd/root.go                                                     |  48 
internal/cmd/schema.go                                                   |   4 
internal/config/config.go                                                |  37 
internal/config/load.go                                                  |  37 
internal/csync/maps.go                                                   |  19 
internal/csync/maps_test.go                                              |  52 
internal/fsext/fileutil.go                                               |  65 
internal/fsext/fileutil_test.go                                          |  90 
internal/fsext/ignore_test.go                                            |   8 
internal/fsext/lookup_test.go                                            |  64 
internal/fsext/ls.go                                                     |  33 
internal/fsext/ls_test.go                                                |  73 
internal/llm/agent/agent.go                                              | 129 
internal/llm/agent/mcp-tools.go                                          | 218 
internal/llm/prompt/coder.go                                             |   2 
internal/llm/provider/vertexai.go                                        |   2 
internal/llm/tools/grep.go                                               |  44 
internal/llm/tools/grep_test.go                                          | 192 
internal/llm/tools/ls.go                                                 |  61 
internal/tui/components/chat/editor/editor.go                            |   4 
internal/tui/components/core/core.go                                     |   7 
internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden |   2 
internal/tui/components/dialogs/commands/arguments.go                    |   9 
internal/tui/components/dialogs/commands/keys.go                         |  14 
internal/tui/components/lsp/lsp.go                                       |  51 
internal/tui/components/mcp/mcp.go                                       |   2 
internal/tui/tui.go                                                      |   6 
main.go                                                                  |  13 
schema.json                                                              |  72 
38 files changed, 1,081 insertions(+), 522 deletions(-)

Detailed changes

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

@@ -679,6 +679,38 @@
       "created_at": "2025-09-29T01:19:40Z",
       "repoId": 987670088,
       "pullRequestNo": 1148
+    },
+    {
+      "name": "kucukkanat",
+      "id": 914316,
+      "comment_id": 3369230313,
+      "created_at": "2025-10-05T18:13:57Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1195
+    },
+    {
+      "name": "thuggys",
+      "id": 150315417,
+      "comment_id": 3369149503,
+      "created_at": "2025-10-05T15:59:55Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1194
+    },
+    {
+      "name": "nikaro",
+      "id": 3918653,
+      "comment_id": 3373586148,
+      "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

.goreleaser.yml ๐Ÿ”—

@@ -312,19 +312,19 @@ changelog:
       - "^wip "
       - "^wip:"
   groups:
-    - title: "New Features"
-      regexp: '^.*?feat(\(.+\))??!?:.+$'
+    - title: "Deps"
+      regexp: "^.*\\(deps\\)*:+.*$"
+      order: 300
+    - title: "New!"
+      regexp: "^.*feat[(\\w)]*:+.*$"
       order: 100
-    - title: "Security updates"
-      regexp: '^.*?sec(\(.+\))??!?:.+$'
-      order: 150
-    - title: "Bug fixes and improvements"
-      regexp: '^.*?(fix|refactor)(\(.+\))??!?:.+$'
+    - title: "Fixed"
+      regexp: "^.*fix[(\\w)]*:+.*$"
       order: 200
-    - title: "Documentation updates"
-      regexp: ^.*?docs?(\(.+\))??!?:.+$
+    - title: "Docs"
+      regexp: "^.*docs[(\\w)]*:+.*$"
       order: 400
-    - title: Other work
+    - title: "Other stuff"
       order: 9999
 
 release:

Taskfile.yaml ๐Ÿ”—

@@ -38,7 +38,7 @@ tasks:
   run:
     desc: Run build
     cmds:
-      - go run .
+      - go run . {{.CLI_ARGS}}
 
   test:
     desc: Run tests
@@ -89,7 +89,7 @@ tasks:
     desc: Create and push a new tag following semver
     vars:
       NEXT:
-        sh: go run github.com/caarlos0/svu/v3@latest next --always
+        sh: svu next --always || go run github.com/caarlos0/svu/v3@latest next --always
     prompt: "This will release {{.NEXT}}. Continue?"
     preconditions:
       - sh: '[ $(git symbolic-ref --short HEAD) = "main" ]'
@@ -97,8 +97,13 @@ tasks:
       - sh: "[ $(git status --porcelain=2 | wc -l) = 0 ]"
         msg: "Git is dirty"
     cmds:
+      - task: fetch-tags
       - git commit --allow-empty -m "{{.NEXT}}"
-      - git tag -d nightly
-      - git tag --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:
+      - git tag -d nightly || true
+      - git fetch --tags

go.mod ๐Ÿ”—

@@ -7,32 +7,32 @@ 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.12.0
+	github.com/anthropics/anthropic-sdk-go v1.13.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.20250910155747-997384b0b35e
-	github.com/charmbracelet/catwalk v0.6.1
-	github.com/charmbracelet/fang v0.4.2
+	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
 	github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706
-	github.com/charmbracelet/x/ansi v0.10.1
+	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/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.0
+	github.com/modelcontextprotocol/go-sdk v1.0.0
 	github.com/muesli/termenv v0.16.0
-	github.com/ncruces/go-sqlite3 v0.29.0
+	github.com/ncruces/go-sqlite3 v0.29.1
 	github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
 	github.com/nxadm/tail v1.4.11
 	github.com/openai/openai-go v1.12.0
-	github.com/pressly/goose/v3 v3.25.0
+	github.com/pressly/goose/v3 v3.26.0
 	github.com/qjebbs/go-jsons v1.0.0-alpha.4
 	github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
 	github.com/sahilm/fuzzy v0.1.1
@@ -72,7 +72,7 @@ require (
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/bahlo/generic-list-go v0.2.0 // indirect
 	github.com/buger/jsonparser v1.1.1 // indirect
-	github.com/charmbracelet/colorprofile v0.3.2 // indirect
+	github.com/charmbracelet/colorprofile v0.3.2
 	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
@@ -91,6 +91,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
@@ -104,7 +105,7 @@ require (
 	github.com/lucasb-eyer/go-colorful v1.3.0
 	github.com/mailru/easyjson v0.7.7 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
-	github.com/mattn/go-runewidth v0.0.16 // indirect
+	github.com/mattn/go-runewidth v0.0.17 // indirect
 	github.com/mfridman/interpolate v0.0.2 // indirect
 	github.com/microcosm-cc/bluemonday v1.0.27 // indirect
 	github.com/mitchellh/mapstructure v1.5.0 // indirect
@@ -116,12 +117,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
@@ -141,18 +141,18 @@ require (
 	go.opentelemetry.io/otel/metric v1.37.0 // indirect
 	go.opentelemetry.io/otel/trace v1.37.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
-	golang.org/x/crypto v0.41.0 // indirect
+	golang.org/x/crypto v0.42.0 // indirect
 	golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
 	golang.org/x/image v0.26.0 // indirect
 	golang.org/x/net v0.43.0 // indirect
 	golang.org/x/oauth2 v0.30.0 // indirect
 	golang.org/x/sync v0.17.0 // indirect
 	golang.org/x/sys v0.36.0 // indirect
-	golang.org/x/term v0.34.0 // indirect
-	golang.org/x/text v0.29.0
+	golang.org/x/term v0.35.0 // indirect
+	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.26.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 ๐Ÿ”—

@@ -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.12.0 h1:xPqlGnq7rWrTiHazIvCiumA0u7mGQnwDQtvA1M82h9U=
-github.com/anthropics/anthropic-sdk-go v1.12.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
+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/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=
@@ -78,14 +78,14 @@ 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.20250910155747-997384b0b35e h1:4BBnKWFwJ5FLyhw/ijFxKE04i9rubr8WIPR1kjO57iA=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250910155747-997384b0b35e/go.mod h1:F7AfLKYQqpM3NNBVs7ctW417tavhvoh9SBjsgtwpzbY=
-github.com/charmbracelet/catwalk v0.6.1 h1:2rRqUlwo+fdyIty8jEvUufRTgqBl0aea21LV6YQPqb0=
-github.com/charmbracelet/catwalk v0.6.1/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.2 h1:nWr7Tb82/TTNNGMGG35aTZ1X68loAOQmpb0qxkKXjas=
-github.com/charmbracelet/fang v0.4.2/go.mod h1:wHJKQYO5ReYsxx+yZl+skDtrlKO/4LLEQ6EXsdHhRhg=
+github.com/charmbracelet/fang v0.4.3 h1:qXeMxnL4H6mSKBUhDefHu8NfikFbP/MBNTfqTrXvzmY=
+github.com/charmbracelet/fang v0.4.3/go.mod h1:wHJKQYO5ReYsxx+yZl+skDtrlKO/4LLEQ6EXsdHhRhg=
 github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018 h1:PU4Zvpagsk5sgaDxn5W4sxHuLp9QRMBZB3bFSk40A4w=
 github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018/go.mod h1:Z/GLmp9fzaqX4ze3nXG7StgWez5uBM5XtlLHK8V/qSk=
 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea h1:g1HfUgSMvye8mgecMD1mPscpt+pzJoDEiSA+p2QXzdQ=
@@ -94,8 +94,8 @@ github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 h1:WkwO6Ks3mS
 github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706/go.mod h1:mjJGp00cxcfvD5xdCa+bso251Jt4owrQvuimJtVmEmM=
 github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef h1:VrWaUi2LXYLjfjCHowdSOEc6dQ9Ro14KY7Bw4IWd19M=
 github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef/go.mod h1:AThRsQH1t+dfyOKIwXRoJBniYFQUkUpQq4paheHMc2o=
-github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
-github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw=
+github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8=
 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a h1:zYSNtEJM9jwHbJts2k+Hroj+xQwsW1yxc4Wopdv7KaI=
 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a/go.mod h1:rc2bsPC6MWae3LdOxNO1mOb443NlMrrDL0xEya48NNc=
 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1xwHZg6eMZ9Wv5TE1UGub6ARubyOd1Lo5kPUI/6VL50=
@@ -130,8 +130,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 +142,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,18 +194,18 @@ 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.0 h1:IFfJaovCet65F3av00bE1HzSnmHpMRWM1kz96R98I70=
-github.com/mark3labs/mcp-go v0.41.0/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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
-github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ=
+github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
 github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
 github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
 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=
@@ -218,8 +218,8 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
 github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
 github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
-github.com/ncruces/go-sqlite3 v0.29.0 h1:1tsLiagCoqZEfcHDeKsNSv5jvrY/Iu393pAnw2wLNJU=
-github.com/ncruces/go-sqlite3 v0.29.0/go.mod h1:r1hSvYKPNJ+OlUA1O3r8o9LAawzPAlqeZiIdxTBBBJ0=
+github.com/ncruces/go-sqlite3 v0.29.1 h1:NIi8AISWBToRHyoz01FXiTNvU147Tqdibgj2tFzJCqM=
+github.com/ncruces/go-sqlite3 v0.29.1/go.mod h1:PpccBNNhvjwUOwDQEn2gXQPFPTWdlromj0+fSkd5KSg=
 github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
 github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
 github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
@@ -237,10 +237,10 @@ 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/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng=
-github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
+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=
 github.com/qjebbs/go-jsons v1.0.0-alpha.4/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -265,8 +265,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=
@@ -338,8 +336,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
 golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
 golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
 golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
-golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
+golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
+golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
 golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
@@ -401,8 +399,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
 golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
 golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
 golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
-golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
-golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
+golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
+golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -412,8 +410,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 +420,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.26.0 h1:r4HGL54kFv/WCRMTAbZg05Ct+vXfhAbTRlXhFyBkEQo=
-google.golang.org/genai v1.26.0/go.mod h1:OClfdf+r5aaD+sCd4aUSkPzJItmg2wD/WON9lQnRPaY=
+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 ๐Ÿ”—

@@ -17,13 +17,13 @@ import (
 	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/llm/agent"
 	"github.com/charmbracelet/crush/internal/log"
-	"github.com/charmbracelet/crush/internal/pubsub"
-
 	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/update"
+	"github.com/charmbracelet/x/ansi"
 )
 
 type App struct {
@@ -111,7 +111,6 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool
 	ctx, cancel := context.WithCancel(ctx)
 	defer cancel()
 
-	// Start spinner if not in quiet mode.
 	var spinner *format.Spinner
 	if !quiet {
 		spinner = format.NewSpinner(ctx, cancel, "Generating")
@@ -155,7 +154,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/cmd/dirs.go ๐Ÿ”—

@@ -0,0 +1,66 @@
+package cmd
+
+import (
+	"os"
+	"path/filepath"
+
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/lipgloss/v2/table"
+	"github.com/charmbracelet/x/term"
+	"github.com/spf13/cobra"
+)
+
+var dirsCmd = &cobra.Command{
+	Use:   "dirs",
+	Short: "Print directories used by Crush",
+	Long: `Print the directories where Crush stores its configuration and data files.
+This includes the global configuration directory and data directory.`,
+	Example: `
+# Print all directories
+crush dirs
+
+# Print only the config directory
+crush dirs config
+
+# Print only the data directory
+crush dirs data
+  `,
+	Run: func(cmd *cobra.Command, args []string) {
+		if term.IsTerminal(os.Stdout.Fd()) {
+			// We're in a TTY: make it fancy.
+			t := table.New().
+				Border(lipgloss.RoundedBorder()).
+				StyleFunc(func(row, col int) lipgloss.Style {
+					return lipgloss.NewStyle().Padding(0, 2)
+				}).
+				Row("Config", filepath.Dir(config.GlobalConfig())).
+				Row("Data", filepath.Dir(config.GlobalConfigData()))
+			lipgloss.Println(t)
+			return
+		}
+		// Not a TTY.
+		cmd.Println(filepath.Dir(config.GlobalConfig()))
+		cmd.Println(filepath.Dir(config.GlobalConfigData()))
+	},
+}
+
+var configDirCmd = &cobra.Command{
+	Use:   "config",
+	Short: "Print the configuration directory used by Crush",
+	Run: func(cmd *cobra.Command, args []string) {
+		cmd.Println(filepath.Dir(config.GlobalConfig()))
+	},
+}
+
+var dataDirCmd = &cobra.Command{
+	Use:   "data",
+	Short: "Print the datauration directory used by Crush",
+	Run: func(cmd *cobra.Command, args []string) {
+		cmd.Println(filepath.Dir(config.GlobalConfigData()))
+	},
+}
+
+func init() {
+	dirsCmd.AddCommand(configDirCmd, dataDirCmd)
+}

internal/cmd/logs.go ๐Ÿ”—

@@ -68,7 +68,6 @@ var logsCmd = &cobra.Command{
 func init() {
 	logsCmd.Flags().BoolP("follow", "f", false, "Follow log output")
 	logsCmd.Flags().IntP("tail", "t", defaultTailLines, "Show only the last N lines default: 1000 for performance")
-	rootCmd.AddCommand(logsCmd)
 }
 
 func followLogs(ctx context.Context, logsFile string, tailLines int) error {

internal/cmd/root.go ๐Ÿ”—

@@ -1,7 +1,9 @@
 package cmd
 
 import (
+	"bytes"
 	"context"
+	"errors"
 	"fmt"
 	"io"
 	"log/slog"
@@ -10,6 +12,7 @@ import (
 	"strconv"
 
 	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/colorprofile"
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/db"
@@ -17,6 +20,8 @@ import (
 	"github.com/charmbracelet/crush/internal/tui"
 	"github.com/charmbracelet/crush/internal/version"
 	"github.com/charmbracelet/fang"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/exp/charmtone"
 	"github.com/charmbracelet/x/term"
 	"github.com/spf13/cobra"
 )
@@ -29,8 +34,13 @@ func init() {
 	rootCmd.Flags().BoolP("help", "h", false, "Help")
 	rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
 
-	rootCmd.AddCommand(runCmd)
-	rootCmd.AddCommand(updateProvidersCmd)
+	rootCmd.AddCommand(
+		runCmd,
+		dirsCmd,
+		updateProvidersCmd,
+		logsCmd,
+		schemaCmd,
+	)
 }
 
 var rootCmd = &cobra.Command{
@@ -84,7 +94,7 @@ crush -y
 		if _, err := program.Run(); err != nil {
 			event.Error(err)
 			slog.Error("TUI run error", "error", err)
-			return fmt.Errorf("TUI error: %v", err)
+			return errors.New("Crush crashed. If metrics are enabled, we were notified about it. If you'd like to report it, please copy the stacktrace above and open an issue at https://github.com/charmbracelet/crush/issues/new?template=bug.yml") //nolint:staticcheck
 		}
 		return nil
 	},
@@ -93,7 +103,39 @@ crush -y
 	},
 }
 
+var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
+    โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„    โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„
+  โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ  โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ
+โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ
+โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ
+โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–€โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–€โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ
+โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ
+โ–€โ–€โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–„โ–ˆโ–ˆโ–ˆโ–ˆโ–„โ–„โ–ˆโ–ˆโ–ˆโ–ˆโ–„โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–€โ–€
+  โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ
+    โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ
+       โ–€โ–€โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–€โ–€
+           โ–€โ–€โ–€โ–€โ–€โ–€
+`)
+
+// copied from cobra:
+const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
+`
+
 func Execute() {
+	// NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
+	// it forward to a bytes.Buffer, write the colored heartbit to it, and then
+	// finally prepend it in the version template.
+	// Unfortunately cobra doesn't give us a way to set a function to handle
+	// printing the version, and PreRunE runs after the version is already
+	// handled, so that doesn't work either.
+	// This is the only way I could find that works relatively well.
+	if term.IsTerminal(os.Stdout.Fd()) {
+		var b bytes.Buffer
+		w := colorprofile.NewWriter(os.Stdout, os.Environ())
+		w.Forward = &b
+		_, _ = w.WriteString(heartbit.String())
+		rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate)
+	}
 	if err := fang.Execute(
 		context.Background(),
 		rootCmd,

internal/config/config.go ๐Ÿ”—

@@ -99,7 +99,7 @@ type MCPType string
 
 const (
 	MCPStdio MCPType = "stdio"
-	MCPSse   MCPType = "sse"
+	MCPSSE   MCPType = "sse"
 	MCPHttp  MCPType = "http"
 )
 
@@ -131,6 +131,19 @@ type TUIOptions struct {
 	CompactMode bool   `json:"compact_mode,omitempty" jsonschema:"description=Enable compact mode for the TUI interface,default=false"`
 	DiffMode    string `json:"diff_mode,omitempty" jsonschema:"description=Diff mode for the TUI interface,enum=unified,enum=split"`
 	// Here we can add themes later or any TUI related options
+	//
+
+	Completions Completions `json:"completions,omitzero" jsonschema:"description=Completions UI options"`
+}
+
+// Completions defines options for the completions UI.
+type Completions struct {
+	MaxDepth *int `json:"max_depth,omitempty" jsonschema:"description=Maximum depth for the ls tool,default=0,example=10"`
+	MaxItems *int `json:"max_items,omitempty" jsonschema:"description=Maximum number of items to return for the ls tool,default=1000,example=100"`
+}
+
+func (c Completions) Limits() (depth, items int) {
+	return ptrValOr(c.MaxDepth, 0), ptrValOr(c.MaxItems, 0)
 }
 
 type Permissions struct {
@@ -246,6 +259,19 @@ type Agent struct {
 	ContextPaths []string `json:"context_paths,omitempty"`
 }
 
+type Tools struct {
+	Ls ToolLs `json:"ls,omitzero"`
+}
+
+type ToolLs struct {
+	MaxDepth *int `json:"max_depth,omitempty" jsonschema:"description=Maximum depth for the ls tool,default=0,example=10"`
+	MaxItems *int `json:"max_items,omitempty" jsonschema:"description=Maximum number of items to return for the ls tool,default=1000,example=100"`
+}
+
+func (t ToolLs) Limits() (depth, items int) {
+	return ptrValOr(t.MaxDepth, 0), ptrValOr(t.MaxItems, 0)
+}
+
 // Config holds the configuration for crush.
 type Config struct {
 	Schema string `json:"$schema,omitempty"`
@@ -264,6 +290,8 @@ type Config struct {
 
 	Permissions *Permissions `json:"permissions,omitempty" jsonschema:"description=Permission settings for tool usage"`
 
+	Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"`
+
 	// Internal
 	workingDir string `json:"-"`
 	// TODO: most likely remove this concept when I come back to it
@@ -579,3 +607,10 @@ func resolveEnvs(envs map[string]string) []string {
 	}
 	return res
 }
+
+func ptrValOr[T any](t *T, el T) T {
+	if t == nil {
+		return el
+	}
+	return *t
+}

internal/config/load.go ๐Ÿ”—

@@ -1,12 +1,14 @@
 package config
 
 import (
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
 	"log/slog"
 	"maps"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"runtime"
 	"slices"
@@ -62,6 +64,16 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) {
 		cfg.Options.Debug,
 	)
 
+	if !isInsideWorktree() {
+		const depth = 2
+		const items = 100
+		slog.Warn("No git repository detected in working directory, will limit file walk operations", "depth", depth, "items", items)
+		assignIfNil(&cfg.Tools.Ls.MaxDepth, depth)
+		assignIfNil(&cfg.Tools.Ls.MaxItems, items)
+		assignIfNil(&cfg.Options.TUI.Completions.MaxDepth, depth)
+		assignIfNil(&cfg.Options.TUI.Completions.MaxItems, items)
+	}
+
 	// Load known providers, this loads the config from catwalk
 	providers, err := Providers(cfg)
 	if err != nil {
@@ -520,7 +532,7 @@ func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) erro
 func lookupConfigs(cwd string) []string {
 	// prepend default config paths
 	configPaths := []string{
-		globalConfig(),
+		GlobalConfig(),
 		GlobalConfigData(),
 	}
 
@@ -593,10 +605,16 @@ 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
 }
 
-func globalConfig() string {
+// GlobalConfig returns the global configuration file path for the application.
+func GlobalConfig() string {
 	xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
 	if xdgConfigHome != "" {
 		return filepath.Join(xdgConfigHome, appName, fmt.Sprintf("%s.json", appName))
@@ -637,3 +655,18 @@ func GlobalConfigData() string {
 
 	return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
 }
+
+func assignIfNil[T any](ptr **T, val T) {
+	if *ptr == nil {
+		*ptr = &val
+	}
+}
+
+func isInsideWorktree() bool {
+	bts, err := exec.CommandContext(
+		context.Background(),
+		"git", "rev-parse",
+		"--is-inside-work-tree",
+	).CombinedOutput()
+	return err == nil && strings.TrimSpace(string(bts)) == "true"
+}

internal/csync/maps.go ๐Ÿ”—

@@ -27,6 +27,25 @@ func NewMapFrom[K comparable, V any](m map[K]V) *Map[K, V] {
 	}
 }
 
+// NewLazyMap creates a new lazy-loaded map. The provided load function is
+// executed in a separate goroutine to populate the map.
+func NewLazyMap[K comparable, V any](load func() map[K]V) *Map[K, V] {
+	m := &Map[K, V]{}
+	m.mu.Lock()
+	go func() {
+		m.inner = load()
+		m.mu.Unlock()
+	}()
+	return m
+}
+
+// Reset replaces the inner map with the new one.
+func (m *Map[K, V]) Reset(input map[K]V) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	m.inner = input
+}
+
 // Set sets the value for the specified key in the map.
 func (m *Map[K, V]) Set(key K, value V) {
 	m.mu.Lock()

internal/csync/maps_test.go ๐Ÿ”—

@@ -5,6 +5,8 @@ import (
 	"maps"
 	"sync"
 	"testing"
+	"testing/synctest"
+	"time"
 
 	"github.com/stretchr/testify/require"
 )
@@ -36,6 +38,56 @@ func TestNewMapFrom(t *testing.T) {
 	require.Equal(t, 1, value)
 }
 
+func TestNewLazyMap(t *testing.T) {
+	t.Parallel()
+
+	synctest.Test(t, func(t *testing.T) {
+		t.Helper()
+
+		waiter := sync.Mutex{}
+		waiter.Lock()
+		loadCalled := false
+
+		loadFunc := func() map[string]int {
+			waiter.Lock()
+			defer waiter.Unlock()
+			loadCalled = true
+			return map[string]int{
+				"key1": 1,
+				"key2": 2,
+			}
+		}
+
+		m := NewLazyMap(loadFunc)
+		require.NotNil(t, m)
+
+		waiter.Unlock() // Allow the load function to proceed
+		time.Sleep(100 * time.Millisecond)
+		require.True(t, loadCalled)
+		require.Equal(t, 2, m.Len())
+
+		value, ok := m.Get("key1")
+		require.True(t, ok)
+		require.Equal(t, 1, value)
+	})
+}
+
+func TestMap_Reset(t *testing.T) {
+	t.Parallel()
+
+	m := NewMapFrom(map[string]int{
+		"a": 10,
+	})
+
+	m.Reset(map[string]int{
+		"b": 20,
+	})
+	value, ok := m.Get("b")
+	require.True(t, ok)
+	require.Equal(t, 20, value)
+	require.Equal(t, 1, m.Len())
+}
+
 func TestMap_Set(t *testing.T) {
 	t.Parallel()
 

internal/fsext/fileutil.go ๐Ÿ”—

@@ -1,15 +1,17 @@
 package fsext
 
 import (
+	"errors"
 	"fmt"
 	"os"
 	"path/filepath"
-	"sort"
+	"slices"
 	"strings"
 	"time"
 
 	"github.com/bmatcuk/doublestar/v4"
 	"github.com/charlievieth/fastwalk"
+	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/home"
 )
 
@@ -80,10 +82,9 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool,
 	pattern = filepath.ToSlash(pattern)
 
 	walker := NewFastGlobWalker(searchPath)
-	var matches []FileInfo
+	found := csync.NewSlice[FileInfo]()
 	conf := fastwalk.Config{
-		Follow: true,
-		// Use forward slashes when running a Windows binary under WSL or MSYS
+		Follow:  true,
 		ToSlash: fastwalk.DefaultToSlash(),
 		Sort:    fastwalk.SortFilesFirst,
 	}
@@ -121,31 +122,26 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool,
 			return nil
 		}
 
-		matches = append(matches, FileInfo{Path: path, ModTime: info.ModTime()})
-		if limit > 0 && len(matches) >= limit*2 {
+		found.Append(FileInfo{Path: path, ModTime: info.ModTime()})
+		if limit > 0 && found.Len() >= limit*2 { // NOTE: why x2?
 			return filepath.SkipAll
 		}
 		return nil
 	})
-	if err != nil {
+	if err != nil && !errors.Is(err, filepath.SkipAll) {
 		return nil, false, fmt.Errorf("fastwalk error: %w", err)
 	}
 
-	sort.Slice(matches, func(i, j int) bool {
-		return matches[i].ModTime.After(matches[j].ModTime)
+	matches := slices.SortedFunc(found.Seq(), func(a, b FileInfo) int {
+		return b.ModTime.Compare(a.ModTime)
 	})
-
-	truncated := false
-	if limit > 0 && len(matches) > limit {
-		matches = matches[:limit]
-		truncated = true
-	}
+	matches, truncated := truncate(matches, limit)
 
 	results := make([]string, len(matches))
 	for i, m := range matches {
 		results[i] = m.Path
 	}
-	return results, truncated, nil
+	return results, truncated || errors.Is(err, filepath.SkipAll), nil
 }
 
 // ShouldExcludeFile checks if a file should be excluded from processing
@@ -155,36 +151,6 @@ func ShouldExcludeFile(rootPath, filePath string) bool {
 		shouldIgnore(filePath, nil)
 }
 
-// WalkDirectories walks a directory tree and calls the provided function for each directory,
-// respecting hierarchical .gitignore/.crushignore files like git does.
-func WalkDirectories(rootPath string, fn func(path string, d os.DirEntry, err error) error) error {
-	dl := NewDirectoryLister(rootPath)
-
-	conf := fastwalk.Config{
-		Follow:  true,
-		ToSlash: fastwalk.DefaultToSlash(),
-		Sort:    fastwalk.SortDirsFirst,
-	}
-
-	return fastwalk.Walk(&conf, rootPath, func(path string, d os.DirEntry, err error) error {
-		if err != nil {
-			return fn(path, d, err)
-		}
-
-		// Only process directories
-		if !d.IsDir() {
-			return nil
-		}
-
-		// Check if directory should be ignored
-		if dl.shouldIgnore(path, nil) {
-			return filepath.SkipDir
-		}
-
-		return fn(path, d, err)
-	})
-}
-
 func PrettyPath(path string) string {
 	return home.Short(path)
 }
@@ -248,3 +214,10 @@ func ToWindowsLineEndings(content string) (string, bool) {
 	}
 	return content, false
 }
+
+func truncate[T any](input []T, limit int) ([]T, bool) {
+	if limit > 0 && len(input) > limit {
+		return input[:limit], true
+	}
+	return input, false
+}

internal/fsext/fileutil_test.go ๐Ÿ”—

@@ -5,7 +5,6 @@ import (
 	"os"
 	"path/filepath"
 	"testing"
-	"testing/synctest"
 	"time"
 
 	"github.com/stretchr/testify/require"
@@ -148,37 +147,35 @@ func TestGlobWithDoubleStar(t *testing.T) {
 		require.NoError(t, err)
 		require.False(t, truncated)
 
-		require.Equal(t, matches, []string{file1})
+		require.Equal(t, []string{file1}, matches)
 	})
 
 	t.Run("returns results sorted by modification time (newest first)", func(t *testing.T) {
-		synctest.Test(t, func(t *testing.T) {
-			testDir := t.TempDir()
+		testDir := t.TempDir()
 
-			file1 := filepath.Join(testDir, "file1.txt")
-			require.NoError(t, os.WriteFile(file1, []byte("first"), 0o644))
+		file1 := filepath.Join(testDir, "file1.txt")
+		require.NoError(t, os.WriteFile(file1, []byte("first"), 0o644))
 
-			file2 := filepath.Join(testDir, "file2.txt")
-			require.NoError(t, os.WriteFile(file2, []byte("second"), 0o644))
+		file2 := filepath.Join(testDir, "file2.txt")
+		require.NoError(t, os.WriteFile(file2, []byte("second"), 0o644))
 
-			file3 := filepath.Join(testDir, "file3.txt")
-			require.NoError(t, os.WriteFile(file3, []byte("third"), 0o644))
+		file3 := filepath.Join(testDir, "file3.txt")
+		require.NoError(t, os.WriteFile(file3, []byte("third"), 0o644))
 
-			base := time.Now()
-			m1 := base
-			m2 := base.Add(1 * time.Millisecond)
-			m3 := base.Add(2 * time.Millisecond)
+		base := time.Now()
+		m1 := base
+		m2 := base.Add(10 * time.Hour)
+		m3 := base.Add(20 * time.Hour)
 
-			require.NoError(t, os.Chtimes(file1, m1, m1))
-			require.NoError(t, os.Chtimes(file2, m2, m2))
-			require.NoError(t, os.Chtimes(file3, m3, m3))
+		require.NoError(t, os.Chtimes(file1, m1, m1))
+		require.NoError(t, os.Chtimes(file2, m2, m2))
+		require.NoError(t, os.Chtimes(file3, m3, m3))
 
-			matches, truncated, err := GlobWithDoubleStar("*.txt", testDir, 0)
-			require.NoError(t, err)
-			require.False(t, truncated)
+		matches, truncated, err := GlobWithDoubleStar("*.txt", testDir, 0)
+		require.NoError(t, err)
+		require.False(t, truncated)
 
-			require.Equal(t, matches, []string{file3, file2, file1})
-		})
+		require.Equal(t, []string{file3, file2, file1}, matches)
 	})
 
 	t.Run("handles empty directory", func(t *testing.T) {
@@ -188,7 +185,7 @@ func TestGlobWithDoubleStar(t *testing.T) {
 		require.NoError(t, err)
 		require.False(t, truncated)
 		// Even empty directories should return the directory itself
-		require.Equal(t, matches, []string{testDir})
+		require.Equal(t, []string{testDir}, matches)
 	})
 
 	t.Run("handles non-existent search path", func(t *testing.T) {
@@ -235,39 +232,38 @@ func TestGlobWithDoubleStar(t *testing.T) {
 		matches, truncated, err = GlobWithDoubleStar("*.txt", testDir, 0)
 		require.NoError(t, err)
 		require.False(t, truncated)
-		require.Equal(t, matches, []string{goodFile})
+		require.Equal(t, []string{goodFile}, matches)
 	})
 
 	t.Run("handles mixed file and directory matching with sorting", func(t *testing.T) {
-		synctest.Test(t, func(t *testing.T) {
-			testDir := t.TempDir()
+		testDir := t.TempDir()
 
-			oldestFile := filepath.Join(testDir, "old.test")
-			require.NoError(t, os.WriteFile(oldestFile, []byte("old"), 0o644))
+		oldestFile := filepath.Join(testDir, "old.rs")
+		require.NoError(t, os.WriteFile(oldestFile, []byte("old"), 0o644))
 
-			middleDir := filepath.Join(testDir, "mid.test")
-			require.NoError(t, os.MkdirAll(middleDir, 0o755))
+		middleDir := filepath.Join(testDir, "mid.rs")
+		require.NoError(t, os.MkdirAll(middleDir, 0o755))
 
-			newestFile := filepath.Join(testDir, "new.test")
-			require.NoError(t, os.WriteFile(newestFile, []byte("new"), 0o644))
+		newestFile := filepath.Join(testDir, "new.rs")
+		require.NoError(t, os.WriteFile(newestFile, []byte("new"), 0o644))
 
-			base := time.Now()
-			tOldest := base
-			tMiddle := base.Add(1 * time.Millisecond)
-			tNewest := base.Add(2 * time.Millisecond)
+		base := time.Now()
+		tOldest := base
+		tMiddle := base.Add(10 * time.Hour)
+		tNewest := base.Add(20 * time.Hour)
 
-			// Reverse the expected order
-			require.NoError(t, os.Chtimes(newestFile, tOldest, tOldest))
-			require.NoError(t, os.Chtimes(middleDir, tMiddle, tMiddle))
-			require.NoError(t, os.Chtimes(oldestFile, tNewest, tNewest))
+		// Reverse the expected order
+		require.NoError(t, os.Chtimes(newestFile, tOldest, tOldest))
+		require.NoError(t, os.Chtimes(middleDir, tMiddle, tMiddle))
+		require.NoError(t, os.Chtimes(oldestFile, tNewest, tNewest))
 
-			matches, truncated, err := GlobWithDoubleStar("*.test", testDir, 0)
-			require.NoError(t, err)
-			require.False(t, truncated)
+		matches, truncated, err := GlobWithDoubleStar("*.rs", testDir, 0)
+		require.NoError(t, err)
+		require.False(t, truncated)
+		require.Len(t, matches, 3)
 
-			// Results should be sorted by mod time, but we set the oldestFile
-			// to have the most recent mod time
-			require.Equal(t, matches, []string{oldestFile, middleDir, newestFile})
-		})
+		// Results should be sorted by mod time, but we set the oldestFile
+		// to have the most recent mod time
+		require.Equal(t, []string{oldestFile, middleDir, newestFile}, matches)
 	})
 }

internal/fsext/ignore_test.go ๐Ÿ”—

@@ -9,14 +9,8 @@ import (
 )
 
 func TestCrushIgnore(t *testing.T) {
-	// Create a temporary directory for testing
 	tempDir := t.TempDir()
-
-	// Change to temp directory
-	oldWd, _ := os.Getwd()
-	err := os.Chdir(tempDir)
-	require.NoError(t, err)
-	defer os.Chdir(oldWd)
+	t.Chdir(tempDir)
 
 	// Create test files
 	require.NoError(t, os.WriteFile("test1.txt", []byte("test"), 0o644))

internal/fsext/lookup_test.go ๐Ÿ”—

@@ -12,15 +12,7 @@ import (
 
 func TestLookupClosest(t *testing.T) {
 	tempDir := t.TempDir()
-
-	// Change to temp directory
-	oldWd, _ := os.Getwd()
-	err := os.Chdir(tempDir)
-	require.NoError(t, err)
-
-	t.Cleanup(func() {
-		os.Chdir(oldWd)
-	})
+	t.Chdir(tempDir)
 
 	t.Run("target found in starting directory", func(t *testing.T) {
 		testDir := t.TempDir()
@@ -114,24 +106,15 @@ func TestLookupClosest(t *testing.T) {
 	})
 
 	t.Run("relative path handling", func(t *testing.T) {
-		testDir := t.TempDir()
-
-		// Change to test directory
-		oldWd, _ := os.Getwd()
-		err := os.Chdir(testDir)
-		require.NoError(t, err)
-		defer os.Chdir(oldWd)
-
 		// Create target file in current directory
-		err = os.WriteFile("target.txt", []byte("test"), 0o644)
-		require.NoError(t, err)
+		require.NoError(t, os.WriteFile("target.txt", []byte("test"), 0o644))
 
 		// Search using relative path
 		foundPath, found := LookupClosest(".", "target.txt")
 		require.True(t, found)
 
 		// Resolve symlinks to handle macOS /private/var vs /var discrepancy
-		expectedPath, err := filepath.EvalSymlinks(filepath.Join(testDir, "target.txt"))
+		expectedPath, err := filepath.EvalSymlinks(filepath.Join(tempDir, "target.txt"))
 		require.NoError(t, err)
 		actualPath, err := filepath.EvalSymlinks(foundPath)
 		require.NoError(t, err)
@@ -145,15 +128,7 @@ func TestLookupClosestWithOwnership(t *testing.T) {
 	// This test focuses on the basic functionality when ownership checks pass.
 
 	tempDir := t.TempDir()
-
-	// Change to temp directory
-	oldWd, _ := os.Getwd()
-	err := os.Chdir(tempDir)
-	require.NoError(t, err)
-
-	t.Cleanup(func() {
-		os.Chdir(oldWd)
-	})
+	t.Chdir(tempDir)
 
 	t.Run("search respects same ownership", func(t *testing.T) {
 		testDir := t.TempDir()
@@ -177,15 +152,7 @@ func TestLookupClosestWithOwnership(t *testing.T) {
 
 func TestLookup(t *testing.T) {
 	tempDir := t.TempDir()
-
-	// Change to temp directory
-	oldWd, _ := os.Getwd()
-	err := os.Chdir(tempDir)
-	require.NoError(t, err)
-
-	t.Cleanup(func() {
-		os.Chdir(oldWd)
-	})
+	t.Chdir(tempDir)
 
 	t.Run("no targets returns empty slice", func(t *testing.T) {
 		testDir := t.TempDir()
@@ -358,22 +325,9 @@ func TestLookup(t *testing.T) {
 	})
 
 	t.Run("relative path handling", func(t *testing.T) {
-		testDir := t.TempDir()
-
-		// Change to test directory
-		oldWd, _ := os.Getwd()
-		err := os.Chdir(testDir)
-		require.NoError(t, err)
-
-		t.Cleanup(func() {
-			os.Chdir(oldWd)
-		})
-
 		// Create target files in current directory
-		err = os.WriteFile("target1.txt", []byte("test1"), 0o644)
-		require.NoError(t, err)
-		err = os.WriteFile("target2.txt", []byte("test2"), 0o644)
-		require.NoError(t, err)
+		require.NoError(t, os.WriteFile("target1.txt", []byte("test1"), 0o644))
+		require.NoError(t, os.WriteFile("target2.txt", []byte("test2"), 0o644))
 
 		// Search using relative path
 		found, err := Lookup(".", "target1.txt", "target2.txt")
@@ -381,9 +335,9 @@ func TestLookup(t *testing.T) {
 		require.Len(t, found, 2)
 
 		// Resolve symlinks to handle macOS /private/var vs /var discrepancy
-		expectedPath1, err := filepath.EvalSymlinks(filepath.Join(testDir, "target1.txt"))
+		expectedPath1, err := filepath.EvalSymlinks(filepath.Join(tempDir, "target1.txt"))
 		require.NoError(t, err)
-		expectedPath2, err := filepath.EvalSymlinks(filepath.Join(testDir, "target2.txt"))
+		expectedPath2, err := filepath.EvalSymlinks(filepath.Join(tempDir, "target2.txt"))
 		require.NoError(t, err)
 
 		// Check that found paths match expected paths (order may vary)

internal/fsext/ls.go ๐Ÿ”—

@@ -1,6 +1,7 @@
 package fsext
 
 import (
+	"errors"
 	"log/slog"
 	"os"
 	"path/filepath"
@@ -71,6 +72,11 @@ var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser {
 
 		// Crush
 		".crush",
+
+		// macOS stuff
+		"OrbStack",
+		".local",
+		".share",
 	)
 })
 
@@ -200,16 +206,17 @@ func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser {
 }
 
 // ListDirectory lists files and directories in the specified path,
-func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
-	results := csync.NewSlice[string]()
-	truncated := false
+func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int) ([]string, bool, error) {
+	found := csync.NewSlice[string]()
 	dl := NewDirectoryLister(initialPath)
 
+	slog.Debug("listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns)
+
 	conf := fastwalk.Config{
-		Follow: true,
-		// Use forward slashes when running a Windows binary under WSL or MSYS
-		ToSlash: fastwalk.DefaultToSlash(),
-		Sort:    fastwalk.SortDirsFirst,
+		Follow:   true,
+		ToSlash:  fastwalk.DefaultToSlash(),
+		Sort:     fastwalk.SortDirsFirst,
+		MaxDepth: depth,
 	}
 
 	err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
@@ -228,19 +235,19 @@ func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]st
 			if d.IsDir() {
 				path = path + string(filepath.Separator)
 			}
-			results.Append(path)
+			found.Append(path)
 		}
 
-		if limit > 0 && results.Len() >= limit {
-			truncated = true
+		if limit > 0 && found.Len() >= limit {
 			return filepath.SkipAll
 		}
 
 		return nil
 	})
-	if err != nil && results.Len() == 0 {
-		return nil, truncated, err
+	if err != nil && !errors.Is(err, filepath.SkipAll) {
+		return nil, false, err
 	}
 
-	return slices.Collect(results.Seq()), truncated, nil
+	matches, truncated := truncate(slices.Collect(found.Seq()), limit)
+	return matches, truncated || errors.Is(err, filepath.SkipAll), nil
 }

internal/fsext/ls_test.go ๐Ÿ”—

@@ -5,26 +5,11 @@ import (
 	"path/filepath"
 	"testing"
 
-	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
-func chdir(t *testing.T, dir string) {
-	original, err := os.Getwd()
-	require.NoError(t, err)
-
-	err = os.Chdir(dir)
-	require.NoError(t, err)
-
-	t.Cleanup(func() {
-		err := os.Chdir(original)
-		require.NoError(t, err)
-	})
-}
-
 func TestListDirectory(t *testing.T) {
-	tempDir := t.TempDir()
-	chdir(t, tempDir)
+	tmp := t.TempDir()
 
 	testFiles := map[string]string{
 		"regular.txt":     "content",
@@ -35,32 +20,40 @@ func TestListDirectory(t *testing.T) {
 		"build.log":       "build output",
 	}
 
-	for filePath, content := range testFiles {
-		dir := filepath.Dir(filePath)
-		if dir != "." {
-			require.NoError(t, os.MkdirAll(dir, 0o755))
-		}
-
-		err := os.WriteFile(filePath, []byte(content), 0o644)
-		require.NoError(t, err)
+	for name, content := range testFiles {
+		fp := filepath.Join(tmp, name)
+		dir := filepath.Dir(fp)
+		require.NoError(t, os.MkdirAll(dir, 0o755))
+		require.NoError(t, os.WriteFile(fp, []byte(content), 0o644))
 	}
 
-	files, truncated, err := ListDirectory(".", nil, 0)
-	require.NoError(t, err)
-	assert.False(t, truncated)
-	assert.Equal(t, len(files), 4)
+	t.Run("no limit", func(t *testing.T) {
+		files, truncated, err := ListDirectory(tmp, nil, -1, -1)
+		require.NoError(t, err)
+		require.False(t, truncated)
+		require.Len(t, files, 4)
+		require.ElementsMatch(t, []string{
+			"regular.txt",
+			"subdir",
+			"subdir/.another",
+			"subdir/file.go",
+		}, relPaths(t, files, tmp))
+	})
+	t.Run("limit", func(t *testing.T) {
+		files, truncated, err := ListDirectory(tmp, nil, -1, 2)
+		require.NoError(t, err)
+		require.True(t, truncated)
+		require.Len(t, files, 2)
+	})
+}
 
-	fileSet := make(map[string]bool)
-	for _, file := range files {
-		fileSet[filepath.ToSlash(file)] = true
+func relPaths(tb testing.TB, in []string, base string) []string {
+	tb.Helper()
+	out := make([]string, 0, len(in))
+	for _, p := range in {
+		rel, err := filepath.Rel(base, p)
+		require.NoError(tb, err)
+		out = append(out, filepath.ToSlash(rel))
 	}
-
-	assert.True(t, fileSet["./regular.txt"])
-	assert.True(t, fileSet["./subdir/"])
-	assert.True(t, fileSet["./subdir/file.go"])
-	assert.True(t, fileSet["./regular.txt"])
-
-	assert.False(t, fileSet["./.hidden"])
-	assert.False(t, fileSet["./.gitignore"])
-	assert.False(t, fileSet["./build.log"])
+	return out
 }

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

@@ -1,3 +1,4 @@
+// Package agent contains the implementation of the AI agent service.
 package agent
 
 import (
@@ -5,6 +6,7 @@ import (
 	"errors"
 	"fmt"
 	"log/slog"
+	"maps"
 	"slices"
 	"strings"
 	"time"
@@ -65,11 +67,13 @@ type agent struct {
 	sessions    session.Service
 	messages    message.Service
 	permissions permission.Service
-	mcpTools    []McpTool
+	baseTools   *csync.Map[string, tools.BaseTool]
+	mcpTools    *csync.Map[string, tools.BaseTool]
+	lspClients  *csync.Map[string, *lsp.Client]
 
-	tools *csync.LazySlice[tools.BaseTool]
 	// We need this to be able to update it when model changes
-	agentToolFn func() (tools.BaseTool, error)
+	agentToolFn  func() (tools.BaseTool, error)
+	cleanupFuncs []func()
 
 	provider   provider.Provider
 	providerID string
@@ -171,14 +175,16 @@ func NewAgent(
 		return nil, err
 	}
 
-	toolFn := func() []tools.BaseTool {
-		slog.Info("Initializing agent tools", "agent", agentCfg.ID)
+	baseToolsFn := func() map[string]tools.BaseTool {
+		slog.Debug("Initializing agent base tools", "agent", agentCfg.ID)
 		defer func() {
-			slog.Info("Initialized agent tools", "agent", agentCfg.ID)
+			slog.Debug("Initialized agent base tools", "agent", agentCfg.ID)
 		}()
 
+		// Base tools available to all agents
 		cwd := cfg.WorkingDir()
-		allTools := []tools.BaseTool{
+		result := make(map[string]tools.BaseTool)
+		for _, tool := range []tools.BaseTool{
 			tools.NewBashTool(permissions, cwd, cfg.Options.Attribution),
 			tools.NewDownloadTool(permissions, cwd),
 			tools.NewEditTool(lspClients, permissions, history, cwd),
@@ -190,36 +196,25 @@ func NewAgent(
 			tools.NewSourcegraphTool(),
 			tools.NewViewTool(lspClients, permissions, cwd),
 			tools.NewWriteTool(lspClients, permissions, history, cwd),
+		} {
+			result[tool.Name()] = tool
 		}
+		return result
+	}
+	mcpToolsFn := func() map[string]tools.BaseTool {
+		slog.Debug("Initializing agent mcp tools", "agent", agentCfg.ID)
+		defer func() {
+			slog.Debug("Initialized agent mcp tools", "agent", agentCfg.ID)
+		}()
 
 		mcpToolsOnce.Do(func() {
-			mcpTools = doGetMCPTools(ctx, permissions, cfg)
+			doGetMCPTools(ctx, permissions, cfg)
 		})
 
-		withCoderTools := func(t []tools.BaseTool) []tools.BaseTool {
-			if agentCfg.ID == "coder" {
-				t = append(t, mcpTools...)
-				if lspClients.Len() > 0 {
-					t = append(t, tools.NewDiagnosticsTool(lspClients))
-				}
-			}
-			return t
-		}
-
-		if agentCfg.AllowedTools == nil {
-			return withCoderTools(allTools)
-		}
-
-		var filteredTools []tools.BaseTool
-		for _, tool := range allTools {
-			if slices.Contains(agentCfg.AllowedTools, tool.Name()) {
-				filteredTools = append(filteredTools, tool)
-			}
-		}
-		return withCoderTools(filteredTools)
+		return maps.Collect(mcpTools.Seq2())
 	}
 
-	return &agent{
+	a := &agent{
 		Broker:              pubsub.NewBroker[AgentEvent](),
 		agentCfg:            agentCfg,
 		provider:            agentProvider,
@@ -231,10 +226,14 @@ func NewAgent(
 		summarizeProviderID: string(providerCfg.ID),
 		agentToolFn:         agentToolFn,
 		activeRequests:      csync.NewMap[string, context.CancelFunc](),
-		tools:               csync.NewLazySlice(toolFn),
+		mcpTools:            csync.NewLazyMap(mcpToolsFn),
+		baseTools:           csync.NewLazyMap(baseToolsFn),
 		promptQueue:         csync.NewMap[string, []string](),
 		permissions:         permissions,
-	}, nil
+		lspClients:          lspClients,
+	}
+	a.setupEvents(ctx)
+	return a, nil
 }
 
 func (a *agent) Model() catwalk.Model {
@@ -517,7 +516,18 @@ func (a *agent) createUserMessage(ctx context.Context, sessionID, content string
 }
 
 func (a *agent) getAllTools() ([]tools.BaseTool, error) {
-	allTools := slices.Collect(a.tools.Seq())
+	var allTools []tools.BaseTool
+	for tool := range a.baseTools.Seq() {
+		if a.agentCfg.AllowedTools == nil || slices.Contains(a.agentCfg.AllowedTools, tool.Name()) {
+			allTools = append(allTools, tool)
+		}
+	}
+	if a.agentCfg.ID == "coder" {
+		allTools = slices.AppendSeq(allTools, a.mcpTools.Seq())
+		if a.lspClients.Len() > 0 {
+			allTools = append(allTools, tools.NewDiagnosticsTool(a.lspClients))
+		}
+	}
 	if a.agentToolFn != nil {
 		agentTool, agentToolErr := a.agentToolFn()
 		if agentToolErr != nil {
@@ -591,7 +601,7 @@ loop:
 		default:
 			// Continue processing
 			var tool tools.BaseTool
-			allTools, _ := a.getAllTools()
+			allTools, _ = a.getAllTools()
 			for _, availableTool := range allTools {
 				if availableTool.Info().Name == toolCall.Name {
 					tool = availableTool
@@ -960,6 +970,12 @@ func (a *agent) CancelAll() {
 		a.Cancel(key) // key is sessionID
 	}
 
+	for _, cleanup := range a.cleanupFuncs {
+		if cleanup != nil {
+			cleanup()
+		}
+	}
+
 	timeout := time.After(5 * time.Second)
 	for a.IsBusy() {
 		select {
@@ -1071,3 +1087,48 @@ func (a *agent) UpdateModel() error {
 
 	return nil
 }
+
+func (a *agent) setupEvents(ctx context.Context) {
+	ctx, cancel := context.WithCancel(ctx)
+
+	go func() {
+		subCh := SubscribeMCPEvents(ctx)
+
+		for {
+			select {
+			case event, ok := <-subCh:
+				if !ok {
+					slog.Debug("MCPEvents subscription channel closed")
+					return
+				}
+				switch event.Payload.Type {
+				case MCPEventToolsListChanged:
+					name := event.Payload.Name
+					c, ok := mcpClients.Get(name)
+					if !ok {
+						slog.Warn("MCP client not found for tools update", "name", name)
+						continue
+					}
+					cfg := config.Get()
+					tools, err := getTools(ctx, name, a.permissions, c, cfg.WorkingDir())
+					if err != nil {
+						slog.Error("error listing tools", "error", err)
+						updateMCPState(name, MCPStateError, err, nil, 0)
+						_ = c.Close()
+						continue
+					}
+					updateMcpTools(name, tools)
+					a.mcpTools.Reset(maps.Collect(mcpTools.Seq2()))
+					updateMCPState(name, MCPStateConnected, nil, c, a.mcpTools.Len())
+				default:
+					continue
+				}
+			case <-ctx.Done():
+				slog.Debug("MCPEvents subscription cancelled")
+				return
+			}
+		}
+	}()
+
+	a.cleanupFuncs = append(a.cleanupFuncs, cancel)
+}

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

@@ -6,9 +6,11 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"io"
 	"log/slog"
 	"maps"
-	"slices"
+	"net/http"
+	"os/exec"
 	"strings"
 	"sync"
 	"time"
@@ -20,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
@@ -54,7 +54,8 @@ func (s MCPState) String() string {
 type MCPEventType string
 
 const (
-	MCPEventStateChanged MCPEventType = "state_changed"
+	MCPEventStateChanged     MCPEventType = "state_changed"
+	MCPEventToolsListChanged MCPEventType = "tools_list_changed"
 )
 
 // MCPEvent represents an event in the MCP system
@@ -71,22 +72,23 @@ type MCPClientInfo struct {
 	Name        string
 	State       MCPState
 	Error       error
-	Client      *client.Client
+	Client      *mcp.ClientSession
 	ToolCount   int
 	ConnectedAt time.Time
 }
 
 var (
-	mcpToolsOnce sync.Once
-	mcpTools     []tools.BaseTool
-	mcpClients   = csync.NewMap[string, *client.Client]()
-	mcpStates    = csync.NewMap[string, MCPClientInfo]()
-	mcpBroker    = pubsub.NewBroker[MCPEvent]()
+	mcpToolsOnce    sync.Once
+	mcpTools        = csync.NewMap[string, tools.BaseTool]()
+	mcpClient2Tools = csync.NewMap[string, []tools.BaseTool]()
+	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
 }
@@ -96,14 +98,9 @@ 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)
-	}
+	input := b.tool.InputSchema.(map[string]any)
+	required, _ := input["required"].([]string)
+	parameters, _ := input["properties"].(map[string]any)
 	return tools.ToolInfo{
 		Name:        fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name),
 		Description: b.tool.Description,
@@ -122,11 +119,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
@@ -134,8 +129,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))
 		}
@@ -143,8 +138,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)
 	}
@@ -156,20 +151,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) {
@@ -196,8 +191,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
 	}
@@ -229,7 +224,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,
@@ -237,8 +232,12 @@ func updateMCPState(name string, state MCPState, err error, client *client.Clien
 		Client:    client,
 		ToolCount: toolCount,
 	}
-	if state == MCPStateConnected {
+	switch state {
+	case MCPStateConnected:
 		info.ConnectedAt = time.Now()
+	case MCPStateError:
+		updateMcpTools(name, nil)
+		mcpClients.Del(name)
 	}
 	mcpStates.Set(name, info)
 
@@ -256,7 +255,10 @@ func updateMCPState(name string, state MCPState, err error, client *client.Clien
 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))
 		}
 	}
@@ -264,20 +266,8 @@ 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) []tools.BaseTool {
+func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) {
 	var wg sync.WaitGroup
-	result := csync.NewSlice[tools.BaseTool]()
-
 	// Initialize states for all configured MCPs
 	for name, m := range cfg.MCP {
 		if m.Disabled {
@@ -311,11 +301,13 @@ 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
 			}
 
+			mcpClients.Set(name, c)
+
 			tools, err := getTools(ctx, name, permissions, c, cfg.WorkingDir())
 			if err != nil {
 				slog.Error("error listing tools", "error", err)
@@ -324,45 +316,68 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con
 				return
 			}
 
+			updateMcpTools(name, tools)
 			mcpClients.Set(name, c)
 			updateMCPState(name, MCPStateConnected, nil, c, len(tools))
-			result.Append(tools...)
 		}(name, m)
 	}
 	wg.Wait()
-	return slices.Collect(result.Seq())
 }
 
-func createAndInitializeClient(ctx context.Context, name string, m config.MCPConfig, resolver config.VariableResolver) (*client.Client, error) {
-	c, err := createMcpClient(name, m, resolver)
-	if err != nil {
-		updateMCPState(name, MCPStateError, err, nil, 0)
-		slog.Error("error creating mcp client", "error", err, "name", name)
-		return nil, err
+// updateMcpTools updates the global mcpTools and mcpClient2Tools maps
+func updateMcpTools(mcpName string, tools []tools.BaseTool) {
+	if len(tools) == 0 {
+		mcpClient2Tools.Del(mcpName)
+	} else {
+		mcpClient2Tools.Set(mcpName, tools)
+	}
+	for _, tools := range mcpClient2Tools.Seq2() {
+		for _, t := range tools {
+			mcpTools.Set(t.Name(), t)
+		}
 	}
+}
 
-	// XXX: ideally we should be able to use context.WithTimeout here, but,
-	// the SSE MCP client will start failing once that context is canceled.
+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)
-	if err := c.Start(mcpCtx); err != nil {
-		updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0)
-		slog.Error("error starting mcp client", "error", err, "name", name)
-		_ = c.Close()
-		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
 	}
-	if _, err := c.Initialize(mcpCtx, mcpInitRequest); err != nil {
+
+	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,
+		},
+	)
+
+	session, err := client.Connect(mcpCtx, transport, nil)
+	if err != nil {
 		updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0)
-		slog.Error("error initializing mcp client", "error", err, "name", name)
-		_ = c.Close()
+		slog.Error("error starting mcp client", "error", err, "name", name)
 		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 {
@@ -372,7 +387,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)
@@ -382,44 +397,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/prompt/coder.go ๐Ÿ”—

@@ -53,7 +53,7 @@ func getEnvironmentInfo() string {
 	isGit := isGitRepo(cwd)
 	platform := runtime.GOOS
 	date := time.Now().Format("1/2/2006")
-	output, _ := tools.ListDirectoryTree(cwd, nil)
+	output, _, _ := tools.ListDirectoryTree(cwd, tools.LSParams{})
 	return fmt.Sprintf(`Here is useful information about the environment you are running in:
 <env>
 Working directory: %s

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 ๐Ÿ”—

@@ -1,6 +1,7 @@
 package tools
 
 import (
+	"cmp"
 	"context"
 	_ "embed"
 	"encoding/json"
@@ -9,6 +10,7 @@ import (
 	"path/filepath"
 	"strings"
 
+	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/permission"
 )
@@ -16,11 +18,13 @@ import (
 type LSParams struct {
 	Path   string   `json:"path"`
 	Ignore []string `json:"ignore"`
+	Depth  int      `json:"depth"`
 }
 
 type LSPermissionsParams struct {
 	Path   string   `json:"path"`
 	Ignore []string `json:"ignore"`
+	Depth  int      `json:"depth"`
 }
 
 type TreeNode struct {
@@ -42,7 +46,7 @@ type lsTool struct {
 
 const (
 	LSToolName = "ls"
-	MaxLSFiles = 1000
+	maxLSFiles = 1000
 )
 
 //go:embed ls.md
@@ -68,6 +72,10 @@ func (l *lsTool) Info() ToolInfo {
 				"type":        "string",
 				"description": "The path to the directory to list (defaults to current working directory)",
 			},
+			"depth": map[string]any{
+				"type":        "integer",
+				"description": "The maximum depth to traverse",
+			},
 			"ignore": map[string]any{
 				"type":        "array",
 				"description": "List of glob patterns to ignore",
@@ -86,13 +94,7 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
 		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
 	}
 
-	searchPath := params.Path
-	if searchPath == "" {
-		searchPath = l.workingDir
-	}
-
-	var err error
-	searchPath, err = fsext.Expand(searchPath)
+	searchPath, err := fsext.Expand(cmp.Or(params.Path, l.workingDir))
 	if err != nil {
 		return ToolResponse{}, fmt.Errorf("error expanding path: %w", err)
 	}
@@ -137,44 +139,49 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
 		}
 	}
 
-	output, err := ListDirectoryTree(searchPath, params.Ignore)
+	output, metadata, err := ListDirectoryTree(searchPath, params)
 	if err != nil {
 		return ToolResponse{}, err
 	}
 
-	// Get file count for metadata
-	files, truncated, err := fsext.ListDirectory(searchPath, params.Ignore, MaxLSFiles)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("error listing directory for metadata: %w", err)
-	}
-
 	return WithResponseMetadata(
 		NewTextResponse(output),
-		LSResponseMetadata{
-			NumberOfFiles: len(files),
-			Truncated:     truncated,
-		},
+		metadata,
 	), nil
 }
 
-func ListDirectoryTree(searchPath string, ignore []string) (string, error) {
+func ListDirectoryTree(searchPath string, params LSParams) (string, LSResponseMetadata, error) {
 	if _, err := os.Stat(searchPath); os.IsNotExist(err) {
-		return "", fmt.Errorf("path does not exist: %s", searchPath)
+		return "", LSResponseMetadata{}, fmt.Errorf("path does not exist: %s", searchPath)
 	}
 
-	files, truncated, err := fsext.ListDirectory(searchPath, ignore, MaxLSFiles)
+	ls := config.Get().Tools.Ls
+	depth, limit := ls.Limits()
+	maxFiles := cmp.Or(limit, maxLSFiles)
+	files, truncated, err := fsext.ListDirectory(
+		searchPath,
+		params.Ignore,
+		cmp.Or(params.Depth, depth),
+		maxFiles,
+	)
 	if err != nil {
-		return "", fmt.Errorf("error listing directory: %w", err)
+		return "", LSResponseMetadata{}, fmt.Errorf("error listing directory: %w", err)
 	}
 
+	metadata := LSResponseMetadata{
+		NumberOfFiles: len(files),
+		Truncated:     truncated,
+	}
 	tree := createFileTree(files, searchPath)
-	output := printTree(tree, searchPath)
 
+	var output string
 	if truncated {
-		output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %d files and directories are included below:\n\n%s", MaxLSFiles, MaxLSFiles, output)
+		output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %[1]d files and directories are included below.\n", maxFiles)
 	}
-
-	return output, nil
+	if depth > 0 {
+		output = fmt.Sprintf("The directory tree is shown up to a depth of %d. Use a higher depth and a specific path to see more levels.\n", cmp.Or(params.Depth, depth))
+	}
+	return output + "\n" + printTree(tree, searchPath), metadata, nil
 }
 
 func createFileTree(sortedPaths []string, rootPath string) []*TreeNode {

internal/tui/components/chat/editor/editor.go ๐Ÿ”—

@@ -480,7 +480,9 @@ func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
 }
 
 func (m *editorCmp) startCompletions() tea.Msg {
-	files, _, _ := fsext.ListDirectory(".", nil, 0)
+	ls := m.app.Config().Options.TUI.Completions
+	depth, limit := ls.Limits()
+	files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
 	slices.Sort(files)
 	completionItems := make([]completions.Completion, 0, len(files))
 	for _, file := range files {

internal/tui/components/core/core.go ๐Ÿ”—

@@ -110,14 +110,17 @@ func Status(opts StatusOpts, width int) string {
 			extraContentWidth += 1
 		}
 		description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContentWidth, "โ€ฆ")
+		description = t.S().Base.Foreground(descriptionColor).Render(description)
 	}
-	description = t.S().Base.Foreground(descriptionColor).Render(description)
 
 	content := []string{}
 	if icon != "" {
 		content = append(content, icon)
 	}
-	content = append(content, title, description)
+	content = append(content, title)
+	if description != "" {
+		content = append(content, description)
+	}
 	if opts.ExtraContent != "" {
 		content = append(content, opts.ExtraContent)
 	}

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
 }

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

internal/tui/components/lsp/lsp.go ๐Ÿ”—

@@ -56,32 +56,7 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
 			break
 		}
 
-		// Determine icon color and description based on state
-		icon := t.ItemOfflineIcon
-		description := l.LSP.Command
-
-		if l.LSP.Disabled {
-			description = t.S().Subtle.Render("disabled")
-		} else if state, exists := lspStates[l.Name]; exists {
-			switch state.State {
-			case lsp.StateStarting:
-				icon = t.ItemBusyIcon
-				description = t.S().Subtle.Render("starting...")
-			case lsp.StateReady:
-				icon = t.ItemOnlineIcon
-				description = l.LSP.Command
-			case lsp.StateError:
-				icon = t.ItemErrorIcon
-				if state.Error != nil {
-					description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
-				} else {
-					description = t.S().Subtle.Render("error")
-				}
-			case lsp.StateDisabled:
-				icon = t.ItemOfflineIcon.Foreground(t.FgMuted)
-				description = t.S().Base.Foreground(t.FgMuted).Render("no root markers found")
-			}
-		}
+		icon, description := iconAndDescription(l, t, lspStates)
 
 		// Calculate diagnostic counts if we have LSP clients
 		var extraContent string
@@ -134,6 +109,30 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
 	return lspList
 }
 
+func iconAndDescription(l config.LSP, t *styles.Theme, states map[string]app.LSPClientInfo) (lipgloss.Style, string) {
+	if l.LSP.Disabled {
+		return t.ItemOfflineIcon.Foreground(t.FgMuted), t.S().Subtle.Render("disabled")
+	}
+
+	info := states[l.Name]
+	switch info.State {
+	case lsp.StateStarting:
+		return t.ItemBusyIcon, t.S().Subtle.Render("starting...")
+	case lsp.StateReady:
+		return t.ItemOnlineIcon, ""
+	case lsp.StateError:
+		description := t.S().Subtle.Render("error")
+		if info.Error != nil {
+			description = t.S().Subtle.Render(fmt.Sprintf("error: %s", info.Error.Error()))
+		}
+		return t.ItemErrorIcon, description
+	case lsp.StateDisabled:
+		return t.ItemOfflineIcon.Foreground(t.FgMuted), t.S().Subtle.Render("inactive")
+	default:
+		return t.ItemOfflineIcon, ""
+	}
+}
+
 // RenderLSPBlock renders a complete LSP block with optional truncation indicator.
 func RenderLSPBlock(lspClients *csync.Map[string, *lsp.Client], opts RenderOptions, showTruncationIndicator bool) string {
 	t := styles.CurrentTheme()

internal/tui/components/mcp/mcp.go ๐Ÿ”—

@@ -55,7 +55,7 @@ func RenderMCPList(opts RenderOptions) []string {
 
 		// Determine icon and color based on state
 		icon := t.ItemOfflineIcon
-		description := l.MCP.Command
+		description := ""
 		extraContent := ""
 
 		if state, exists := mcpStates[l.Name]; exists {

internal/tui/tui.go ๐Ÿ”—

@@ -3,6 +3,7 @@ package tui
 import (
 	"context"
 	"fmt"
+	"math/rand"
 	"strings"
 	"time"
 
@@ -612,6 +613,11 @@ func (a *appModel) View() tea.View {
 
 	view.Layer = canvas
 	view.Cursor = cursor
+	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
 }
 

main.go ๐Ÿ”—

@@ -3,23 +3,14 @@ package main
 import (
 	"log/slog"
 	"net/http"
+	_ "net/http/pprof"
 	"os"
 
-	_ "net/http/pprof" // profiling
-
-	_ "github.com/joho/godotenv/autoload" // automatically load .env files
-
 	"github.com/charmbracelet/crush/internal/cmd"
-	"github.com/charmbracelet/crush/internal/event"
-	"github.com/charmbracelet/crush/internal/log"
+	_ "github.com/joho/godotenv/autoload"
 )
 
 func main() {
-	defer log.RecoverPanic("main", func() {
-		event.Flush()
-		slog.Error("Application terminated due to unhandled panic")
-	})
-
 	if os.Getenv("CRUSH_PROFILE") != "" {
 		go func() {
 			slog.Info("Serving pprof at localhost:6060")

schema.json ๐Ÿ”—

@@ -19,6 +19,28 @@
       "additionalProperties": false,
       "type": "object"
     },
+    "Completions": {
+      "properties": {
+        "max_depth": {
+          "type": "integer",
+          "description": "Maximum depth for the ls tool",
+          "default": 0,
+          "examples": [
+            10
+          ]
+        },
+        "max_items": {
+          "type": "integer",
+          "description": "Maximum number of items to return for the ls tool",
+          "default": 1000,
+          "examples": [
+            100
+          ]
+        }
+      },
+      "additionalProperties": false,
+      "type": "object"
+    },
     "Config": {
       "properties": {
         "$schema": {
@@ -53,10 +75,17 @@
         "permissions": {
           "$ref": "#/$defs/Permissions",
           "description": "Permission settings for tool usage"
+        },
+        "tools": {
+          "$ref": "#/$defs/Tools",
+          "description": "Tool configurations"
         }
       },
       "additionalProperties": false,
-      "type": "object"
+      "type": "object",
+      "required": [
+        "tools"
+      ]
     },
     "LSPConfig": {
       "properties": {
@@ -484,10 +513,51 @@
             "split"
           ],
           "description": "Diff mode for the TUI interface"
+        },
+        "completions": {
+          "$ref": "#/$defs/Completions",
+          "description": "Completions UI options"
+        }
+      },
+      "additionalProperties": false,
+      "type": "object",
+      "required": [
+        "completions"
+      ]
+    },
+    "ToolLs": {
+      "properties": {
+        "max_depth": {
+          "type": "integer",
+          "description": "Maximum depth for the ls tool",
+          "default": 0,
+          "examples": [
+            10
+          ]
+        },
+        "max_items": {
+          "type": "integer",
+          "description": "Maximum number of items to return for the ls tool",
+          "default": 1000,
+          "examples": [
+            100
+          ]
         }
       },
       "additionalProperties": false,
       "type": "object"
+    },
+    "Tools": {
+      "properties": {
+        "ls": {
+          "$ref": "#/$defs/ToolLs"
+        }
+      },
+      "additionalProperties": false,
+      "type": "object",
+      "required": [
+        "ls"
+      ]
     }
   }
 }