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

Carlos Alexandro Becker created

Change summary

.github/cla-signatures.json                                              |  88 
.github/workflows/schema-update.yml                                      |   2 
.goreleaser.yml                                                          |  21 
CRUSH.md                                                                 |   2 
README.md                                                                |   7 
Taskfile.yaml                                                            |  30 
go.mod                                                                   |  39 
go.sum                                                                   |  82 
internal/app/app.go                                                      |   9 
internal/cmd/dirs.go                                                     |  66 
internal/cmd/dirs_test.go                                                |  46 
internal/cmd/logs.go                                                     |   6 
internal/cmd/root.go                                                     |  53 
internal/cmd/schema.go                                                   |   4 
internal/config/config.go                                                |  37 
internal/config/load.go                                                  |  41 
internal/config/provider.go                                              |  34 
internal/config/provider_test.go                                         |   2 
internal/csync/maps.go                                                   |  19 
internal/csync/maps_test.go                                              |  52 
internal/event/logger.go                                                 |   9 
internal/format/spinner.go                                               |   4 
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                                              | 158 
internal/llm/agent/mcp-tools.go                                          | 293 
internal/llm/prompt/coder.go                                             |   2 
internal/llm/provider/anthropic.go                                       |   8 
internal/llm/provider/gemini.go                                          |  14 
internal/llm/provider/openai.go                                          |  12 
internal/llm/provider/openai_test.go                                     |  76 
internal/llm/provider/vertexai.go                                        |   2 
internal/llm/tools/diagnostics.go                                        |   4 
internal/llm/tools/grep.go                                               | 152 
internal/llm/tools/grep_test.go                                          | 221 
internal/llm/tools/ls.go                                                 |  61 
internal/llm/tools/references.go                                         | 214 
internal/llm/tools/references.md                                         |  36 
internal/llm/tools/rg.go                                                 |   2 
internal/llm/tools/testdata/grep.txt                                     |   3 
internal/log/http.go                                                     |  38 
internal/lsp/client.go                                                   |  10 
internal/tui/components/anim/anim.go                                     |   3 
internal/tui/components/chat/chat.go                                     |   2 
internal/tui/components/chat/editor/editor.go                            |   8 
internal/tui/components/chat/header/header.go                            |   2 
internal/tui/components/chat/messages/messages.go                        |  11 
internal/tui/components/chat/messages/tool.go                            |   6 
internal/tui/components/chat/sidebar/sidebar.go                          |   2 
internal/tui/components/chat/splash/splash.go                            |   2 
internal/tui/components/completions/completions.go                       |   4 
internal/tui/components/core/core.go                                     |   7 
internal/tui/components/core/status/status.go                            |   2 
internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden |   2 
internal/tui/components/dialogs/commands/arguments.go                    |   9 
internal/tui/components/dialogs/commands/commands.go                     |   2 
internal/tui/components/dialogs/commands/keys.go                         |   7 
internal/tui/components/dialogs/compact/compact.go                       |   2 
internal/tui/components/dialogs/dialogs.go                               |  10 
internal/tui/components/dialogs/filepicker/filepicker.go                 |   2 
internal/tui/components/dialogs/models/apikey.go                         |   3 
internal/tui/components/dialogs/models/keys.go                           |   4 
internal/tui/components/dialogs/models/models.go                         |   2 
internal/tui/components/dialogs/permissions/keys.go                      |   2 
internal/tui/components/dialogs/permissions/permissions.go               |   2 
internal/tui/components/dialogs/quit/quit.go                             |   2 
internal/tui/components/dialogs/reasoning/reasoning.go                   |   2 
internal/tui/components/dialogs/sessions/keys.go                         |   4 
internal/tui/components/dialogs/sessions/sessions.go                     |   2 
internal/tui/components/lsp/lsp.go                                       |  51 
internal/tui/components/mcp/mcp.go                                       |   2 
internal/tui/exp/list/filterable.go                                      |  50 
internal/tui/exp/list/filterable_group.go                                |   3 
internal/tui/exp/list/grouped.go                                         |   2 
internal/tui/exp/list/items.go                                           |   5 
internal/tui/exp/list/list.go                                            |   9 
internal/tui/exp/list/list_test.go                                       |   5 
internal/tui/page/chat/chat.go                                           |   2 
internal/tui/tui.go                                                      |  42 
internal/tui/util/util.go                                                |  12 
main.go                                                                  |  13 
schema.json                                                              |  72 
86 files changed, 1,877 insertions(+), 789 deletions(-)

Detailed changes

.github/cla-signatures.json 🔗

@@ -663,6 +663,94 @@
       "created_at": "2025-09-26T13:30:16Z",
       "repoId": 987670088,
       "pullRequestNo": 1135
+    },
+    {
+      "name": "maxious",
+      "id": 81432,
+      "comment_id": 3341700737,
+      "created_at": "2025-09-27T13:09:22Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1141
+    },
+    {
+      "name": "Wangch29",
+      "id": 115294077,
+      "comment_id": 3344526018,
+      "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
+    },
+    {
+      "name": "BrunoKrugel",
+      "id": 30608179,
+      "comment_id": 3411978929,
+      "created_at": "2025-10-16T17:30:07Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1245
+    },
+    {
+      "name": "dpolishuk",
+      "id": 466424,
+      "comment_id": 3418756045,
+      "created_at": "2025-10-18T19:24:00Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1254
+    },
+    {
+      "name": "Jesssullivan",
+      "id": 37297218,
+      "comment_id": 3439361465,
+      "created_at": "2025-10-23T21:50:17Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1292
+    },
+    {
+      "name": "mmangkad",
+      "id": 176301910,
+      "comment_id": 3440286180,
+      "created_at": "2025-10-24T01:32:47Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1294
+    },
+    {
+      "name": "blouflab",
+      "id": 227565774,
+      "comment_id": 3444483981,
+      "created_at": "2025-10-24T18:59:01Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1296
     }
   ]
 }

.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 🔗

@@ -303,6 +303,7 @@ changelog:
       - "^docs: update$"
       - "^test:"
       - "^test\\("
+      - "^v\\d.*"
       - "merge conflict"
       - "merge conflict"
       - Merge branch
@@ -312,19 +313,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:

CRUSH.md 🔗

@@ -54,7 +54,7 @@ func TestYourFunction(t *testing.T) {
 ## Formatting
 
 - ALWAYS format any Go code you write.
-  - First, try `goftumpt -w .`.
+  - First, try `gofumpt -w .`.
   - If `gofumpt` is not available, use `goimports`.
   - If `goimports` is not available, use `gofmt`.
   - You can also use `task fmt` to run `gofumpt -w .` on the entire project,

README.md 🔗

@@ -189,8 +189,8 @@ That said, you can also set environment variables for preferred providers.
 | `AWS_ACCESS_KEY_ID`         | AWS Bedrock (Claude)                               |
 | `AWS_SECRET_ACCESS_KEY`     | AWS Bedrock (Claude)                               |
 | `AWS_REGION`                | AWS Bedrock (Claude)                               |
-| `AWS_PROFILE`               | Custom AWS Profile                                 |
-| `AWS_REGION`                | AWS Region                                         |
+| `AWS_PROFILE`               | AWS Bedrock (Custom Profile)                       |
+| `AWS_BEARER_TOKEN_BEDROCK`  | AWS Bedrock                                        |
 | `AZURE_OPENAI_API_ENDPOINT` | Azure OpenAI models                                |
 | `AZURE_OPENAI_API_KEY`      | Azure OpenAI models (optional when using Entra ID) |
 | `AZURE_OPENAI_API_VERSION`  | Azure OpenAI models                                |
@@ -479,6 +479,7 @@ Crush currently supports running Anthropic models through Bedrock, with caching
 - A Bedrock provider will appear once you have AWS configured, i.e. `aws configure`
 - Crush also expects the `AWS_REGION` or `AWS_DEFAULT_REGION` to be set
 - To use a specific AWS profile set `AWS_PROFILE` in your environment, i.e. `AWS_PROFILE=myprofile crush`
+- Alternatively to `aws configure`, you can also just set `AWS_BEARER_TOKEN_BEDROCK`
 
 ### Vertex AI Platform
 
@@ -649,8 +650,8 @@ See the [contributing guide](https://github.com/charmbracelet/crush?tab=contribu
 We’d love to hear your thoughts on this project. Need help? We gotchu. You can find us on:
 
 - [Twitter](https://twitter.com/charmcli)
-- [Discord][discord]
 - [Slack](https://charm.land/slack)
+- [Discord][discord]
 - [The Fediverse](https://mastodon.social/@charmcli)
 - [Bluesky](https://bsky.app/profile/charm.land)
 

Taskfile.yaml 🔗

@@ -2,6 +2,10 @@
 
 version: "3"
 
+vars:
+  VERSION:
+    sh: git describe --long 2>/dev/null || echo ""
+
 env:
   CGO_ENABLED: 0
   GOEXPERIMENT: greenteagc
@@ -30,15 +34,17 @@ tasks:
 
   build:
     desc: Run build
+    vars:
+      LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}'
     cmds:
-      - go build .
+      - go build {{.LDFLAGS}} .
     generates:
       - crush
 
   run:
     desc: Run build
     cmds:
-      - go run .
+      - go run . {{.CLI_ARGS}}
 
   test:
     desc: Run tests
@@ -59,8 +65,10 @@ tasks:
 
   install:
     desc: Install the application
+    vars:
+      LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}'
     cmds:
-      - go install -v .
+      - go install {{.LDFLAGS}} -v .
 
   profile:cpu:
     desc: 10s CPU profile
@@ -89,7 +97,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,7 +105,13 @@ tasks:
       - sh: "[ $(git status --porcelain=2 | wc -l) = 0 ]"
         msg: "Git is dirty"
     cmds:
-      - git tag -d nightly
-      - git tag --sign {{.NEXT}} {{.CLI_ARGS}}
-      - echo "pushing {{.NEXT}}..."
-      - git push origin --tags
+      - task: fetch-tags
+      - git commit --allow-empty -m "{{.NEXT}}"
+      - git tag --annotate --sign -m "{{.NEXT}}" {{.NEXT}} {{.CLI_ARGS}}
+      - echo "Pushing {{.NEXT}}..."
+      - git push origin main --follow-tags
+
+  fetch-tags:
+    cmds:
+      - git tag -d nightly || true
+      - git fetch --tags

go.mod 🔗

@@ -7,32 +7,33 @@ 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/atotto/clipboard v0.1.4
 	github.com/aymanbagabas/go-udiff v0.3.1
 	github.com/bmatcuk/doublestar/v4 v4.9.1
 	github.com/charlievieth/fastwalk v1.0.14
+	github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904
 	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.5
+	github.com/charmbracelet/catwalk v0.7.0
+	github.com/charmbracelet/fang v0.4.3
 	github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018
 	github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea
 	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/charmbracelet/x/exp/ordered v0.1.0
 	github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
 	github.com/google/uuid v1.6.0
 	github.com/invopop/jsonschema v0.13.0
 	github.com/joho/godotenv v1.5.1
-	github.com/mark3labs/mcp-go v0.40.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,11 +73,11 @@ 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/ultraviolet v0.0.0-20250915111650-81d4262876ef
+	github.com/charmbracelet/colorprofile v0.3.2
+	github.com/charmbracelet/ultraviolet v0.0.0-20251017140847-d4ace4d6e731
 	github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a // indirect
 	github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d
-	github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4
+	github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4
 	github.com/charmbracelet/x/term v0.2.1
 	github.com/charmbracelet/x/termios v0.1.1 // indirect
 	github.com/charmbracelet/x/windows v0.2.2 // indirect
@@ -91,6 +92,7 @@ require (
 	github.com/go-logr/logr v1.4.3 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/google/go-cmp v0.7.0 // indirect
+	github.com/google/jsonschema-go v0.3.0 // indirect
 	github.com/google/s2a-go v0.1.8 // indirect
 	github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
 	github.com/googleapis/gax-go/v2 v2.14.1 // indirect
@@ -104,7 +106,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 +118,11 @@ require (
 	github.com/ncruces/julianday v1.0.0 // indirect
 	github.com/pierrec/lz4/v4 v4.1.22 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
-	github.com/posthog/posthog-go v1.6.10
+	github.com/posthog/posthog-go v1.6.11
 	github.com/rivo/uniseg v0.4.7
 	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
 	github.com/sethvargo/go-retry v0.3.0 // indirect
 	github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect
-	github.com/spf13/cast v1.7.1 // indirect
 	github.com/spf13/pflag v1.0.9 // indirect
 	github.com/tetratelabs/wazero v1.9.0 // indirect
 	github.com/tidwall/gjson v1.18.0 // indirect
@@ -141,18 +142,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/sys v0.37.0 // indirect
+	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.31.0
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
 	google.golang.org/grpc v1.71.0 // indirect
 	google.golang.org/protobuf v1.36.8 // indirect

go.sum 🔗

@@ -30,8 +30,6 @@ github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW5
 github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
 github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
 github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
-github.com/anthropics/anthropic-sdk-go v1.12.0 h1:xPqlGnq7rWrTiHazIvCiumA0u7mGQnwDQtvA1M82h9U=
-github.com/anthropics/anthropic-sdk-go v1.12.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
 github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
@@ -76,36 +74,40 @@ github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMU
 github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
 github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICgnWlhAyg=
 github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
+github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
+github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4=
 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 h1:973OHYuq2Jx9deyuPwe/6lsuQrDCatOsjP8uCd02URE=
 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.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.5 h1:oAChAeh730gtLKK/BpaTeJHzmj3KFuEfQ7AZgf2VGHM=
+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.5/go.mod h1:SUTLq+/pGQ5qntHgt0JswfVJFfgJgWDqyvyiSLVlmbo=
+github.com/charmbracelet/catwalk v0.7.0 h1:qhLv56aeel5Q+2G/YFh9k5FhTqsozsn4HYViuAQ/Rio=
+github.com/charmbracelet/catwalk v0.7.0/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY=
 github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
 github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
-github.com/charmbracelet/fang v0.4.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=
 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea/go.mod h1:ngHerf1JLJXBrDXdphn5gFrBPriCL437uwukd5c93pM=
 github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 h1:WkwO6Ks3mSIGnGuSdKl9qDSyfbYK50z2wc2gGMggegE=
 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/ultraviolet v0.0.0-20251017140847-d4ace4d6e731 h1:Lr+igmzKpLPdb8yUZBP9noYWwCZP042z2nWPrJZTc+8=
+github.com/charmbracelet/ultraviolet v0.0.0-20251017140847-d4ace4d6e731/go.mod h1:KfWwUa0Oe//D72YlhbOq/g40L7UiGtATrvsGI3cciG8=
+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=
 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
 github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
 github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
+github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
 github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d h1:H2oh4WlSsXy8qwLd7I3eAvPd/X3S40aM9l+h47WF1eA=
 github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
-github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4 h1:ZhDGU688EHQXslD9KphRpXwK0pKP03egUoZAATUDlV0=
-github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
+github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc=
+github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
 github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
 github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
 github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
@@ -130,8 +132,6 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
-github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
-github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
 github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
 github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
 github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
@@ -144,13 +144,15 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
 github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
 github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
-github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
-github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
+github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
 github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
 github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
+github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
 github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
 github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -194,18 +196,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.40.0 h1:M0oqK412OHBKut9JwXSsj4KanSmEKpzoW8TcxoPOkAU=
-github.com/mark3labs/mcp-go v0.40.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 +220,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 +239,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 +267,6 @@ github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah
 github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
 github.com/sourcegraph/jsonrpc2 v0.2.1 h1:2GtljixMQYUYCmIg7W9aF2dFmniq/mOr2T9tFRh6zSQ=
 github.com/sourcegraph/jsonrpc2 v0.2.1/go.mod h1:ZafdZgk/axhT1cvZAPOhw+95nz2I/Ra5qMlU4gTRwIo=
-github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
-github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
 github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
 github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
 github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
@@ -338,8 +338,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=
@@ -388,8 +388,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
-golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
+golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -401,8 +401,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 +412,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
-golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
+golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
+golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
 golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
 golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -422,11 +422,13 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
+golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/api v0.211.0 h1:IUpLjq09jxBSV1lACO33CGY3jsRcbctfGzhj+ZSE/Bg=
 google.golang.org/api v0.211.0/go.mod h1:XOloB4MXFH4UTlQSGuNUxw0UT74qdENK8d6JNsXKLi0=
-google.golang.org/genai v1.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.31.0 h1:R7xDt/Dosz11vcXbZ4IgisGnzUGGau2PZOIOAnXsYjw=
+google.golang.org/genai v1.31.0/go.mod h1:7pAilaICJlQBonjKKJNhftDFv3SREhZcTe9F6nRcjbg=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
 google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=

internal/app/app.go 🔗

@@ -17,12 +17,12 @@ 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/x/ansi"
 )
 
 type App struct {
@@ -107,7 +107,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")
@@ -151,7 +150,11 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool
 	messageEvents := app.Messages.Subscribe(ctx)
 	messageReadBytes := make(map[string]int)
 
+	defer fmt.Printf(ansi.ResetProgressBar)
 	for {
+		// HACK: add it again on every iteration so it doesn't get hidden by
+		// the terminal due to inactivity.
+		fmt.Printf(ansi.SetIndeterminateProgressBar)
 		select {
 		case result := <-done:
 			stopSpinner()

internal/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/dirs_test.go 🔗

@@ -0,0 +1,46 @@
+package cmd
+
+import (
+	"bytes"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func init() {
+	os.Setenv("XDG_CONFIG_HOME", "/tmp/fakeconfig")
+	os.Setenv("XDG_DATA_HOME", "/tmp/fakedata")
+}
+
+func TestDirs(t *testing.T) {
+	var b bytes.Buffer
+	dirsCmd.SetOut(&b)
+	dirsCmd.SetErr(&b)
+	dirsCmd.SetIn(bytes.NewReader(nil))
+	dirsCmd.Run(dirsCmd, nil)
+	expected := filepath.FromSlash("/tmp/fakeconfig/crush") + "\n" +
+		filepath.FromSlash("/tmp/fakedata/crush") + "\n"
+	require.Equal(t, expected, b.String())
+}
+
+func TestConfigDir(t *testing.T) {
+	var b bytes.Buffer
+	configDirCmd.SetOut(&b)
+	configDirCmd.SetErr(&b)
+	configDirCmd.SetIn(bytes.NewReader(nil))
+	configDirCmd.Run(configDirCmd, nil)
+	expected := filepath.FromSlash("/tmp/fakeconfig/crush") + "\n"
+	require.Equal(t, expected, b.String())
+}
+
+func TestDataDir(t *testing.T) {
+	var b bytes.Buffer
+	dataDirCmd.SetOut(&b)
+	dataDirCmd.SetErr(&b)
+	dataDirCmd.SetIn(bytes.NewReader(nil))
+	dataDirCmd.Run(dataDirCmd, nil)
+	expected := filepath.FromSlash("/tmp/fakedata/crush") + "\n"
+	require.Equal(t, expected, b.String())
+}

internal/cmd/logs.go 🔗

@@ -10,8 +10,10 @@ import (
 	"slices"
 	"time"
 
+	"github.com/charmbracelet/colorprofile"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/log/v2"
+	"github.com/charmbracelet/x/term"
 	"github.com/nxadm/tail"
 	"github.com/spf13/cobra"
 )
@@ -45,6 +47,9 @@ var logsCmd = &cobra.Command{
 
 		log.SetLevel(log.DebugLevel)
 		log.SetOutput(os.Stdout)
+		if !term.IsTerminal(os.Stdout.Fd()) {
+			log.SetColorProfile(colorprofile.NoTTY)
+		}
 
 		cfg, err := config.Load(cwd, dataDir, false)
 		if err != nil {
@@ -68,7 +73,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{
@@ -73,18 +83,15 @@ crush -y
 		// Set up the TUI.
 		program := tea.NewProgram(
 			tui.New(app),
-			tea.WithAltScreen(),
 			tea.WithContext(cmd.Context()),
-			tea.WithMouseCellMotion(),            // Use cell motion instead of all motion to reduce event flooding
-			tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
-		)
+			tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state
 
 		go app.Subscribe(program)
 
 		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 +100,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/cmd/schema.go 🔗

@@ -24,7 +24,3 @@ var schemaCmd = &cobra.Command{
 		return nil
 	},
 }
-
-func init() {
-	rootCmd.AddCommand(schemaCmd)
-}

internal/config/config.go 🔗

@@ -99,7 +99,7 @@ type MCPType string
 
 const (
 	MCPStdio MCPType = "stdio"
-	MCPSse   MCPType = "sse"
+	MCPSSE   MCPType = "sse"
 	MCPHttp  MCPType = "http"
 )
 
@@ -132,6 +132,19 @@ type TUIOptions struct {
 	DiffMode    string `json:"diff_mode,omitempty" jsonschema:"description=Diff mode for the TUI interface,enum=unified,enum=split"`
 	Transparent bool   `json:"transparent,omitempty" jsonschema:"description=Enable transparent background for the TUI interface,default=false"`
 	// 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 {
@@ -247,6 +260,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"`
@@ -265,6 +291,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
@@ -580,3 +608,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(),
 	}
 
@@ -577,6 +589,10 @@ func hasVertexCredentials(env env.Env) bool {
 }
 
 func hasAWSCredentials(env env.Env) bool {
+	if env.Get("AWS_BEARER_TOKEN_BEDROCK") != "" {
+		return true
+	}
+
 	if env.Get("AWS_ACCESS_KEY_ID") != "" && env.Get("AWS_SECRET_ACCESS_KEY") != "" {
 		return true
 	}
@@ -593,10 +609,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 +659,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/config/provider.go 🔗

@@ -126,7 +126,7 @@ func Providers(cfg *Config) ([]catwalk.Provider, error) {
 }
 
 func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string) ([]catwalk.Provider, error) {
-	cacheIsStale, cacheExists := isCacheStale(path)
+	_, cacheExists := isCacheStale(path)
 
 	catwalkGetAndSave := func() ([]catwalk.Provider, error) {
 		providers, err := client.GetProviders()
@@ -142,25 +142,6 @@ func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string)
 		return providers, nil
 	}
 
-	backgroundCacheUpdate := func() {
-		go func() {
-			slog.Info("Updating providers cache in background", "path", path)
-
-			providers, err := client.GetProviders()
-			if err != nil {
-				slog.Error("Failed to fetch providers in background from Catwalk", "error", err)
-				return
-			}
-			if len(providers) == 0 {
-				slog.Error("Empty providers list from Catwalk")
-				return
-			}
-			if err := saveProvidersInCache(path, providers); err != nil {
-				slog.Error("Failed to update providers.json in background", "error", err)
-			}
-		}()
-	}
-
 	switch {
 	case autoUpdateDisabled:
 		slog.Warn("Providers auto-update is disabled")
@@ -177,19 +158,6 @@ func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string)
 		}
 		return providers, nil
 
-	case cacheExists && !cacheIsStale:
-		slog.Info("Recent providers cache is available.", "path", path)
-
-		providers, err := loadProvidersFromCache(path)
-		if err != nil {
-			return nil, err
-		}
-		if len(providers) == 0 {
-			return catwalkGetAndSave()
-		}
-		backgroundCacheUpdate()
-		return providers, nil
-
 	default:
 		slog.Info("Cache is not available or is stale. Fetching providers from Catwalk.", "path", path)
 

internal/config/provider_test.go 🔗

@@ -57,7 +57,7 @@ func TestProvider_loadProvidersWithIssues(t *testing.T) {
 	if err != nil {
 		t.Fatalf("Failed to write old providers to file: %v", err)
 	}
-	providers, err := loadProviders(false, client, tmpPath)
+	providers, err := loadProviders(true, client, tmpPath)
 	require.NoError(t, err)
 	require.NotNil(t, providers)
 	require.Len(t, providers, 1)

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/event/logger.go 🔗

@@ -1,6 +1,7 @@
 package event
 
 import (
+	"fmt"
 	"log/slog"
 
 	"github.com/posthog/posthog-go"
@@ -11,17 +12,17 @@ var _ posthog.Logger = logger{}
 type logger struct{}
 
 func (logger) Debugf(format string, args ...any) {
-	slog.Debug(format, args...)
+	slog.Debug(fmt.Sprintf(format, args...))
 }
 
 func (logger) Logf(format string, args ...any) {
-	slog.Info(format, args...)
+	slog.Info(fmt.Sprintf(format, args...))
 }
 
 func (logger) Warnf(format string, args ...any) {
-	slog.Warn(format, args...)
+	slog.Warn(fmt.Sprintf(format, args...))
 }
 
 func (logger) Errorf(format string, args ...any) {
-	slog.Error(format, args...)
+	slog.Error(fmt.Sprintf(format, args...))
 }

internal/format/spinner.go 🔗

@@ -23,8 +23,8 @@ type model struct {
 	anim   *anim.Anim
 }
 
-func (m model) Init() tea.Cmd { return m.anim.Init() }
-func (m model) View() string  { return m.anim.View() }
+func (m model) Init() tea.Cmd  { return m.anim.Init() }
+func (m model) View() tea.View { return tea.NewView(m.anim.View()) }
 
 // Update implements tea.Model.
 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

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), tools.NewReferencesTool(a.lspClients))
+		}
+	}
 	if a.agentToolFn != nil {
 		agentTool, agentToolErr := a.agentToolFn()
 		if agentToolErr != nil {
@@ -525,6 +535,10 @@ func (a *agent) getAllTools() ([]tools.BaseTool, error) {
 		}
 		allTools = append(allTools, agentTool)
 	}
+
+	slices.SortFunc(allTools, func(a, b tools.BaseTool) int {
+		return strings.Compare(a.Name(), b.Name())
+	})
 	return allTools, nil
 }
 
@@ -552,17 +566,22 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
 	// Add the session and message ID into the context if needed by tools.
 	ctx = context.WithValue(ctx, tools.MessageIDContextKey, assistantMsg.ID)
 
-	// Process each event in the stream.
-	for event := range eventChan {
-		if processErr := a.processEvent(ctx, sessionID, &assistantMsg, event); processErr != nil {
-			if errors.Is(processErr, context.Canceled) {
-				a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
-			} else {
-				a.finishMessage(ctx, &assistantMsg, message.FinishReasonError, "API Error", processErr.Error())
+loop:
+	for {
+		select {
+		case event, ok := <-eventChan:
+			if !ok {
+				break loop
 			}
-			return assistantMsg, nil, processErr
-		}
-		if ctx.Err() != nil {
+			if processErr := a.processEvent(ctx, sessionID, &assistantMsg, event); processErr != nil {
+				if errors.Is(processErr, context.Canceled) {
+					a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
+				} else {
+					a.finishMessage(ctx, &assistantMsg, message.FinishReasonError, "API Error", processErr.Error())
+				}
+				return assistantMsg, nil, processErr
+			}
+		case <-ctx.Done():
 			a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
 			return assistantMsg, nil, ctx.Err()
 		}
@@ -586,7 +605,7 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
 		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
@@ -955,6 +974,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 {
@@ -1066,3 +1091,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,12 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"io"
 	"log/slog"
 	"maps"
-	"slices"
+	"net/http"
+	"os"
+	"os/exec"
 	"strings"
 	"sync"
 	"time"
@@ -20,9 +23,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 +55,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 +73,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 +99,26 @@ func (b *McpTool) Name() string {
 }
 
 func (b *McpTool) Info() tools.ToolInfo {
-	required := b.tool.InputSchema.Required
-	if required == nil {
-		required = make([]string, 0)
-	}
-	parameters := b.tool.InputSchema.Properties
-	if parameters == nil {
-		parameters = make(map[string]any)
+	parameters := make(map[string]any)
+	required := make([]string, 0)
+
+	if input, ok := b.tool.InputSchema.(map[string]any); ok {
+		if props, ok := input["properties"].(map[string]any); ok {
+			parameters = props
+		}
+		if req, ok := input["required"].([]any); ok {
+			// Convert []any -> []string when elements are strings
+			for _, v := range req {
+				if s, ok := v.(string); ok {
+					required = append(required, s)
+				}
+			}
+		} else if reqStr, ok := input["required"].([]string); ok {
+			// Handle case where it's already []string
+			required = reqStr
+		}
 	}
+
 	return tools.ToolInfo{
 		Name:        fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name),
 		Description: b.tool.Description,
@@ -122,11 +137,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 +147,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 +156,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 +169,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,14 +209,10 @@ 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 {
-	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 {
-		slog.Error("error listing tools", "error", err)
-		updateMCPState(name, MCPStateError, err, nil, 0)
-		c.Close()
-		mcpClients.Del(name)
-		return nil
+		return nil, err
 	}
 	mcpTools := make([]tools.BaseTool, 0, len(result.Tools))
 	for _, tool := range result.Tools {
@@ -214,7 +223,7 @@ func getTools(ctx context.Context, name string, permissions permission.Service,
 			workingDir:  workingDir,
 		})
 	}
-	return mcpTools
+	return mcpTools, nil
 }
 
 // SubscribeMCPEvents returns a channel for MCP events
@@ -233,7 +242,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,
@@ -241,8 +250,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)
 
@@ -260,7 +273,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))
 		}
 	}
@@ -268,20 +284,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 {
@@ -314,61 +318,119 @@ 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 := getTools(ctx, name, permissions, c, cfg.WorkingDir())
+			tools, err := getTools(ctx, name, permissions, c, cfg.WorkingDir())
+			if err != nil {
+				slog.Error("error listing tools", "error", err)
+				updateMCPState(name, MCPStateError, err, nil, 0)
+				c.Close()
+				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)
+// 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)
+		}
+	}
+}
+
+func createMCPSession(ctx context.Context, name string, m config.MCPConfig, resolver config.VariableResolver) (*mcp.ClientSession, error) {
+	timeout := mcpTimeout(m)
+	mcpCtx, cancel := context.WithCancel(ctx)
+	cancelTimer := time.AfterFunc(timeout, cancel)
+
+	transport, err := createMCPTransport(mcpCtx, m, resolver)
 	if err != nil {
 		updateMCPState(name, MCPStateError, err, nil, 0)
 		slog.Error("error creating mcp client", "error", err, "name", name)
+		cancel()
+		cancelTimer.Stop()
 		return nil, err
 	}
 
-	timeout := mcpTimeout(m)
-	initCtx, cancel := context.WithTimeout(ctx, timeout)
-	defer cancel()
+	client := mcp.NewClient(
+		&mcp.Implementation{
+			Name:    "crush",
+			Version: version.Version,
+			Title:   "Crush",
+		},
+		&mcp.ClientOptions{
+			ToolListChangedHandler: func(context.Context, *mcp.ToolListChangedRequest) {
+				mcpBroker.Publish(pubsub.UpdatedEvent, MCPEvent{
+					Type: MCPEventToolsListChanged,
+					Name: name,
+				})
+			},
+			KeepAlive: time.Minute * 10,
+		},
+	)
 
-	// Only call Start() for non-stdio clients, as stdio clients auto-start
-	if m.Type != config.MCPStdio {
-		if err := c.Start(initCtx); err != nil {
-			updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0)
-			slog.Error("error starting mcp client", "error", err, "name", name)
-			_ = c.Close()
-			return nil, err
-		}
-	}
-	if _, err := c.Initialize(initCtx, mcpInitRequest); err != nil {
+	session, err := client.Connect(mcpCtx, transport, nil)
+	if err != nil {
+		err = maybeStdioErr(err, transport)
 		updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0)
-		slog.Error("error initializing mcp client", "error", err, "name", name)
-		_ = c.Close()
+		slog.Error("error starting mcp client", "error", err, "name", name)
+		cancel()
+		cancelTimer.Stop()
 		return nil, err
 	}
 
+	cancelTimer.Stop()
 	slog.Info("Initialized mcp client", "name", name)
-	return c, nil
+	return session, nil
+}
+
+// maybeStdioErr if a stdio mcp prints an error in non-json format, it'll fail
+// to parse, and the cli will then close it, causing the EOF error.
+// so, if we got an EOF err, and the transport is STDIO, we try to exec it
+// again with a timeout and collect the output so we can add details to the
+// error.
+// this happens particularly when starting things with npx, e.g. if node can't
+// be found or some other error like that.
+func maybeStdioErr(err error, transport mcp.Transport) error {
+	if !errors.Is(err, io.EOF) {
+		return err
+	}
+	ct, ok := transport.(*mcp.CommandTransport)
+	if !ok {
+		return err
+	}
+	if err2 := stdioMCPCheck(ct.Command); err2 != nil {
+		err = errors.Join(err, err2)
+	}
+	return err
 }
 
 func maybeTimeoutErr(err error, timeout time.Duration) error {
-	if errors.Is(err, context.DeadlineExceeded) {
+	if errors.Is(err, context.Canceled) {
 		return fmt.Errorf("timed out after %s", timeout)
 	}
 	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)
@@ -378,46 +440,65 @@ 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 = append(os.Environ(), 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 {
 	return time.Duration(cmp.Or(m.Timeout, 15)) * time.Second
 }
+
+func stdioMCPCheck(old *exec.Cmd) error {
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
+	defer cancel()
+	cmd := exec.CommandContext(ctx, old.Path, old.Args...)
+	cmd.Env = old.Env
+	out, err := cmd.CombinedOutput()
+	if err == nil || errors.Is(ctx.Err(), context.DeadlineExceeded) {
+		return nil
+	}
+	return fmt.Errorf("%w: %s", err, string(out))
+}

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/anthropic.go 🔗

@@ -13,10 +13,10 @@ import (
 	"strings"
 	"time"
 
-	"github.com/anthropics/anthropic-sdk-go"
-	"github.com/anthropics/anthropic-sdk-go/bedrock"
-	"github.com/anthropics/anthropic-sdk-go/option"
-	"github.com/anthropics/anthropic-sdk-go/vertex"
+	"github.com/charmbracelet/anthropic-sdk-go"
+	"github.com/charmbracelet/anthropic-sdk-go/bedrock"
+	"github.com/charmbracelet/anthropic-sdk-go/option"
+	"github.com/charmbracelet/anthropic-sdk-go/vertex"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/llm/tools"

internal/llm/provider/gemini.go 🔗

@@ -43,9 +43,14 @@ func createGeminiClient(opts providerClientOptions) (*genai.Client, error) {
 	cc := &genai.ClientConfig{
 		APIKey:  opts.apiKey,
 		Backend: genai.BackendGeminiAPI,
-		HTTPOptions: genai.HTTPOptions{
-			BaseURL: opts.baseURL,
-		},
+	}
+	if opts.baseURL != "" {
+		resolvedBaseURL, err := config.Get().Resolve(opts.baseURL)
+		if err == nil && resolvedBaseURL != "" {
+			cc.HTTPOptions = genai.HTTPOptions{
+				BaseURL: resolvedBaseURL,
+			}
+		}
 	}
 	if config.Get().Options.Debug {
 		cc.HTTPClient = log.NewHTTPClient()
@@ -65,9 +70,8 @@ func (g *geminiClient) convertMessages(messages []message.Message) []*genai.Cont
 			var parts []*genai.Part
 			parts = append(parts, &genai.Part{Text: msg.Content().String()})
 			for _, binaryContent := range msg.BinaryContent() {
-				imageFormat := strings.Split(binaryContent.MIMEType, "/")
 				parts = append(parts, &genai.Part{InlineData: &genai.Blob{
-					MIMEType: imageFormat[1],
+					MIMEType: binaryContent.MIMEType,
 					Data:     binaryContent.Data,
 				}})
 			}

internal/llm/provider/openai.go 🔗

@@ -529,11 +529,19 @@ func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error)
 			return true, 0, nil
 		}
 
-		if apiErr.StatusCode != http.StatusTooManyRequests && apiErr.StatusCode != http.StatusInternalServerError {
+		if apiErr.StatusCode == http.StatusTooManyRequests {
+			// Check if this is an insufficient quota error (permanent)
+			if apiErr.Type == "insufficient_quota" || apiErr.Code == "insufficient_quota" {
+				return false, 0, fmt.Errorf("OpenAI quota exceeded: %s. Please check your plan and billing details", apiErr.Message)
+			}
+			// Other 429 errors (rate limiting) can be retried
+		} else if apiErr.StatusCode != http.StatusInternalServerError {
 			return false, 0, err
 		}
 
-		retryAfterValues = apiErr.Response.Header.Values("Retry-After")
+		if apiErr.Response != nil {
+			retryAfterValues = apiErr.Response.Header.Values("Retry-After")
+		}
 	}
 
 	if apiErr != nil {

internal/llm/provider/openai_test.go 🔗

@@ -6,6 +6,7 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"os"
+	"strings"
 	"testing"
 	"time"
 
@@ -88,3 +89,78 @@ func TestOpenAIClientStreamChoices(t *testing.T) {
 		}
 	}
 }
+
+func TestOpenAIClient429InsufficientQuotaError(t *testing.T) {
+	client := &openaiClient{
+		providerOptions: providerClientOptions{
+			modelType:     config.SelectedModelTypeLarge,
+			apiKey:        "test-key",
+			systemMessage: "test",
+			config: config.ProviderConfig{
+				ID:     "test-openai",
+				APIKey: "test-key",
+			},
+			model: func(config.SelectedModelType) catwalk.Model {
+				return catwalk.Model{
+					ID:   "test-model",
+					Name: "test-model",
+				}
+			},
+		},
+	}
+
+	// Test insufficient_quota error should not retry
+	apiErr := &openai.Error{
+		StatusCode: 429,
+		Message:    "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.",
+		Type:       "insufficient_quota",
+		Code:       "insufficient_quota",
+	}
+
+	retry, _, err := client.shouldRetry(1, apiErr)
+	if retry {
+		t.Error("Expected shouldRetry to return false for insufficient_quota error, but got true")
+	}
+	if err == nil {
+		t.Error("Expected shouldRetry to return an error for insufficient_quota, but got nil")
+	}
+	if err != nil && !strings.Contains(err.Error(), "quota") {
+		t.Errorf("Expected error message to mention quota, got: %v", err)
+	}
+}
+
+func TestOpenAIClient429RateLimitError(t *testing.T) {
+	client := &openaiClient{
+		providerOptions: providerClientOptions{
+			modelType:     config.SelectedModelTypeLarge,
+			apiKey:        "test-key",
+			systemMessage: "test",
+			config: config.ProviderConfig{
+				ID:     "test-openai",
+				APIKey: "test-key",
+			},
+			model: func(config.SelectedModelType) catwalk.Model {
+				return catwalk.Model{
+					ID:   "test-model",
+					Name: "test-model",
+				}
+			},
+		},
+	}
+
+	// Test regular rate limit error should retry
+	apiErr := &openai.Error{
+		StatusCode: 429,
+		Message:    "Rate limit reached for requests",
+		Type:       "rate_limit_exceeded",
+		Code:       "rate_limit_exceeded",
+	}
+
+	retry, _, err := client.shouldRetry(1, apiErr)
+	if !retry {
+		t.Error("Expected shouldRetry to return true for rate_limit_exceeded error, but got false")
+	}
+	if err != nil {
+		t.Errorf("Expected shouldRetry to return nil error for rate_limit_exceeded, but got: %v", err)
+	}
+}

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/diagnostics.go 🔗

@@ -23,7 +23,7 @@ type diagnosticsTool struct {
 	lspClients *csync.Map[string, *lsp.Client]
 }
 
-const DiagnosticsToolName = "diagnostics"
+const DiagnosticsToolName = "lsp_diagnostics"
 
 //go:embed diagnostics.md
 var diagnosticsDescription []byte
@@ -122,7 +122,7 @@ func getDiagnostics(filePath string, lsps *csync.Map[string, *lsp.Client]) strin
 	}
 
 	out := output.String()
-	slog.Info("Diagnostics", "output", fmt.Sprintf("%q", out))
+	slog.Info("Diagnostics", "output", out)
 	return out
 }
 

internal/llm/tools/grep.go 🔗

@@ -2,17 +2,18 @@ package tools
 
 import (
 	"bufio"
+	"bytes"
 	"context"
 	_ "embed"
 	"encoding/json"
 	"fmt"
 	"io"
+	"net/http"
 	"os"
 	"os/exec"
 	"path/filepath"
 	"regexp"
 	"sort"
-	"strconv"
 	"strings"
 	"sync"
 	"time"
@@ -81,6 +82,7 @@ type grepMatch struct {
 	path     string
 	modTime  time.Time
 	lineNum  int
+	charNum  int
 	lineText string
 }
 
@@ -188,7 +190,11 @@ func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
 				fmt.Fprintf(&output, "%s:\n", match.path)
 			}
 			if match.lineNum > 0 {
-				fmt.Fprintf(&output, "  Line %d: %s\n", match.lineNum, match.lineText)
+				if match.charNum > 0 {
+					fmt.Fprintf(&output, "  Line %d, Char %d: %s\n", match.lineNum, match.charNum, match.lineText)
+				} else {
+					fmt.Fprintf(&output, "  Line %d: %s\n", match.lineNum, match.lineText)
+				}
 			} else {
 				fmt.Fprintf(&output, "  %s\n", match.path)
 			}
@@ -251,66 +257,51 @@ func searchWithRipgrep(ctx context.Context, pattern, path, include string) ([]gr
 		return nil, err
 	}
 
-	lines := strings.Split(strings.TrimSpace(string(output)), "\n")
-	matches := make([]grepMatch, 0, len(lines))
-
-	for _, line := range lines {
-		if line == "" {
+	var matches []grepMatch
+	for line := range bytes.SplitSeq(bytes.TrimSpace(output), []byte{'\n'}) {
+		if len(line) == 0 {
 			continue
 		}
-
-		// Parse ripgrep output using null separation
-		filePath, lineNumStr, lineText, ok := parseRipgrepLine(line)
-		if !ok {
+		var match ripgrepMatch
+		if err := json.Unmarshal(line, &match); err != nil {
 			continue
 		}
-
-		lineNum, err := strconv.Atoi(lineNumStr)
-		if err != nil {
+		if match.Type != "match" {
 			continue
 		}
-
-		fileInfo, err := os.Stat(filePath)
-		if err != nil {
-			continue // Skip files we can't access
+		for _, m := range match.Data.Submatches {
+			fi, err := os.Stat(match.Data.Path.Text)
+			if err != nil {
+				continue // Skip files we can't access
+			}
+			matches = append(matches, grepMatch{
+				path:     match.Data.Path.Text,
+				modTime:  fi.ModTime(),
+				lineNum:  match.Data.LineNumber,
+				charNum:  m.Start + 1, // ensure 1-based
+				lineText: strings.TrimSpace(match.Data.Lines.Text),
+			})
+			// only get the first match of each line
+			break
 		}
-
-		matches = append(matches, grepMatch{
-			path:     filePath,
-			modTime:  fileInfo.ModTime(),
-			lineNum:  lineNum,
-			lineText: lineText,
-		})
 	}
-
 	return matches, nil
 }
 
-// parseRipgrepLine parses ripgrep output with null separation to handle Windows paths
-func parseRipgrepLine(line string) (filePath, lineNum, lineText string, ok bool) {
-	// Split on null byte first to separate filename from rest
-	parts := strings.SplitN(line, "\x00", 2)
-	if len(parts) != 2 {
-		return "", "", "", false
-	}
-
-	filePath = parts[0]
-	remainder := parts[1]
-
-	// Now split the remainder on first colon: "linenum:content"
-	colonIndex := strings.Index(remainder, ":")
-	if colonIndex == -1 {
-		return "", "", "", false
-	}
-
-	lineNumStr := remainder[:colonIndex]
-	lineText = remainder[colonIndex+1:]
-
-	if _, err := strconv.Atoi(lineNumStr); err != nil {
-		return "", "", "", false
-	}
-
-	return filePath, lineNumStr, lineText, true
+type ripgrepMatch struct {
+	Type string `json:"type"`
+	Data struct {
+		Path struct {
+			Text string `json:"text"`
+		} `json:"path"`
+		Lines struct {
+			Text string `json:"text"`
+		} `json:"lines"`
+		LineNumber int `json:"line_number"`
+		Submatches []struct {
+			Start int `json:"start"`
+		} `json:"submatches"`
+	} `json:"data"`
 }
 
 func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) {
@@ -362,7 +353,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
 			return nil
 		}
 
-		match, lineNum, lineText, err := fileContainsPattern(path, regex)
+		match, lineNum, charNum, lineText, err := fileContainsPattern(path, regex)
 		if err != nil {
 			return nil // Skip files we can't read
 		}
@@ -372,6 +363,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
 				path:     path,
 				modTime:  info.ModTime(),
 				lineNum:  lineNum,
+				charNum:  charNum,
 				lineText: lineText,
 			})
 
@@ -389,15 +381,15 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
 	return matches, nil
 }
 
-func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, string, error) {
-	// Quick binary file detection
-	if isBinaryFile(filePath) {
-		return false, 0, "", nil
+func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, int, string, error) {
+	// Only search text files.
+	if !isTextFile(filePath) {
+		return false, 0, 0, "", nil
 	}
 
 	file, err := os.Open(filePath)
 	if err != nil {
-		return false, 0, "", err
+		return false, 0, 0, "", err
 	}
 	defer file.Close()
 
@@ -406,53 +398,39 @@ func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, st
 	for scanner.Scan() {
 		lineNum++
 		line := scanner.Text()
-		if pattern.MatchString(line) {
-			return true, lineNum, line, nil
+		if loc := pattern.FindStringIndex(line); loc != nil {
+			charNum := loc[0] + 1
+			return true, lineNum, charNum, line, nil
 		}
 	}
 
-	return false, 0, "", scanner.Err()
+	return false, 0, 0, "", scanner.Err()
 }
 
-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,224 @@ 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)
+		})
+	}
+}
+
+func TestColumnMatch(t *testing.T) {
+	t.Parallel()
+
+	// Test both implementations
+	for name, fn := range map[string]func(pattern, path, include string) ([]grepMatch, error){
+		"regex": searchFilesWithRegex,
+		"rg": func(pattern, path, include string) ([]grepMatch, error) {
+			return searchWithRipgrep(t.Context(), pattern, path, include)
+		},
+	} {
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+
+			if name == "rg" && getRg() == "" {
+				t.Skip("rg is not in $PATH")
+			}
+
+			matches, err := fn("THIS", "./testdata/", "")
+			require.NoError(t, err)
+			require.Len(t, matches, 1)
+			match := matches[0]
+			require.Equal(t, 2, match.lineNum)
+			require.Equal(t, 14, match.charNum)
+			require.Equal(t, "I wanna grep THIS particular word", match.lineText)
+			require.Equal(t, "testdata/grep.txt", filepath.ToSlash(filepath.Clean(match.path)))
+		})
+	}
+}

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/llm/tools/references.go 🔗

@@ -0,0 +1,214 @@
+package tools
+
+import (
+	"cmp"
+	"context"
+	_ "embed"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"log/slog"
+	"maps"
+	"path/filepath"
+	"regexp"
+	"slices"
+	"sort"
+	"strings"
+
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
+)
+
+type ReferencesParams struct {
+	Symbol string `json:"symbol"`
+	Path   string `json:"path"`
+}
+
+type referencesTool struct {
+	lspClients *csync.Map[string, *lsp.Client]
+}
+
+const ReferencesToolName = "lsp_references"
+
+//go:embed references.md
+var referencesDescription []byte
+
+func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) BaseTool {
+	return &referencesTool{
+		lspClients,
+	}
+}
+
+func (r *referencesTool) Name() string {
+	return ReferencesToolName
+}
+
+func (r *referencesTool) Info() ToolInfo {
+	return ToolInfo{
+		Name:        ReferencesToolName,
+		Description: string(referencesDescription),
+		Parameters: map[string]any{
+			"symbol": map[string]any{
+				"type":        "string",
+				"description": "The symbol name to search for (e.g., function name, variable name, type name).",
+			},
+			"path": map[string]any{
+				"type":        "string",
+				"description": "The directory to search in. Should be the entire project most of the time. Defaults to the current working directory.",
+			},
+		},
+		Required: []string{"symbol"},
+	}
+}
+
+func (r *referencesTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
+	var params ReferencesParams
+	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
+		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
+	}
+
+	if params.Symbol == "" {
+		return NewTextErrorResponse("symbol is required"), nil
+	}
+
+	if r.lspClients.Len() == 0 {
+		return NewTextErrorResponse("no LSP clients available"), nil
+	}
+
+	workingDir := cmp.Or(params.Path, ".")
+
+	matches, _, err := searchFiles(ctx, regexp.QuoteMeta(params.Symbol), workingDir, "", 100)
+	if err != nil {
+		return NewTextErrorResponse(fmt.Sprintf("failed to search for symbol: %s", err)), nil
+	}
+
+	if len(matches) == 0 {
+		return NewTextResponse(fmt.Sprintf("Symbol '%s' not found", params.Symbol)), nil
+	}
+
+	var allLocations []protocol.Location
+	var allErrs error
+	for _, match := range matches {
+		locations, err := r.find(ctx, params.Symbol, match)
+		if err != nil {
+			if strings.Contains(err.Error(), "no identifier found") {
+				// grep probably matched a comment, string value, or something else that's irrelevant
+				continue
+			}
+			slog.Error("Failed to find references", "error", err, "symbol", params.Symbol, "path", match.path, "line", match.lineNum, "char", match.charNum)
+			allErrs = errors.Join(allErrs, err)
+			continue
+		}
+		allLocations = append(allLocations, locations...)
+		// XXX: should we break here or look for all results?
+	}
+
+	if len(allLocations) > 0 {
+		output := formatReferences(cleanupLocations(allLocations))
+		return NewTextResponse(output), nil
+	}
+
+	if allErrs != nil {
+		return NewTextErrorResponse(allErrs.Error()), nil
+	}
+	return NewTextResponse(fmt.Sprintf("No references found for symbol '%s'", params.Symbol)), nil
+}
+
+func (r *referencesTool) find(ctx context.Context, symbol string, match grepMatch) ([]protocol.Location, error) {
+	absPath, err := filepath.Abs(match.path)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get absolute path: %s", err)
+	}
+
+	var client *lsp.Client
+	for c := range r.lspClients.Seq() {
+		if c.HandlesFile(absPath) {
+			client = c
+			break
+		}
+	}
+
+	if client == nil {
+		slog.Warn("No LSP clients to handle", "path", match.path)
+		return nil, nil
+	}
+
+	return client.FindReferences(
+		ctx,
+		absPath,
+		match.lineNum,
+		match.charNum+getSymbolOffset(symbol),
+		true,
+	)
+}
+
+// getSymbolOffset returns the character offset to the actual symbol name
+// in a qualified symbol (e.g., "Bar" in "foo.Bar" or "method" in "Class::method").
+func getSymbolOffset(symbol string) int {
+	// Check for :: separator (Rust, C++, Ruby modules/classes, PHP static).
+	if idx := strings.LastIndex(symbol, "::"); idx != -1 {
+		return idx + 2
+	}
+	// Check for . separator (Go, Python, JavaScript, Java, C#, Ruby methods).
+	if idx := strings.LastIndex(symbol, "."); idx != -1 {
+		return idx + 1
+	}
+	// Check for \ separator (PHP namespaces).
+	if idx := strings.LastIndex(symbol, "\\"); idx != -1 {
+		return idx + 1
+	}
+	return 0
+}
+
+func cleanupLocations(locations []protocol.Location) []protocol.Location {
+	slices.SortFunc(locations, func(a, b protocol.Location) int {
+		if a.URI != b.URI {
+			return strings.Compare(string(a.URI), string(b.URI))
+		}
+		if a.Range.Start.Line != b.Range.Start.Line {
+			return cmp.Compare(a.Range.Start.Line, b.Range.Start.Line)
+		}
+		return cmp.Compare(a.Range.Start.Character, b.Range.Start.Character)
+	})
+	return slices.CompactFunc(locations, func(a, b protocol.Location) bool {
+		return a.URI == b.URI &&
+			a.Range.Start.Line == b.Range.Start.Line &&
+			a.Range.Start.Character == b.Range.Start.Character
+	})
+}
+
+func groupByFilename(locations []protocol.Location) map[string][]protocol.Location {
+	files := make(map[string][]protocol.Location)
+	for _, loc := range locations {
+		path, err := loc.URI.Path()
+		if err != nil {
+			slog.Error("Failed to convert location URI to path", "uri", loc.URI, "error", err)
+			continue
+		}
+		files[path] = append(files[path], loc)
+	}
+	return files
+}
+
+func formatReferences(locations []protocol.Location) string {
+	fileRefs := groupByFilename(locations)
+	files := slices.Collect(maps.Keys(fileRefs))
+	sort.Strings(files)
+
+	var output strings.Builder
+	output.WriteString(fmt.Sprintf("Found %d reference(s) in %d file(s):\n\n", len(locations), len(files)))
+
+	for _, file := range files {
+		refs := fileRefs[file]
+		output.WriteString(fmt.Sprintf("%s (%d reference(s)):\n", file, len(refs)))
+		for _, ref := range refs {
+			line := ref.Range.Start.Line + 1
+			char := ref.Range.Start.Character + 1
+			output.WriteString(fmt.Sprintf("  Line %d, Column %d\n", line, char))
+		}
+		output.WriteString("\n")
+	}
+
+	return output.String()
+}

internal/llm/tools/references.md 🔗

@@ -0,0 +1,36 @@
+Find all references to/usage of a symbol by name using the Language Server Protocol (LSP).
+
+WHEN TO USE THIS TOOL:
+
+- **ALWAYS USE THIS FIRST** when searching for where a function, method, variable, type, or constant is used
+- **DO NOT use grep/glob for symbol searches** - this tool is semantic-aware and much more accurate
+- Use when you need to find all usages of a specific symbol (function, variable, type, class, method, etc.)
+- More accurate than grep because it understands code semantics and scope
+- Finds only actual references, not string matches in comments or unrelated code
+- Helpful for understanding where a symbol is used throughout the codebase
+- Useful for refactoring or analyzing code dependencies
+- Good for finding all call sites of a function, method, type, package, constant, variable, etc.
+
+HOW TO USE:
+
+- Provide the symbol name (e.g., "MyFunction", "myVariable", "MyType")
+- Optionally specify a path to narrow the search to a specific directory
+- The tool will automatically find the symbol and locate all references
+
+FEATURES:
+
+- Returns all references grouped by file
+- Shows line and column numbers for each reference
+- Supports multiple programming languages through LSP
+- Automatically finds the symbol without needing exact position
+
+LIMITATIONS:
+
+- May not find references in files that haven't been opened or indexed
+- Results depend on the LSP server's capabilities
+
+TIPS:
+
+- **Use this tool instead of grep when looking for symbol references** - it's more accurate and semantic-aware
+- Simply provide the symbol name and let the tool find it for you
+- This tool understands code structure, so it won't match unrelated strings or comments

internal/llm/tools/rg.go 🔗

@@ -43,7 +43,7 @@ func getRgSearchCmd(ctx context.Context, pattern, path, include string) *exec.Cm
 		return nil
 	}
 	// Use -n to show line numbers, -0 for null separation to handle Windows paths
-	args := []string{"-H", "-n", "-0", pattern}
+	args := []string{"--json", "-H", "-n", "-0", pattern}
 	if include != "" {
 		args = append(args, "--glob", include)
 	}

internal/log/http.go 🔗

@@ -39,12 +39,14 @@ func (h *HTTPRoundTripLogger) RoundTrip(req *http.Request) (*http.Response, erro
 		return nil, err
 	}
 
-	slog.Debug(
-		"HTTP Request",
-		"method", req.Method,
-		"url", req.URL,
-		"body", bodyToString(save),
-	)
+	if slog.Default().Enabled(req.Context(), slog.LevelDebug) {
+		slog.Debug(
+			"HTTP Request",
+			"method", req.Method,
+			"url", req.URL,
+			"body", bodyToString(save),
+		)
+	}
 
 	start := time.Now()
 	resp, err := h.Transport.RoundTrip(req)
@@ -61,16 +63,18 @@ func (h *HTTPRoundTripLogger) RoundTrip(req *http.Request) (*http.Response, erro
 	}
 
 	save, resp.Body, err = drainBody(resp.Body)
-	slog.Debug(
-		"HTTP Response",
-		"status_code", resp.StatusCode,
-		"status", resp.Status,
-		"headers", formatHeaders(resp.Header),
-		"body", bodyToString(save),
-		"content_length", resp.ContentLength,
-		"duration_ms", duration.Milliseconds(),
-		"error", err,
-	)
+	if slog.Default().Enabled(req.Context(), slog.LevelDebug) {
+		slog.Debug(
+			"HTTP Response",
+			"status_code", resp.StatusCode,
+			"status", resp.Status,
+			"headers", formatHeaders(resp.Header),
+			"body", bodyToString(save),
+			"content_length", resp.ContentLength,
+			"duration_ms", duration.Milliseconds(),
+			"error", err,
+		)
+	}
 	return resp, err
 }
 
@@ -84,7 +88,7 @@ func bodyToString(body io.ReadCloser) string {
 		return ""
 	}
 	var b bytes.Buffer
-	if json.Compact(&b, bytes.TrimSpace(src)) != nil {
+	if json.Indent(&b, bytes.TrimSpace(src), "", "  ") != nil {
 		// not json probably
 		return string(src)
 	}

internal/lsp/client.go 🔗

@@ -445,6 +445,16 @@ func (c *Client) WaitForDiagnostics(ctx context.Context, d time.Duration) {
 	}
 }
 
+// FindReferences finds all references to the symbol at the given position.
+func (c *Client) FindReferences(ctx context.Context, filepath string, line, character int, includeDeclaration bool) ([]protocol.Location, error) {
+	if err := c.OpenFileOnDemand(ctx, filepath); err != nil {
+		return nil, err
+	}
+	// NOTE: line and character should be 0-based.
+	// See: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position
+	return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration)
+}
+
 // HasRootMarkers checks if any of the specified root marker patterns exist in the given directory.
 // Uses glob patterns to match files, allowing for more flexible matching.
 func HasRootMarkers(dir string, rootMarkers []string) bool {

internal/tui/components/anim/anim.go 🔗

@@ -16,6 +16,7 @@ import (
 	"github.com/lucasb-eyer/go-colorful"
 
 	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/tui/util"
 )
 
 const (
@@ -318,7 +319,7 @@ func (a *Anim) Init() tea.Cmd {
 }
 
 // Update processes animation steps (or not).
-func (a *Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (a *Anim) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case StepMsg:
 		if msg.id != a.id {

internal/tui/components/chat/chat.go 🔗

@@ -101,7 +101,7 @@ func (m *messageListCmp) Init() tea.Cmd {
 }
 
 // Update handles incoming messages and updates the component state.
-func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	if m.session.ID != "" && m.app.CoderAgent != nil {
 		queueSize := m.app.CoderAgent.QueuedPrompts(m.session.ID)

internal/tui/components/chat/editor/editor.go 🔗

@@ -86,6 +86,7 @@ var DeleteKeyMaps = DeleteAttachmentKeyMaps{
 
 const (
 	maxAttachments = 5
+	maxFileResults = 25
 )
 
 type OpenEditorMsg struct {
@@ -171,7 +172,7 @@ func (m *editorCmp) repositionCompletions() tea.Msg {
 	return completions.RepositionCompletionsMsg{X: x, Y: y}
 }
 
-func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	var cmd tea.Cmd
 	var cmds []tea.Cmd
 	switch msg := msg.(type) {
@@ -480,7 +481,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 {
@@ -498,6 +501,7 @@ func (m *editorCmp) startCompletions() tea.Msg {
 		Completions: completionItems,
 		X:           x,
 		Y:           y,
+		MaxResults:  maxFileResults,
 	}
 }
 

internal/tui/components/chat/header/header.go 🔗

@@ -44,7 +44,7 @@ func (h *header) Init() tea.Cmd {
 	return nil
 }
 
-func (h *header) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (h *header) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case pubsub.Event[session.Session]:
 		if msg.Type == pubsub.UpdatedEvent {

internal/tui/components/chat/messages/messages.go 🔗

@@ -12,6 +12,7 @@ import (
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/ansi"
+	"github.com/charmbracelet/x/exp/ordered"
 	"github.com/google/uuid"
 
 	"github.com/atotto/clipboard"
@@ -34,7 +35,7 @@ var ClearSelectionKey = key.NewBinding(key.WithKeys("esc", "alt+esc"), key.WithH
 // MessageCmp defines the interface for message components in the chat interface.
 // It combines standard UI model interfaces with message-specific functionality.
 type MessageCmp interface {
-	util.Model                      // Basic Bubble Tea model interface
+	util.Model                      // Basic Bubble util.Model interface
 	layout.Sizeable                 // Width/height management
 	layout.Focusable                // Focus state management
 	GetMessage() message.Message    // Access to underlying message data
@@ -93,7 +94,7 @@ func (m *messageCmp) Init() tea.Cmd {
 
 // Update handles incoming messages and updates the component state.
 // Manages animation updates for spinning messages and stops animation when appropriate.
-func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *messageCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case anim.StepMsg:
 		m.spinning = m.shouldSpin()
@@ -271,7 +272,7 @@ func (m *messageCmp) renderThinkingContent() string {
 		}
 	}
 	fullContent := content.String()
-	height := util.Clamp(lipgloss.Height(fullContent), 1, 10)
+	height := ordered.Clamp(lipgloss.Height(fullContent), 1, 10)
 	m.thinkingViewport.SetHeight(height)
 	m.thinkingViewport.SetWidth(m.textWidth())
 	m.thinkingViewport.SetContent(fullContent)
@@ -344,7 +345,7 @@ func (m *messageCmp) GetSize() (int, int) {
 
 // SetSize updates the width of the message component for text wrapping
 func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
-	m.width = util.Clamp(width, 1, 120)
+	m.width = ordered.Clamp(width, 1, 120)
 	m.thinkingViewport.SetWidth(m.width - 4)
 	return nil
 }
@@ -383,7 +384,7 @@ func (m *assistantSectionModel) Init() tea.Cmd {
 	return nil
 }
 
-func (m *assistantSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
+func (m *assistantSectionModel) Update(tea.Msg) (util.Model, tea.Cmd) {
 	return m, nil
 }
 

internal/tui/components/chat/messages/tool.go 🔗

@@ -27,7 +27,7 @@ import (
 // ToolCallCmp defines the interface for tool call components in the chat interface.
 // It manages the display of tool execution including pending states, results, and errors.
 type ToolCallCmp interface {
-	util.Model                         // Basic Bubble Tea model interface
+	util.Model                         // Basic Bubble util.Model interface
 	layout.Sizeable                    // Width/height management
 	layout.Focusable                   // Focus state management
 	GetToolCall() message.ToolCall     // Access to tool call data
@@ -147,7 +147,7 @@ func (m *toolCallCmp) Init() tea.Cmd {
 
 // Update handles incoming messages and updates the component state.
 // Manages animation updates for pending tool calls.
-func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *toolCallCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case anim.StepMsg:
 		var cmds []tea.Cmd
@@ -160,7 +160,7 @@ func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 		if m.spinning {
 			u, cmd := m.anim.Update(msg)
-			m.anim = u.(util.Model)
+			m.anim = u
 			cmds = append(cmds, cmd)
 		}
 		return m, tea.Batch(cmds...)

internal/tui/components/chat/sidebar/sidebar.go 🔗

@@ -88,7 +88,7 @@ func (m *sidebarCmp) Init() tea.Cmd {
 	return nil
 }
 
-func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *sidebarCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case SessionFilesMsg:
 		m.files = csync.NewMap[string, SessionFile]()

internal/tui/components/chat/splash/splash.go 🔗

@@ -135,7 +135,7 @@ func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
 }
 
 // Update implements SplashPage.
-func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		return s, s.SetSize(msg.Width, msg.Height)

internal/tui/components/completions/completions.go 🔗

@@ -22,6 +22,7 @@ type OpenCompletionsMsg struct {
 	Completions []Completion
 	X           int // X position for the completions popup
 	Y           int // Y position for the completions popup
+	MaxResults  int // Maximum number of results to render, 0 for no limit
 }
 
 type FilterCompletionsMsg struct {
@@ -111,7 +112,7 @@ func (c *completionsCmp) Init() tea.Cmd {
 }
 
 // Update implements Completions.
-func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (c *completionsCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		c.wWidth, c.wHeight = msg.Width, msg.Height
@@ -192,6 +193,7 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 		c.width = width
 		c.height = max(min(maxCompletionsHeight, len(items)), 1) // Ensure at least 1 item height
+		c.list.SetResultsSize(msg.MaxResults)
 		return c, tea.Batch(
 			c.list.SetItems(items),
 			c.list.SetSize(c.width, c.height),

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/core/status/status.go 🔗

@@ -36,7 +36,7 @@ func (m *statusCmp) Init() tea.Cmd {
 	return nil
 }
 
-func (m *statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *statusCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		m.width = msg.Width

internal/tui/components/dialogs/commands/arguments.go 🔗

@@ -92,7 +92,7 @@ func (c *commandArgumentsDialogCmp) Init() tea.Cmd {
 }
 
 // Update implements CommandArgumentsDialog.
-func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		c.wWidth = msg.Width
@@ -128,12 +128,17 @@ func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			c.inputs[c.focusIndex].Blur()
 			c.focusIndex = (c.focusIndex - 1 + len(c.inputs)) % len(c.inputs)
 			c.inputs[c.focusIndex].Focus()
-
+		case key.Matches(msg, c.keys.Close):
+			return c, util.CmdHandler(dialogs.CloseDialogMsg{})
 		default:
 			var cmd tea.Cmd
 			c.inputs[c.focusIndex], cmd = c.inputs[c.focusIndex].Update(msg)
 			return c, cmd
 		}
+	case tea.PasteMsg:
+		var cmd tea.Cmd
+		c.inputs[c.focusIndex], cmd = c.inputs[c.focusIndex].Update(msg)
+		return c, cmd
 	}
 	return c, nil
 }

internal/tui/components/dialogs/commands/commands.go 🔗

@@ -116,7 +116,7 @@ func (c *commandDialogCmp) Init() tea.Cmd {
 	return c.SetCommandType(c.commandType)
 }
 
-func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (c *commandDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		c.wWidth = msg.Width

internal/tui/components/dialogs/commands/keys.go 🔗

@@ -76,6 +76,7 @@ type ArgumentsDialogKeyMap struct {
 	Confirm  key.Binding
 	Next     key.Binding
 	Previous key.Binding
+	Close    key.Binding
 }
 
 func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap {
@@ -93,6 +94,10 @@ func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap {
 			key.WithKeys("shift+tab", "up"),
 			key.WithHelp("shift+tab/↑", "previous"),
 		),
+		Close: key.NewBinding(
+			key.WithKeys("esc", "alt+esc"),
+			key.WithHelp("esc", "cancel"),
+		),
 	}
 }
 
@@ -102,6 +107,7 @@ func (k ArgumentsDialogKeyMap) KeyBindings() []key.Binding {
 		k.Confirm,
 		k.Next,
 		k.Previous,
+		k.Close,
 	}
 }
 
@@ -122,5 +128,6 @@ func (k ArgumentsDialogKeyMap) ShortHelp() []key.Binding {
 		k.Confirm,
 		k.Next,
 		k.Previous,
+		k.Close,
 	}
 }

internal/tui/components/dialogs/compact/compact.go 🔗

@@ -61,7 +61,7 @@ func (c *compactDialogCmp) Init() tea.Cmd {
 	return nil
 }
 
-func (c *compactDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (c *compactDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		c.wWidth = msg.Width

internal/tui/components/dialogs/dialogs.go 🔗

@@ -32,7 +32,7 @@ type CloseDialogMsg struct{}
 
 // DialogCmp manages a stack of dialogs with keyboard navigation.
 type DialogCmp interface {
-	tea.Model
+	util.Model
 
 	Dialogs() []DialogModel
 	HasDialogs() bool
@@ -62,7 +62,7 @@ func (d dialogCmp) Init() tea.Cmd {
 }
 
 // Update handles dialog lifecycle and forwards messages to the active dialog.
-func (d dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (d dialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		var cmds []tea.Cmd
@@ -98,7 +98,11 @@ func (d dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return d, nil
 }
 
-func (d dialogCmp) handleOpen(msg OpenDialogMsg) (tea.Model, tea.Cmd) {
+func (d dialogCmp) View() string {
+	return ""
+}
+
+func (d dialogCmp) handleOpen(msg OpenDialogMsg) (util.Model, tea.Cmd) {
 	if d.HasDialogs() {
 		dialog := d.dialogs[len(d.dialogs)-1]
 		if dialog.ID() == msg.Model.ID() {

internal/tui/components/dialogs/filepicker/filepicker.go 🔗

@@ -88,7 +88,7 @@ func (m *model) Init() tea.Cmd {
 	return m.filePicker.Init()
 }
 
-func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *model) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		m.wWidth = msg.Width

internal/tui/components/dialogs/models/apikey.go 🔗

@@ -9,6 +9,7 @@ import (
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/lipgloss/v2"
 )
 
@@ -75,7 +76,7 @@ func (a *APIKeyInput) Init() tea.Cmd {
 	return a.spinner.Tick
 }
 
-func (a *APIKeyInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (a *APIKeyInput) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case spinner.TickMsg:
 		if a.state == APIKeyInputStateVerifying {

internal/tui/components/dialogs/models/keys.go 🔗

@@ -19,7 +19,7 @@ func DefaultKeyMap() KeyMap {
 	return KeyMap{
 		Select: key.NewBinding(
 			key.WithKeys("enter", "ctrl+y"),
-			key.WithHelp("enter", "confirm"),
+			key.WithHelp("enter", "choose"),
 		),
 		Next: key.NewBinding(
 			key.WithKeys("down", "ctrl+n"),
@@ -35,7 +35,7 @@ func DefaultKeyMap() KeyMap {
 		),
 		Close: key.NewBinding(
 			key.WithKeys("esc", "alt+esc"),
-			key.WithHelp("esc", "cancel"),
+			key.WithHelp("esc", "exit"),
 		),
 	}
 }

internal/tui/components/dialogs/models/models.go 🔗

@@ -98,7 +98,7 @@ func (m *modelDialogCmp) Init() tea.Cmd {
 	return tea.Batch(m.modelList.Init(), m.apiKeyInput.Init())
 }
 
-func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		m.wWidth = msg.Width

internal/tui/components/dialogs/permissions/keys.go 🔗

@@ -42,7 +42,7 @@ func DefaultKeyMap() KeyMap {
 			key.WithHelp("s", "allow session"),
 		),
 		Deny: key.NewBinding(
-			key.WithKeys("d", "D", "ctrl+d", "esc"),
+			key.WithKeys("d", "D", "esc"),
 			key.WithHelp("d", "deny"),
 		),
 		Select: key.NewBinding(

internal/tui/components/dialogs/permissions/permissions.go 🔗

@@ -95,7 +95,7 @@ func (p *permissionDialogCmp) supportsDiffView() bool {
 	return p.permission.ToolName == tools.EditToolName || p.permission.ToolName == tools.WriteToolName || p.permission.ToolName == tools.MultiEditToolName
 }
 
-func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (p *permissionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 
 	switch msg := msg.(type) {

internal/tui/components/dialogs/quit/quit.go 🔗

@@ -40,7 +40,7 @@ func (q *quitDialogCmp) Init() tea.Cmd {
 }
 
 // Update handles keyboard input for the quit dialog.
-func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (q *quitDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		q.wWidth = msg.Width

internal/tui/components/dialogs/reasoning/reasoning.go 🔗

@@ -172,7 +172,7 @@ func (r *reasoningDialogCmp) populateEffortOptions() tea.Cmd {
 	return nil
 }
 
-func (r *reasoningDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (r *reasoningDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		r.wWidth = msg.Width

internal/tui/components/dialogs/sessions/keys.go 🔗

@@ -15,7 +15,7 @@ func DefaultKeyMap() KeyMap {
 	return KeyMap{
 		Select: key.NewBinding(
 			key.WithKeys("enter", "tab", "ctrl+y"),
-			key.WithHelp("enter", "confirm"),
+			key.WithHelp("enter", "choose"),
 		),
 		Next: key.NewBinding(
 			key.WithKeys("down", "ctrl+n"),
@@ -27,7 +27,7 @@ func DefaultKeyMap() KeyMap {
 		),
 		Close: key.NewBinding(
 			key.WithKeys("esc", "alt+esc"),
-			key.WithHelp("esc", "cancel"),
+			key.WithHelp("esc", "exit"),
 		),
 	}
 }

internal/tui/components/dialogs/sessions/sessions.go 🔗

@@ -81,7 +81,7 @@ func (s *sessionDialogCmp) Init() tea.Cmd {
 	return tea.Sequence(cmds...)
 }
 
-func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (s *sessionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		var cmds []tea.Cmd

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/exp/list/filterable.go 🔗

@@ -3,14 +3,13 @@ package list
 import (
 	"regexp"
 	"slices"
-	"sort"
-	"strings"
 
 	"github.com/charmbracelet/bubbles/v2/key"
 	"github.com/charmbracelet/bubbles/v2/textinput"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/sahilm/fuzzy"
 )
@@ -28,7 +27,9 @@ type FilterableList[T FilterableItem] interface {
 	Cursor() *tea.Cursor
 	SetInputWidth(int)
 	SetInputPlaceholder(string)
+	SetResultsSize(int)
 	Filter(q string) tea.Cmd
+	fuzzy.Source
 }
 
 type HasMatchIndexes interface {
@@ -47,10 +48,11 @@ type filterableList[T FilterableItem] struct {
 	*filterableOptions
 	width, height int
 	// stores all available items
-	items      []T
-	input      textinput.Model
-	inputWidth int
-	query      string
+	items       []T
+	resultsSize int
+	input       textinput.Model
+	inputWidth  int
+	query       string
 }
 
 type filterableListOption func(*filterableOptions)
@@ -115,7 +117,7 @@ func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption
 	return f
 }
 
-func (f *filterableList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (f *filterableList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
 		switch {
@@ -246,22 +248,18 @@ func (f *filterableList[T]) Filter(query string) tea.Cmd {
 		return f.list.SetItems(f.items)
 	}
 
-	words := make([]string, len(f.items))
-	for i, item := range f.items {
-		words[i] = strings.ToLower(item.FilterValue())
-	}
-
-	matches := fuzzy.Find(query, words)
-
-	sort.SliceStable(matches, func(i, j int) bool {
-		return matches[i].Score > matches[j].Score
-	})
+	matches := fuzzy.FindFrom(query, f)
 
 	var matchedItems []T
-	for _, match := range matches {
+	resultSize := len(matches)
+	if f.resultsSize > 0 && resultSize > f.resultsSize {
+		resultSize = f.resultsSize
+	}
+	for i := range resultSize {
+		match := matches[i]
 		item := f.items[match.Index]
-		if i, ok := any(item).(HasMatchIndexes); ok {
-			i.MatchIndexes(match.MatchedIndexes)
+		if it, ok := any(item).(HasMatchIndexes); ok {
+			it.MatchIndexes(match.MatchedIndexes)
 		}
 		matchedItems = append(matchedItems, item)
 	}
@@ -307,3 +305,15 @@ func (f *filterableList[T]) SetInputWidth(w int) {
 func (f *filterableList[T]) SetInputPlaceholder(ph string) {
 	f.placeholder = ph
 }
+
+func (f *filterableList[T]) SetResultsSize(size int) {
+	f.resultsSize = size
+}
+
+func (f *filterableList[T]) String(i int) string {
+	return f.items[i].FilterValue()
+}
+
+func (f *filterableList[T]) Len() int {
+	return len(f.items)
+}

internal/tui/exp/list/filterable_group.go 🔗

@@ -11,6 +11,7 @@ import (
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/sahilm/fuzzy"
 )
@@ -65,7 +66,7 @@ func NewFilterableGroupedList[T FilterableItem](items []Group[T], opts ...filter
 	return f
 }
 
-func (f *filterableGroupList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (f *filterableGroupList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
 		switch {

internal/tui/exp/list/grouped.go 🔗

@@ -58,7 +58,7 @@ func (g *groupedList[T]) Init() tea.Cmd {
 	return g.render()
 }
 
-func (l *groupedList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (l *groupedList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	u, cmd := l.list.Update(msg)
 	l.list = u.(*list[Item])
 	return l, cmd

internal/tui/exp/list/items.go 🔗

@@ -7,6 +7,7 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/ansi"
 	"github.com/google/uuid"
@@ -97,7 +98,7 @@ func (c *completionItemCmp[T]) Init() tea.Cmd {
 }
 
 // Update implements CommandItem.
-func (c *completionItemCmp[T]) Update(tea.Msg) (tea.Model, tea.Cmd) {
+func (c *completionItemCmp[T]) Update(tea.Msg) (util.Model, tea.Cmd) {
 	return c, nil
 }
 
@@ -348,7 +349,7 @@ func (m *itemSectionModel) Init() tea.Cmd {
 	return nil
 }
 
-func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
+func (m *itemSectionModel) Update(tea.Msg) (util.Model, tea.Cmd) {
 	return m, nil
 }
 

internal/tui/exp/list/list.go 🔗

@@ -15,6 +15,7 @@ import (
 	"github.com/charmbracelet/lipgloss/v2"
 	uv "github.com/charmbracelet/ultraviolet"
 	"github.com/charmbracelet/x/ansi"
+	"github.com/charmbracelet/x/exp/ordered"
 	"github.com/rivo/uniseg"
 )
 
@@ -216,7 +217,7 @@ func (l *list[T]) Init() tea.Cmd {
 }
 
 // Update implements List.
-func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (l *list[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.MouseWheelMsg:
 		if l.enableMouse {
@@ -276,7 +277,7 @@ func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return l, nil
 }
 
-func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) {
+func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (util.Model, tea.Cmd) {
 	var cmd tea.Cmd
 	switch msg.Button {
 	case tea.MouseWheelDown:
@@ -1283,14 +1284,14 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
 				newItem, ok := l.renderedItems.Get(item.ID())
 				if ok {
 					newLines := newItem.height - oldItem.height
-					l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
+					l.offset = ordered.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
 				}
 			}
 		} else if hasOldItem && l.offset > oldItem.start {
 			newItem, ok := l.renderedItems.Get(item.ID())
 			if ok {
 				newLines := newItem.height - oldItem.height
-				l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
+				l.offset = ordered.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
 			}
 		}
 	}

internal/tui/exp/list/list_test.go 🔗

@@ -7,6 +7,7 @@ import (
 
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
+	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/exp/golden"
 	"github.com/google/uuid"
@@ -602,7 +603,7 @@ func (s *simpleItem) Init() tea.Cmd {
 	return nil
 }
 
-func (s *simpleItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (s *simpleItem) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	return s, nil
 }
 
@@ -644,7 +645,7 @@ func (s *selectableItem) IsFocused() bool {
 	return s.focused
 }
 
-func execCmd(m tea.Model, cmd tea.Cmd) {
+func execCmd(m util.Model, cmd tea.Cmd) {
 	for cmd != nil {
 		msg := cmd()
 		m, cmd = m.Update(msg)

internal/tui/page/chat/chat.go 🔗

@@ -163,7 +163,7 @@ func (p *chatPage) Init() tea.Cmd {
 	)
 }
 
-func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	switch msg := msg.(type) {
 	case tea.KeyboardEnhancementsMsg:

internal/tui/tui.go 🔗

@@ -3,6 +3,7 @@ package tui
 import (
 	"context"
 	"fmt"
+	"math/rand"
 	"strings"
 	"time"
 
@@ -90,8 +91,6 @@ func (a appModel) Init() tea.Cmd {
 	cmd = a.status.Init()
 	cmds = append(cmds, cmd)
 
-	cmds = append(cmds, tea.EnableMouseAllMotion)
-
 	return tea.Batch(cmds...)
 }
 
@@ -105,9 +104,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.KeyboardEnhancementsMsg:
 		for id, page := range a.pages {
 			m, pageCmd := page.Update(msg)
-			if model, ok := m.(util.Model); ok {
-				a.pages[id] = model
-			}
+			a.pages[id] = m
 
 			if pageCmd != nil {
 				cmds = append(cmds, pageCmd)
@@ -231,9 +228,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 		// Forward to view.
 		updated, itemCmd := item.Update(msg)
-		if model, ok := updated.(util.Model); ok {
-			a.pages[a.currentPage] = model
-		}
+		a.pages[a.currentPage] = updated
 
 		return a, itemCmd
 	case pubsub.Event[permission.PermissionRequest]:
@@ -291,9 +286,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 		a.isConfigured = config.HasInitialDataConfig()
 		updated, pageCmd := item.Update(msg)
-		if model, ok := updated.(util.Model); ok {
-			a.pages[a.currentPage] = model
-		}
+		a.pages[a.currentPage] = updated
 
 		cmds = append(cmds, pageCmd)
 		return a, tea.Batch(cmds...)
@@ -313,9 +306,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 
 			updated, pageCmd := item.Update(msg)
-			if model, ok := updated.(util.Model); ok {
-				a.pages[a.currentPage] = model
-			}
+			a.pages[a.currentPage] = updated
 
 			cmds = append(cmds, pageCmd)
 		}
@@ -335,9 +326,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 
 			updated, pageCmd := item.Update(msg)
-			if model, ok := updated.(util.Model); ok {
-				a.pages[a.currentPage] = model
-			}
+			a.pages[a.currentPage] = updated
 
 			cmds = append(cmds, pageCmd)
 		}
@@ -352,9 +341,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	}
 
 	updated, cmd := item.Update(msg)
-	if model, ok := updated.(util.Model); ok {
-		a.pages[a.currentPage] = model
-	}
+	a.pages[a.currentPage] = updated
 
 	if a.dialog.HasDialogs() {
 		u, dialogCmd := a.dialog.Update(msg)
@@ -390,9 +377,7 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
 	// Update the current view.
 	for p, page := range a.pages {
 		updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height})
-		if model, ok := updated.(util.Model); ok {
-			a.pages[p] = model
-		}
+		a.pages[p] = updated
 
 		cmds = append(cmds, pageCmd)
 	}
@@ -495,9 +480,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 		}
 
 		updated, cmd := item.Update(msg)
-		if model, ok := updated.(util.Model); ok {
-			a.pages[a.currentPage] = model
-		}
+		a.pages[a.currentPage] = updated
 		return cmd
 	}
 }
@@ -603,6 +586,13 @@ func (a *appModel) View() tea.View {
 
 	view.Layer = canvas
 	view.Cursor = cursor
+	view.MouseMode = tea.MouseModeCellMotion
+	view.AltScreen = true
+	if a.app != nil && a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() {
+		// HACK: use a random percentage to prevent ghostty from hiding it
+		// after a timeout.
+		view.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
+	}
 	return view
 }
 

internal/tui/util/util.go 🔗

@@ -12,8 +12,9 @@ type Cursor interface {
 }
 
 type Model interface {
-	tea.Model
-	tea.ViewModel
+	Init() tea.Cmd
+	Update(tea.Msg) (Model, tea.Cmd)
+	View() string
 }
 
 func CmdHandler(msg tea.Msg) tea.Cmd {
@@ -60,10 +61,3 @@ type (
 	}
 	ClearStatusMsg struct{}
 )
-
-func Clamp(v, low, high int) int {
-	if high < low {
-		low, high = high, low
-	}
-	return min(high, max(low, v))
-}

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"
+      ]
     }
   }
 }