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

Carlos Alexandro Becker created

Change summary

.github/cla-signatures.json                                    |  48 
.github/workflows/security.yml                                 |  12 
.github/workflows/snapshot.yml                                 |   2 
.goreleaser.yml                                                |   1 
AGENTS.md                                                      |   2 
README.md                                                      |   2 
Taskfile.yaml                                                  |   8 
go.mod                                                         |  37 
go.sum                                                         |  78 
internal/agent/agent.go                                        |  64 
internal/agent/agentic_fetch_tool.go                           |  15 
internal/agent/common_test.go                                  |  14 
internal/agent/coordinator.go                                  |  72 
internal/agent/hyper/provider.go                               |   7 
internal/agent/hyper/provider.json                             |   0 
internal/agent/tools/download.go                               |  13 
internal/agent/tools/edit.go                                   |  53 
internal/agent/tools/fetch.go                                  |  13 
internal/agent/tools/mcp/init.go                               |  13 
internal/agent/tools/mcp/prompts.go                            |   2 
internal/agent/tools/mcp/tools.go                              |   2 
internal/agent/tools/multiedit.go                              |  35 
internal/agent/tools/multiedit_test.go                         |  14 
internal/agent/tools/sourcegraph.go                            |  13 
internal/agent/tools/view.go                                   |  20 
internal/agent/tools/web_fetch.go                              |  13 
internal/agent/tools/web_search.go                             |  13 
internal/agent/tools/write.go                                  |  25 
internal/app/app.go                                            |  44 
internal/app/lsp.go                                            | 109 +
internal/app/provider_test.go                                  |   2 
internal/cmd/models.go                                         |   2 
internal/cmd/root.go                                           |  20 
internal/cmd/run.go                                            |  12 
internal/cmd/stats/index.css                                   |  13 
internal/cmd/stats/index.html                                  |   2 
internal/config/catwalk.go                                     |   4 
internal/config/catwalk_test.go                                |   2 
internal/config/config.go                                      |  16 
internal/config/copilot.go                                     |   2 
internal/config/hyper.go                                       |   2 
internal/config/hyper_test.go                                  |   2 
internal/config/load.go                                        |   9 
internal/config/load_test.go                                   |   2 
internal/config/provider.go                                    |   4 
internal/config/provider_empty_test.go                         |   2 
internal/config/provider_test.go                               |   2 
internal/db/db.go                                              |  40 
internal/db/messages.sql.go                                    |  82 
internal/db/migrations/20260127000000_add_read_files_table.sql |  20 
internal/db/models.go                                          |   6 
internal/db/querier.go                                         |   4 
internal/db/read_files.sql.go                                  |  57 
internal/db/sql/messages.sql                                   |  12 
internal/db/sql/read_files.sql                                 |  15 
internal/event/event.go                                        |  10 
internal/event/event_test.go                                   |  74 
internal/filetracker/filetracker.go                            |  70 
internal/filetracker/service.go                                |  70 
internal/filetracker/service_test.go                           | 116 +
internal/fsext/ls.go                                           |  12 
internal/fsext/paste.go                                        | 129 +
internal/fsext/paste_test.go                                   | 149 +
internal/home/home.go                                          |   2 
internal/lsp/client.go                                         | 107 +
internal/lsp/filtermatching_test.go                            | 111 +
internal/lsp/language.go                                       | 132 -
internal/lsp/rootmarkers_test.go                               |  37 
internal/message/content.go                                    |   2 
internal/message/message.go                                    |  32 
internal/session/session.go                                    |   2 
internal/stringext/string.go                                   |  12 
internal/tui/components/chat/editor/editor.go                  |  27 
internal/tui/components/chat/messages/messages.go              |   2 
internal/tui/components/chat/sidebar/sidebar.go                |   9 
internal/tui/components/chat/splash/splash.go                  |   2 
internal/tui/components/dialogs/commands/commands.go           |   4 
internal/tui/components/dialogs/models/list.go                 |   2 
internal/tui/components/dialogs/models/list_recent_test.go     |   2 
internal/tui/components/dialogs/models/models.go               |   2 
internal/tui/components/lsp/lsp.go                             |  31 
internal/tui/page/chat/chat.go                                 |   3 
internal/ui/chat/generic.go                                    |  98 +
internal/ui/chat/messages.go                                   |  18 
internal/ui/chat/tools.go                                      |  68 
internal/ui/common/capabilities.go                             | 133 +
internal/ui/dialog/actions.go                                  |   2 
internal/ui/dialog/api_key_input.go                            |   2 
internal/ui/dialog/commands.go                                 |  15 
internal/ui/dialog/commands_item.go                            |   4 
internal/ui/dialog/filepicker.go                               |  18 
internal/ui/dialog/models.go                                   |   6 
internal/ui/dialog/models_item.go                              |   6 
internal/ui/dialog/oauth.go                                    |   2 
internal/ui/dialog/oauth_copilot.go                            |   2 
internal/ui/dialog/oauth_hyper.go                              |   2 
internal/ui/dialog/permissions.go                              |   3 
internal/ui/dialog/reasoning.go                                |   4 
internal/ui/dialog/sessions.go                                 |  14 
internal/ui/dialog/sessions_item.go                            |  22 
internal/ui/image/image.go                                     |  52 
internal/ui/list/highlight.go                                  |   3 
internal/ui/list/list.go                                       | 116 
internal/ui/model/chat.go                                      | 274 +++
internal/ui/model/history.go                                   | 184 ++
internal/ui/model/keys.go                                      |  15 
internal/ui/model/lsp.go                                       |  14 
internal/ui/model/onboarding.go                                |   9 
internal/ui/model/pills.go                                     |   3 
internal/ui/model/sidebar.go                                   |   6 
internal/ui/model/ui.go                                        | 340 ++-
internal/ui/styles/styles.go                                   |  66 
schema.json                                                    |  39 
scripts/check_log_capitalization.sh                            |   5 
114 files changed, 2,779 insertions(+), 995 deletions(-)

Detailed changes

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

@@ -1127,6 +1127,54 @@
       "created_at": "2026-01-24T22:42:46Z",
       "repoId": 987670088,
       "pullRequestNo": 1978
+    },
+    {
+      "name": "oug-t",
+      "id": 252025851,
+      "comment_id": 3811704206,
+      "created_at": "2026-01-28T14:42:29Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2022
+    },
+    {
+      "name": "liannnix",
+      "id": 779758,
+      "comment_id": 3815867093,
+      "created_at": "2026-01-29T07:05:12Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2043
+    },
+    {
+      "name": "bittoby",
+      "id": 218712309,
+      "comment_id": 3824931235,
+      "created_at": "2026-01-30T17:52:15Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2065
+    },
+    {
+      "name": "ijt",
+      "id": 15530,
+      "comment_id": 3832667774,
+      "created_at": "2026-02-02T03:06:23Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2080
+    },
+    {
+      "name": "khalilgharbaoui",
+      "id": 8024057,
+      "comment_id": 3832796060,
+      "created_at": "2026-02-02T04:04:04Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2081
+    },
+    {
+      "name": "acmacalister",
+      "id": 1024755,
+      "comment_id": 3837172797,
+      "created_at": "2026-02-02T19:27:08Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2095
     }
   ]
 }

.github/workflows/security.yml ๐Ÿ”—

@@ -30,11 +30,11 @@ jobs:
       - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
         with:
           persist-credentials: false
-      - uses: github/codeql-action/init@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11
+      - uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
         with:
           languages: ${{ matrix.language }}
-      - uses: github/codeql-action/autobuild@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11
-      - uses: github/codeql-action/analyze@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11
+      - uses: github/codeql-action/autobuild@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
+      - uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
 
   grype:
     runs-on: ubuntu-latest
@@ -46,13 +46,13 @@ jobs:
       - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
         with:
           persist-credentials: false
-      - uses: anchore/scan-action@0d444ed77d83ee2ba7f5ced0d90d640a1281d762 # v7.3.0
+      - uses: anchore/scan-action@8d2fce09422cd6037e577f4130e9b925e9a37175 # v7.3.1
         id: scan
         with:
           path: "."
           fail-build: true
           severity-cutoff: critical
-      - uses: github/codeql-action/upload-sarif@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11
+      - uses: github/codeql-action/upload-sarif@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
         with:
           sarif_file: ${{ steps.scan.outputs.sarif }}
 
@@ -73,7 +73,7 @@ jobs:
       - name: Run govulncheck
         run: |
           govulncheck -C . -format sarif ./... > results.sarif
-      - uses: github/codeql-action/upload-sarif@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11
+      - uses: github/codeql-action/upload-sarif@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
         with:
           sarif_file: results.sarif
 

.github/workflows/snapshot.yml ๐Ÿ”—

@@ -27,7 +27,7 @@ jobs:
           go-version-file: go.mod
       - uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0
         with:
-          version: "~> v2"
+          version: "nightly"
           distribution: goreleaser-pro
           args: build --snapshot --clean
         env:

.goreleaser.yml ๐Ÿ”—

@@ -268,6 +268,7 @@ nix:
       name: "Charm"
       email: "charmcli@users.noreply.github.com"
     license: fsl11Mit
+    formatter: nixfmt
     skip_upload: "{{ with .Prerelease }}true{{ end }}"
     extra_install: |-
       installManPage ./manpages/crush.1.gz

AGENTS.md ๐Ÿ”—

@@ -26,6 +26,8 @@
   need of a temporary directory. This directory does not need to be removed.
 - **JSON tags**: Use snake_case for JSON field names
 - **File permissions**: Use octal notation (0o755, 0o644) for file permissions
+- **Log messages**: Log messages must start with a capital letter (e.g., "Failed to save session" not "failed to save session")
+  - This is enforced by `task lint:log` which runs as part of `task lint`
 - **Comments**: End comments in periods unless comments are at the end of the line.
 
 ## Testing with Mock Providers

README.md ๐Ÿ”—

@@ -7,7 +7,7 @@
 </p>
 
 <p align="center">Your new coding bestie, now available in your favourite terminal.<br />Your tools, your code, and your workflows, wired into your LLM of choice.</p>
-<p align="center">ไฝ ็š„ๆ–ฐ็ผ–็จ‹ไผ™ไผด๏ผŒ็Žฐๅœจๅฐฑๅœจไฝ ๆœ€็ˆฑ็š„็ปˆ็ซฏไธญใ€‚<br />ไฝ ็š„ๅทฅๅ…ทใ€ไปฃ็ ๅ’Œๅทฅไฝœๆต๏ผŒ้ƒฝไธŽๆ‚จ้€‰ๆ‹ฉ็š„ LLM ๆจกๅž‹็ดงๅฏ†็›ธ่ฟžใ€‚</p>
+<p align="center">็ปˆ็ซฏ้‡Œ็š„็ผ–็จ‹ๆ–ฐๆญๆกฃ๏ผŒ<br />ๆ— ็ผๆŽฅๅ…ฅไฝ ็š„ๅทฅๅ…ทใ€ไปฃ็ ไธŽๅทฅไฝœๆต๏ผŒๅ…จ้ขๅ…ผๅฎนไธปๆต LLM ๆจกๅž‹ใ€‚</p>
 
 <p align="center"><img width="800" alt="Crush Demo" src="https://github.com/user-attachments/assets/58280caf-851b-470a-b6f7-d5c4ea8a1968" /></p>
 

Taskfile.yaml ๐Ÿ”—

@@ -23,10 +23,16 @@ tasks:
   lint:
     desc: Run base linters
     cmds:
+      - task: lint:log
       - golangci-lint run --path-mode=abs --config=".golangci.yml" --timeout=5m
     env:
       GOEXPERIMENT: null
 
+  lint:log:
+    desc: Check that log messages start with capital letters
+    cmds:
+      - ./scripts/check_log_capitalization.sh
+
   lint:fix:
     desc: Run base linters and fix issues
     cmds:
@@ -147,5 +153,5 @@ tasks:
     desc: Update Fantasy and Catwalk
     cmds:
       - go get charm.land/fantasy
-      - go get github.com/charmbracelet/catwalk
+      - go get charm.land/catwalk
       - go mod tidy

go.mod ๐Ÿ”—

@@ -5,7 +5,8 @@ go 1.25.5
 require (
 	charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66
 	charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e
-	charm.land/fantasy v0.6.1
+	charm.land/catwalk v0.16.1
+	charm.land/fantasy v0.7.0
 	charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b
 	charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971
 	charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da
@@ -19,7 +20,6 @@ require (
 	github.com/aymanbagabas/go-udiff v0.3.1
 	github.com/bmatcuk/doublestar/v4 v4.10.0
 	github.com/charlievieth/fastwalk v1.0.14
-	github.com/charmbracelet/catwalk v0.15.0
 	github.com/charmbracelet/colorprofile v0.4.1
 	github.com/charmbracelet/fang v0.4.4
 	github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560
@@ -31,8 +31,10 @@ require (
 	github.com/charmbracelet/x/exp/ordered v0.1.0
 	github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
 	github.com/charmbracelet/x/exp/strings v0.1.0
-	github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b
+	github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687
 	github.com/charmbracelet/x/term v0.2.2
+	github.com/clipperhouse/displaywidth v0.9.0
+	github.com/clipperhouse/uax29/v2 v2.5.0
 	github.com/denisbrodbeck/machineid v1.0.1
 	github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
 	github.com/disintegration/imaging v1.6.2
@@ -76,12 +78,12 @@ require (
 
 require (
 	cloud.google.com/go v0.116.0 // indirect
-	cloud.google.com/go/auth v0.18.0 // indirect
+	cloud.google.com/go/auth v0.18.1 // indirect
 	cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
 	cloud.google.com/go/compute/metadata v0.9.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
-	github.com/RealAlexandreAI/json-repair v0.0.14 // indirect
+	github.com/RealAlexandreAI/json-repair v0.0.15 // indirect
 	github.com/andybalholm/cascadia v1.3.3 // indirect
 	github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect
 	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
@@ -102,13 +104,12 @@ 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/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 // indirect
 	github.com/charmbracelet/x/json v0.2.0 // indirect
 	github.com/charmbracelet/x/termios v0.1.1 // indirect
 	github.com/charmbracelet/x/windows v0.2.2 // indirect
-	github.com/clipperhouse/displaywidth v0.7.0 // indirect
 	github.com/clipperhouse/stringish v0.1.1 // indirect
-	github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/disintegration/gift v1.1.2 // indirect
 	github.com/dlclark/regexp2 v1.11.5 // indirect
@@ -119,9 +120,9 @@ require (
 	github.com/go-logfmt/logfmt v0.6.0 // indirect
 	github.com/go-logr/logr v1.4.3 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
-	github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
+	github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
 	github.com/goccy/go-json v0.10.5 // indirect
-	github.com/goccy/go-yaml v1.19.0 // indirect
+	github.com/goccy/go-yaml v1.19.2 // indirect
 	github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
 	github.com/google/go-cmp v0.7.0 // indirect
 	github.com/google/jsonschema-go v0.3.0 // indirect
@@ -132,10 +133,10 @@ require (
 	github.com/gorilla/websocket v1.5.3 // indirect
 	github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
-	github.com/kaptinlin/go-i18n v0.2.2 // indirect
-	github.com/kaptinlin/jsonpointer v0.4.8 // indirect
-	github.com/kaptinlin/jsonschema v0.6.6 // indirect
-	github.com/kaptinlin/messageformat-go v0.4.7 // indirect
+	github.com/kaptinlin/go-i18n v0.2.3 // indirect
+	github.com/kaptinlin/jsonpointer v0.4.9 // indirect
+	github.com/kaptinlin/jsonschema v0.6.9 // indirect
+	github.com/kaptinlin/messageformat-go v0.4.9 // indirect
 	github.com/klauspost/compress v1.18.0 // indirect
 	github.com/klauspost/cpuid/v2 v2.2.10 // indirect
 	github.com/klauspost/pgzip v1.2.6 // indirect
@@ -168,12 +169,12 @@ require (
 	github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
 	github.com/yuin/goldmark v1.7.8 // indirect
 	github.com/yuin/goldmark-emoji v1.0.5 // indirect
-	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
+	go.opentelemetry.io/auto/sdk v1.2.1 // indirect
 	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
 	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
-	go.opentelemetry.io/otel v1.37.0 // indirect
-	go.opentelemetry.io/otel/metric v1.37.0 // indirect
-	go.opentelemetry.io/otel/trace v1.37.0 // indirect
+	go.opentelemetry.io/otel v1.39.0 // indirect
+	go.opentelemetry.io/otel/metric v1.39.0 // indirect
+	go.opentelemetry.io/otel/trace v1.39.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
 	go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
 	golang.org/x/crypto v0.47.0 // indirect
@@ -184,7 +185,7 @@ require (
 	golang.org/x/term v0.39.0 // indirect
 	golang.org/x/time v0.14.0 // indirect
 	google.golang.org/api v0.239.0 // indirect
-	google.golang.org/genai v1.41.0 // indirect
+	google.golang.org/genai v1.44.0 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect
 	google.golang.org/grpc v1.76.0 // indirect
 	google.golang.org/protobuf v1.36.10 // indirect

go.sum ๐Ÿ”—

@@ -2,8 +2,10 @@ charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66 h1:2BdJynsAW+8rv
 charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4=
 charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e h1:tXwTmgGpwZT7ParKF5xbEQBVjM2e1uKhKi/GpfU3mYQ=
 charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e/go.mod h1:pDM18flq3Z4njKZPA3zCvyVSSIJbMcoqlE82BdGUtL8=
-charm.land/fantasy v0.6.1 h1:v3pavSHpZ5xTw98TpNYoj6DRq4ksCBWwJiZeiG/mVIc=
-charm.land/fantasy v0.6.1/go.mod h1:Ifj41bNnIXJ1aF6sLKcS9y3MzWbDnObmcHrCaaHfpZ0=
+charm.land/catwalk v0.16.1 h1:4Z4uCxqdAaVHeSX5dDDOkOg8sm7krFqJSaNBMZhE7Ao=
+charm.land/catwalk v0.16.1/go.mod h1:kAdk/GjAJbl1AjRjmfU5c9lZfs7PeC3Uy9TgaVtlN64=
+charm.land/fantasy v0.7.0 h1:qsSKJF07B+mimpPaC61Zyu3N+A9l2Lbs6T3txlP5In8=
+charm.land/fantasy v0.7.0/go.mod h1:zv8Utaob4b9rSPp2ruH515rx7oN+l66gv6RshvwHnww=
 charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b h1:A6IUUyChZDWP16RUdRJCfmYISAKWQGyIcfhZJUCViQ0=
 charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b/go.mod h1:J3kVhY6oHXZq5f+8vC3hmDO95fEvbqj3z7xDwxrfzU8=
 charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971 h1:xZFcNsJMiIDbFtWRyDmkKNk1sjojfaom4Zoe0cyH/8c=
@@ -14,8 +16,8 @@ charm.land/x/vcr v0.1.1 h1:PXCFMUG0rPtyk35rhfzYCJEduOzWXCIbrXTFq4OF/9Q=
 charm.land/x/vcr v0.1.1/go.mod h1:eByq2gqzWvcct/8XE2XO5KznoWEBiXH56+y2gphbltM=
 cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
 cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
-cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0=
-cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo=
+cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs=
+cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA=
 cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
 cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
 cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
@@ -35,8 +37,8 @@ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6
 github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
 github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
 github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
-github.com/RealAlexandreAI/json-repair v0.0.14 h1:4kTqotVonDVTio5n2yweRUELVcNe2x518wl0bCsw0t0=
-github.com/RealAlexandreAI/json-repair v0.0.14/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI=
+github.com/RealAlexandreAI/json-repair v0.0.15 h1:AN8/yt8rcphwQrIs/FZeki+cKaIERUNr25zf1flirIs=
+github.com/RealAlexandreAI/json-repair v0.0.15/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI=
 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
 github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
@@ -92,12 +94,12 @@ github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6
 github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
 github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
 github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 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/catwalk v0.15.0 h1:5oWJdvchTPfF7855A0n40+XbZQz4+vouZ/NhQ661JKI=
-github.com/charmbracelet/catwalk v0.15.0/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
 github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
 github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
 github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
@@ -122,20 +124,20 @@ github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9
 github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
 github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
 github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
-github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b h1:5ye9hzBKH623bMVz5auIuY6K21loCdxpRmFle2O9R/8=
-github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
+github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687 h1:h1XMgTkpBt9kEJ+9DkARNBXEgaigUQ0cI2Bot7Awnt8=
+github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
 github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
 github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
 github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
 github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
 github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
-github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE=
-github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
+github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
+github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
 github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
 github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
-github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
-github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
+github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
+github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
 github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
 github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -179,12 +181,12 @@ 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/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
-github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
+github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
 github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
-github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
-github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
+github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
 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/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
@@ -224,14 +226,14 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA
 github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d h1:on25kP+Sx7sxUMRQiA8gdcToAGet4DK/EIA30mXre+4=
 github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d/go.mod h1:SV0W0APWP9MZ1/gfDQ/NzzTlWdIgYZ/ZbpN4d/UXRYw=
 github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
-github.com/kaptinlin/go-i18n v0.2.2 h1:kebVCZme/BrCTqonh/J+VYCl1+Of5C18bvyn3DRPl5M=
-github.com/kaptinlin/go-i18n v0.2.2/go.mod h1:MiwkeHryBopAhC/M3zEwIM/2IN8TvTqJQswPw6kceqM=
-github.com/kaptinlin/jsonpointer v0.4.8 h1:HocHcXrOBfP/nUJw0YYjed/TlQvuCAY6uRs3Qok7F6g=
-github.com/kaptinlin/jsonpointer v0.4.8/go.mod h1:9y0LgXavlmVE5FSHShY5LRlURJJVhbyVJSRWkilrTqA=
-github.com/kaptinlin/jsonschema v0.6.6 h1:UmIF1amA5ijCGSk4tl4ViNlgYL4jzHHvY+Nd5cnkfDI=
-github.com/kaptinlin/jsonschema v0.6.6/go.mod h1:EbhSbdxZ4QjzIORdMWOrRXJeCHrLTJqXDA8JzNaeFc8=
-github.com/kaptinlin/messageformat-go v0.4.7 h1:HQ/OvFUSU7+fAHWkZnP2ug9y+A/ZyTE8j33jfWr8O3Q=
-github.com/kaptinlin/messageformat-go v0.4.7/go.mod h1:DusKpv8CIybczGvwIVn3j13hbR3psr5mOwhFudkiq1c=
+github.com/kaptinlin/go-i18n v0.2.3 h1:jyN/YOXXLcnGRBLdU+a8+6782B97fWE5aQqAHtvvk8Q=
+github.com/kaptinlin/go-i18n v0.2.3/go.mod h1:O+Ax4HkMO0Jt4OaP4E4WCx0PAADeWkwk8Jgt9bjAU1w=
+github.com/kaptinlin/jsonpointer v0.4.9 h1:o//bYf4PCvnMJIIX8bIg77KB6DO3wBPAabRyPRKh680=
+github.com/kaptinlin/jsonpointer v0.4.9/go.mod h1:9y0LgXavlmVE5FSHShY5LRlURJJVhbyVJSRWkilrTqA=
+github.com/kaptinlin/jsonschema v0.6.9 h1:N6bwMCadb0fA9CYINqQbtPhacIIjXmAjuYnJaWeI1bg=
+github.com/kaptinlin/jsonschema v0.6.9/go.mod h1:ZXZ4K5KrRmCCF1i6dgvBsQifl+WTb8XShKj0NpQNrz8=
+github.com/kaptinlin/messageformat-go v0.4.9 h1:FR5j5n4aL4nG0afKn9vvANrKxLu7HjmbhJnw5ogIwAQ=
+github.com/kaptinlin/messageformat-go v0.4.9/go.mod h1:qZzrGrlvWDz2KyyvN3dOWcK9PVSRV1BnfnNU+zB/RWc=
 github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
 github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
 github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
@@ -371,22 +373,22 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
 github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
 github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
 github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
-go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
-go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
-go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
-go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
-go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
-go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
-go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
-go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
+go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
+go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
+go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
+go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
+go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
+go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
 go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
 go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
-go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
-go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
+go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
+go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
 go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
@@ -494,8 +496,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
 gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
 google.golang.org/api v0.239.0 h1:2hZKUnFZEy81eugPs4e2XzIJ5SOwQg0G82bpXD65Puo=
 google.golang.org/api v0.239.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
-google.golang.org/genai v1.41.0 h1:ayXl75LjTmqTu0y94yr96d17gIb4zF8gWVzX2TgioEY=
-google.golang.org/genai v1.41.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
+google.golang.org/genai v1.44.0 h1:+nn8oXANzrpHsWxGfZz2IySq0cFPiepqFvgMFofK8vw=
+google.golang.org/genai v1.44.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
 google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=

internal/agent/agent.go ๐Ÿ”—

@@ -22,16 +22,18 @@ import (
 	"sync"
 	"time"
 
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/fantasy"
 	"charm.land/fantasy/providers/anthropic"
 	"charm.land/fantasy/providers/bedrock"
 	"charm.land/fantasy/providers/google"
 	"charm.land/fantasy/providers/openai"
 	"charm.land/fantasy/providers/openrouter"
+	"charm.land/fantasy/providers/vercel"
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/agent/hyper"
 	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/message"
@@ -167,6 +169,21 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 	largeModel := a.largeModel.Get()
 	systemPrompt := a.systemPrompt.Get()
 	promptPrefix := a.systemPromptPrefix.Get()
+	var instructions strings.Builder
+
+	for _, server := range mcp.GetStates() {
+		if server.State != mcp.StateConnected {
+			continue
+		}
+		if s := server.Client.InitializeResult().Instructions; s != "" {
+			instructions.WriteString(s)
+			instructions.WriteString("\n\n")
+		}
+	}
+
+	if s := instructions.String(); s != "" {
+		systemPrompt += "\n\n<mcp-instructions>\n" + s + "\n</mcp-instructions>"
+	}
 
 	if len(agentTools) > 0 {
 		// Add Anthropic caching to the last tool.
@@ -372,20 +389,18 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 			}
 			currentAssistant.AddFinish(finishReason, "", "")
 			sessionLock.Lock()
-			updatedSession, getSessionErr := a.sessions.Get(genCtx, call.SessionID)
+			defer sessionLock.Unlock()
+
+			updatedSession, getSessionErr := a.sessions.Get(ctx, call.SessionID)
 			if getSessionErr != nil {
-				sessionLock.Unlock()
 				return getSessionErr
 			}
 			a.updateSessionUsage(largeModel, &updatedSession, stepResult.Usage, a.openrouterCost(stepResult.ProviderMetadata))
-			_, sessionErr := a.sessions.Save(genCtx, updatedSession)
-			if sessionErr == nil {
-				currentSession = updatedSession
-			}
-			sessionLock.Unlock()
+			_, sessionErr := a.sessions.Save(ctx, updatedSession)
 			if sessionErr != nil {
 				return sessionErr
 			}
+			currentSession = updatedSession
 			return a.messages.Update(genCtx, *currentAssistant)
 		},
 		StopWhen: []fantasy.StopCondition{
@@ -674,6 +689,9 @@ func (a *sessionAgent) getCacheControlOptions() fantasy.ProviderOptions {
 		bedrock.Name: &anthropic.ProviderCacheControlOptions{
 			CacheControl: anthropic.CacheControl{Type: "ephemeral"},
 		},
+		vercel.Name: &anthropic.ProviderCacheControlOptions{
+			CacheControl: anthropic.CacheControl{Type: "ephemeral"},
+		},
 	}
 }
 
@@ -795,22 +813,22 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
 	resp, err := agent.Stream(ctx, streamCall)
 	if err == nil {
 		// We successfully generated a title with the small model.
-		slog.Info("generated title with small model")
+		slog.Debug("Generated title with small model")
 	} else {
 		// It didn't work. Let's try with the big model.
-		slog.Error("error generating title with small model; trying big model", "err", err)
+		slog.Error("Error generating title with small model; trying big model", "err", err)
 		model = largeModel
 		agent = newAgent(model.Model, titlePrompt, maxOutputTokens)
 		resp, err = agent.Stream(ctx, streamCall)
 		if err == nil {
-			slog.Info("generated title with large model")
+			slog.Debug("Generated title with large model")
 		} else {
 			// Welp, the large model didn't work either. Use the default
 			// session name and return.
-			slog.Error("error generating title with large model", "err", err)
+			slog.Error("Error generating title with large model", "err", err)
 			saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, defaultSessionName, 0, 0, 0)
 			if saveErr != nil {
-				slog.Error("failed to save session title and usage", "error", saveErr)
+				slog.Error("Failed to save session title and usage", "error", saveErr)
 			}
 			return
 		}
@@ -819,10 +837,10 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
 	if resp == nil {
 		// Actually, we didn't get a response so we can't. Use the default
 		// session name and return.
-		slog.Error("response is nil; can't generate title")
+		slog.Error("Response is nil; can't generate title")
 		saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, defaultSessionName, 0, 0, 0)
 		if saveErr != nil {
-			slog.Error("failed to save session title and usage", "error", saveErr)
+			slog.Error("Failed to save session title and usage", "error", saveErr)
 		}
 		return
 	}
@@ -836,7 +854,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
 
 	title = strings.TrimSpace(title)
 	if title == "" {
-		slog.Warn("empty title; using fallback")
+		slog.Debug("Empty title; using fallback")
 		title = defaultSessionName
 	}
 
@@ -871,7 +889,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
 	// concurrent session updates.
 	saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, title, promptTokens, completionTokens, cost)
 	if saveErr != nil {
-		slog.Error("failed to save session title and usage", "error", saveErr)
+		slog.Error("Failed to save session title and usage", "error", saveErr)
 		return
 	}
 }
@@ -905,7 +923,7 @@ func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session,
 	}
 
 	session.CompletionTokens = usage.OutputTokens
-	session.PromptTokens = usage.InputTokens + usage.CacheCreationTokens
+	session.PromptTokens = usage.InputTokens + usage.CacheReadTokens
 }
 
 func (a *sessionAgent) Cancel(sessionID string) {
@@ -914,25 +932,25 @@ func (a *sessionAgent) Cancel(sessionID string) {
 	// fully completes (including error handling that may access the DB).
 	// The defer in processRequest will clean up the entry.
 	if cancel, ok := a.activeRequests.Get(sessionID); ok && cancel != nil {
-		slog.Info("Request cancellation initiated", "session_id", sessionID)
+		slog.Debug("Request cancellation initiated", "session_id", sessionID)
 		cancel()
 	}
 
 	// Also check for summarize requests.
 	if cancel, ok := a.activeRequests.Get(sessionID + "-summarize"); ok && cancel != nil {
-		slog.Info("Summarize cancellation initiated", "session_id", sessionID)
+		slog.Debug("Summarize cancellation initiated", "session_id", sessionID)
 		cancel()
 	}
 
 	if a.QueuedPrompts(sessionID) > 0 {
-		slog.Info("Clearing queued prompts", "session_id", sessionID)
+		slog.Debug("Clearing queued prompts", "session_id", sessionID)
 		a.messageQueue.Del(sessionID)
 	}
 }
 
 func (a *sessionAgent) ClearQueue(sessionID string) {
 	if a.QueuedPrompts(sessionID) > 0 {
-		slog.Info("Clearing queued prompts", "session_id", sessionID)
+		slog.Debug("Clearing queued prompts", "session_id", sessionID)
 		a.messageQueue.Del(sessionID)
 	}
 }
@@ -1092,7 +1110,7 @@ func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Mes
 			if media, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](toolResult.Output); ok {
 				decoded, err := base64.StdEncoding.DecodeString(media.Data)
 				if err != nil {
-					slog.Warn("failed to decode media data", "error", err)
+					slog.Warn("Failed to decode media data", "error", err)
 					textParts = append(textParts, part)
 					continue
 				}

internal/agent/agentic_fetch_tool.go ๐Ÿ”—

@@ -52,13 +52,14 @@ var agenticFetchPromptTmpl []byte
 
 func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (fantasy.AgentTool, error) {
 	if client == nil {
+		transport := http.DefaultTransport.(*http.Transport).Clone()
+		transport.MaxIdleConns = 100
+		transport.MaxIdleConnsPerHost = 10
+		transport.IdleConnTimeout = 90 * time.Second
+
 		client = &http.Client{
-			Timeout: 30 * time.Second,
-			Transport: &http.Transport{
-				MaxIdleConns:        100,
-				MaxIdleConnsPerHost: 10,
-				IdleConnTimeout:     90 * time.Second,
-			},
+			Timeout:   30 * time.Second,
+			Transport: transport,
 		}
 	}
 
@@ -168,7 +169,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
 				tools.NewGlobTool(tmpDir),
 				tools.NewGrepTool(tmpDir),
 				tools.NewSourcegraphTool(client),
-				tools.NewViewTool(c.lspClients, c.permissions, tmpDir),
+				tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, tmpDir),
 			}
 
 			agent := NewSessionAgent(SessionAgentOptions{

internal/agent/common_test.go ๐Ÿ”—

@@ -8,18 +8,19 @@ import (
 	"testing"
 	"time"
 
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/fantasy"
 	"charm.land/fantasy/providers/anthropic"
 	"charm.land/fantasy/providers/openai"
 	"charm.land/fantasy/providers/openaicompat"
 	"charm.land/fantasy/providers/openrouter"
 	"charm.land/x/vcr"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/agent/prompt"
 	"github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/db"
+	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/crush/internal/message"
@@ -37,6 +38,7 @@ type fakeEnv struct {
 	messages    message.Service
 	permissions permission.Service
 	history     history.Service
+	filetracker *filetracker.Service
 	lspClients  *csync.Map[string, *lsp.Client]
 }
 
@@ -117,6 +119,7 @@ func testEnv(t *testing.T) fakeEnv {
 
 	permissions := permission.NewPermissionService(workingDir, true, []string{})
 	history := history.NewService(q, conn)
+	filetrackerService := filetracker.NewService(q)
 	lspClients := csync.NewMap[string, *lsp.Client]()
 
 	t.Cleanup(func() {
@@ -130,6 +133,7 @@ func testEnv(t *testing.T) fakeEnv {
 		messages,
 		permissions,
 		history,
+		&filetrackerService,
 		lspClients,
 	}
 }
@@ -200,15 +204,15 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
 	allTools := []fantasy.AgentTool{
 		tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution, modelName),
 		tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()),
-		tools.NewEditTool(env.lspClients, env.permissions, env.history, env.workingDir),
-		tools.NewMultiEditTool(env.lspClients, env.permissions, env.history, env.workingDir),
+		tools.NewEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
+		tools.NewMultiEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
 		tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()),
 		tools.NewGlobTool(env.workingDir),
 		tools.NewGrepTool(env.workingDir),
 		tools.NewLsTool(env.permissions, env.workingDir, cfg.Tools.Ls),
 		tools.NewSourcegraphTool(r.GetDefaultClient()),
-		tools.NewViewTool(env.lspClients, env.permissions, env.workingDir),
-		tools.NewWriteTool(env.lspClients, env.permissions, env.history, env.workingDir),
+		tools.NewViewTool(env.lspClients, env.permissions, *env.filetracker, env.workingDir),
+		tools.NewWriteTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
 	}
 
 	return testSessionAgent(env, large, small, systemPrompt, allTools...), nil

internal/agent/coordinator.go ๐Ÿ”—

@@ -15,13 +15,14 @@ import (
 	"slices"
 	"strings"
 
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/fantasy"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/agent/hyper"
 	"github.com/charmbracelet/crush/internal/agent/prompt"
 	"github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/log"
 	"github.com/charmbracelet/crush/internal/lsp"
@@ -38,6 +39,7 @@ import (
 	"charm.land/fantasy/providers/openai"
 	"charm.land/fantasy/providers/openaicompat"
 	"charm.land/fantasy/providers/openrouter"
+	"charm.land/fantasy/providers/vercel"
 	openaisdk "github.com/openai/openai-go/v2/option"
 	"github.com/qjebbs/go-jsons"
 )
@@ -65,6 +67,7 @@ type coordinator struct {
 	messages    message.Service
 	permissions permission.Service
 	history     history.Service
+	filetracker filetracker.Service
 	lspClients  *csync.Map[string, *lsp.Client]
 
 	currentAgent SessionAgent
@@ -80,6 +83,7 @@ func NewCoordinator(
 	messages message.Service,
 	permissions permission.Service,
 	history history.Service,
+	filetracker filetracker.Service,
 	lspClients *csync.Map[string, *lsp.Client],
 ) (Coordinator, error) {
 	c := &coordinator{
@@ -88,6 +92,7 @@ func NewCoordinator(
 		messages:    messages,
 		permissions: permissions,
 		history:     history,
+		filetracker: filetracker,
 		lspClients:  lspClients,
 		agents:      make(map[string]SessionAgent),
 	}
@@ -148,7 +153,7 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
 	mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
 
 	if providerCfg.OAuthToken != nil && providerCfg.OAuthToken.IsExpired() {
-		slog.Info("Token needs to be refreshed", "provider", providerCfg.ID)
+		slog.Debug("Token needs to be refreshed", "provider", providerCfg.ID)
 		if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil {
 			return nil, err
 		}
@@ -173,18 +178,18 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
 	if c.isUnauthorized(originalErr) {
 		switch {
 		case providerCfg.OAuthToken != nil:
-			slog.Info("Received 401. Refreshing token and retrying", "provider", providerCfg.ID)
+			slog.Debug("Received 401. Refreshing token and retrying", "provider", providerCfg.ID)
 			if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil {
 				return nil, originalErr
 			}
-			slog.Info("Retrying request with refreshed OAuth token", "provider", providerCfg.ID)
+			slog.Debug("Retrying request with refreshed OAuth token", "provider", providerCfg.ID)
 			return run()
 		case strings.Contains(providerCfg.APIKeyTemplate, "$"):
-			slog.Info("Received 401. Refreshing API Key template and retrying", "provider", providerCfg.ID)
+			slog.Debug("Received 401. Refreshing API Key template and retrying", "provider", providerCfg.ID)
 			if err := c.refreshApiKeyTemplate(ctx, providerCfg); err != nil {
 				return nil, originalErr
 			}
-			slog.Info("Retrying request with refreshed API key", "provider", providerCfg.ID)
+			slog.Debug("Retrying request with refreshed API key", "provider", providerCfg.ID)
 			return run()
 		}
 	}
@@ -240,7 +245,20 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
 		return options
 	}
 
-	switch providerCfg.Type {
+	providerType := providerCfg.Type
+	if providerType == "hyper" {
+		if strings.Contains(model.CatwalkCfg.ID, "claude") {
+			providerType = anthropic.Name
+		} else if strings.Contains(model.CatwalkCfg.ID, "gpt") {
+			providerType = openai.Name
+		} else if strings.Contains(model.CatwalkCfg.ID, "gemini") {
+			providerType = google.Name
+		} else {
+			providerType = openaicompat.Name
+		}
+	}
+
+	switch providerType {
 	case openai.Name, azure.Name:
 		_, hasReasoningEffort := mergedOptions["reasoning_effort"]
 		if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
@@ -286,6 +304,18 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
 		if err == nil {
 			options[openrouter.Name] = parsed
 		}
+	case vercel.Name:
+		_, hasReasoning := mergedOptions["reasoning"]
+		if !hasReasoning && model.ModelCfg.ReasoningEffort != "" {
+			mergedOptions["reasoning"] = map[string]any{
+				"enabled": true,
+				"effort":  model.ModelCfg.ReasoningEffort,
+			}
+		}
+		parsed, err := vercel.ParseOptions(mergedOptions)
+		if err == nil {
+			options[vercel.Name] = parsed
+		}
 	case google.Name:
 		_, hasReasoning := mergedOptions["thinking_config"]
 		if !hasReasoning {
@@ -394,19 +424,19 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
 		tools.NewJobOutputTool(),
 		tools.NewJobKillTool(),
 		tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
-		tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
-		tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
+		tools.NewEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
+		tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
 		tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
 		tools.NewGlobTool(c.cfg.WorkingDir()),
 		tools.NewGrepTool(c.cfg.WorkingDir()),
 		tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
 		tools.NewSourcegraphTool(nil),
 		tools.NewTodosTool(c.sessions),
-		tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
-		tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
+		tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
+		tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
 	)
 
-	if len(c.cfg.LSP) > 0 {
+	if c.lspClients.Len() > 0 {
 		allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients), tools.NewLSPRestartTool(c.lspClients))
 	}
 
@@ -425,7 +455,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
 		}
 		if len(agent.AllowedMCP) == 0 {
 			// No MCPs allowed
-			slog.Debug("no MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
+			slog.Debug("No MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
 			break
 		}
 
@@ -588,6 +618,20 @@ func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[stri
 	return openrouter.New(opts...)
 }
 
+func (c *coordinator) buildVercelProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
+	opts := []vercel.Option{
+		vercel.WithAPIKey(apiKey),
+	}
+	if c.cfg.Options.Debug {
+		httpClient := log.NewHTTPClient()
+		opts = append(opts, vercel.WithHTTPClient(httpClient))
+	}
+	if len(headers) > 0 {
+		opts = append(opts, vercel.WithHeaders(headers))
+	}
+	return vercel.New(opts...)
+}
+
 func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any, providerID string, isSubAgent bool) (fantasy.Provider, error) {
 	opts := []openaicompat.Option{
 		openaicompat.WithBaseURL(baseURL),
@@ -745,6 +789,8 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con
 		return c.buildAnthropicProvider(baseURL, apiKey, headers)
 	case openrouter.Name:
 		return c.buildOpenrouterProvider(baseURL, apiKey, headers)
+	case vercel.Name:
+		return c.buildVercelProvider(baseURL, apiKey, headers)
 	case azure.Name:
 		return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
 	case bedrock.Name:

internal/agent/hyper/provider.go ๐Ÿ”—

@@ -21,9 +21,9 @@ import (
 	"sync"
 	"time"
 
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/fantasy"
 	"charm.land/fantasy/object"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/event"
 )
 
@@ -49,7 +49,10 @@ var Enabled = sync.OnceValue(func() bool {
 var Embedded = sync.OnceValue(func() catwalk.Provider {
 	var provider catwalk.Provider
 	if err := json.Unmarshal(embedded, &provider); err != nil {
-		slog.Error("could not use embedded provider data", "err", err)
+		slog.Error("Could not use embedded provider data", "err", err)
+	}
+	if e := os.Getenv("HYPER_URL"); e != "" {
+		provider.APIEndpoint = e + "/api/v1/fantasy"
 	}
 	return provider
 })

internal/agent/tools/download.go ๐Ÿ”—

@@ -36,13 +36,14 @@ var downloadDescription []byte
 
 func NewDownloadTool(permissions permission.Service, workingDir string, client *http.Client) fantasy.AgentTool {
 	if client == nil {
+		transport := http.DefaultTransport.(*http.Transport).Clone()
+		transport.MaxIdleConns = 100
+		transport.MaxIdleConnsPerHost = 10
+		transport.IdleConnTimeout = 90 * time.Second
+
 		client = &http.Client{
-			Timeout: 5 * time.Minute, // Default 5 minute timeout for downloads
-			Transport: &http.Transport{
-				MaxIdleConns:        100,
-				MaxIdleConnsPerHost: 10,
-				IdleConnTimeout:     90 * time.Second,
-			},
+			Timeout:   5 * time.Minute, // Default 5 minute timeout for downloads
+			Transport: transport,
 		}
 	}
 	return fantasy.NewParallelAgentTool(

internal/agent/tools/edit.go ๐Ÿ”—

@@ -56,10 +56,17 @@ type editContext struct {
 	ctx         context.Context
 	permissions permission.Service
 	files       history.Service
+	filetracker filetracker.Service
 	workingDir  string
 }
 
-func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
+func NewEditTool(
+	lspClients *csync.Map[string, *lsp.Client],
+	permissions permission.Service,
+	files history.Service,
+	filetracker filetracker.Service,
+	workingDir string,
+) fantasy.AgentTool {
 	return fantasy.NewAgentTool(
 		EditToolName,
 		string(editDescription),
@@ -73,7 +80,7 @@ func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
 			var response fantasy.ToolResponse
 			var err error
 
-			editCtx := editContext{ctx, permissions, files, workingDir}
+			editCtx := editContext{ctx, permissions, files, filetracker, workingDir}
 
 			if params.OldString == "" {
 				response, err = createNewFile(editCtx, params.FilePath, params.NewString, call)
@@ -168,8 +175,7 @@ func createNewFile(edit editContext, filePath, content string, call fantasy.Tool
 		slog.Error("Error creating file history version", "error", err)
 	}
 
-	filetracker.RecordWrite(filePath)
-	filetracker.RecordRead(filePath)
+	edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
 
 	return fantasy.WithResponseMetadata(
 		fantasy.NewTextResponse("File created: "+filePath),
@@ -195,12 +201,17 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
 		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
 	}
 
-	if filetracker.LastReadTime(filePath).IsZero() {
+	sessionID := GetSessionFromContext(edit.ctx)
+	if sessionID == "" {
+		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for deleting content")
+	}
+
+	lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
+	if lastRead.IsZero() {
 		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
 	}
 
-	modTime := fileInfo.ModTime()
-	lastRead := filetracker.LastReadTime(filePath)
+	modTime := fileInfo.ModTime().Truncate(time.Second)
 	if modTime.After(lastRead) {
 		return fantasy.NewTextErrorResponse(
 			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
@@ -236,12 +247,6 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
 		newContent = oldContent[:index] + oldContent[index+len(oldString):]
 	}
 
-	sessionID := GetSessionFromContext(edit.ctx)
-
-	if sessionID == "" {
-		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for deleting content")
-	}
-
 	_, additions, removals := diff.GenerateDiff(
 		oldContent,
 		newContent,
@@ -301,8 +306,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
 		slog.Error("Error creating file history version", "error", err)
 	}
 
-	filetracker.RecordWrite(filePath)
-	filetracker.RecordRead(filePath)
+	edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
 
 	return fantasy.WithResponseMetadata(
 		fantasy.NewTextResponse("Content deleted from file: "+filePath),
@@ -328,12 +332,17 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
 		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
 	}
 
-	if filetracker.LastReadTime(filePath).IsZero() {
+	sessionID := GetSessionFromContext(edit.ctx)
+	if sessionID == "" {
+		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for edit a file")
+	}
+
+	lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
+	if lastRead.IsZero() {
 		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
 	}
 
-	modTime := fileInfo.ModTime()
-	lastRead := filetracker.LastReadTime(filePath)
+	modTime := fileInfo.ModTime().Truncate(time.Second)
 	if modTime.After(lastRead) {
 		return fantasy.NewTextErrorResponse(
 			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
@@ -369,11 +378,6 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
 	if oldContent == newContent {
 		return fantasy.NewTextErrorResponse("new content is the same as old content. No changes made."), nil
 	}
-	sessionID := GetSessionFromContext(edit.ctx)
-
-	if sessionID == "" {
-		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
-	}
 	_, additions, removals := diff.GenerateDiff(
 		oldContent,
 		newContent,
@@ -433,8 +437,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
 		slog.Error("Error creating file history version", "error", err)
 	}
 
-	filetracker.RecordWrite(filePath)
-	filetracker.RecordRead(filePath)
+	edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
 
 	return fantasy.WithResponseMetadata(
 		fantasy.NewTextResponse("Content replaced in file: "+filePath),

internal/agent/tools/fetch.go ๐Ÿ”—

@@ -23,13 +23,14 @@ var fetchDescription []byte
 
 func NewFetchTool(permissions permission.Service, workingDir string, client *http.Client) fantasy.AgentTool {
 	if client == nil {
+		transport := http.DefaultTransport.(*http.Transport).Clone()
+		transport.MaxIdleConns = 100
+		transport.MaxIdleConnsPerHost = 10
+		transport.IdleConnTimeout = 90 * time.Second
+
 		client = &http.Client{
-			Timeout: 30 * time.Second,
-			Transport: &http.Transport{
-				MaxIdleConns:        100,
-				MaxIdleConnsPerHost: 10,
-				IdleConnTimeout:     90 * time.Second,
-			},
+			Timeout:   30 * time.Second,
+			Transport: transport,
 		}
 	}
 

internal/agent/tools/mcp/init.go ๐Ÿ”—

@@ -135,12 +135,13 @@ func Close() error {
 
 // Initialize initializes MCP clients based on the provided configuration.
 func Initialize(ctx context.Context, permissions permission.Service, cfg *config.Config) {
+	slog.Info("Initializing MCP clients")
 	var wg sync.WaitGroup
 	// Initialize states for all configured MCPs
 	for name, m := range cfg.MCP {
 		if m.Disabled {
 			updateState(name, StateDisabled, nil, nil, Counts{})
-			slog.Debug("skipping disabled mcp", "name", name)
+			slog.Debug("Skipping disabled MCP", "name", name)
 			continue
 		}
 
@@ -162,7 +163,7 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
 						err = fmt.Errorf("panic: %v", v)
 					}
 					updateState(name, StateError, err, nil, Counts{})
-					slog.Error("panic in mcp client initialization", "error", err, "name", name)
+					slog.Error("Panic in MCP client initialization", "error", err, "name", name)
 				}
 			}()
 
@@ -174,7 +175,7 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
 
 			tools, err := getTools(ctx, session)
 			if err != nil {
-				slog.Error("error listing tools", "error", err)
+				slog.Error("Error listing tools", "error", err)
 				updateState(name, StateError, err, nil, Counts{})
 				session.Close()
 				return
@@ -182,7 +183,7 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
 
 			prompts, err := getPrompts(ctx, session)
 			if err != nil {
-				slog.Error("error listing prompts", "error", err)
+				slog.Error("Error listing prompts", "error", err)
 				updateState(name, StateError, err, nil, Counts{})
 				session.Close()
 				return
@@ -277,7 +278,7 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve
 	transport, err := createTransport(mcpCtx, m, resolver)
 	if err != nil {
 		updateState(name, StateError, err, nil, Counts{})
-		slog.Error("error creating mcp client", "error", err, "name", name)
+		slog.Error("Error creating MCP client", "error", err, "name", name)
 		cancel()
 		cancelTimer.Stop()
 		return nil, err
@@ -319,7 +320,7 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve
 	}
 
 	cancelTimer.Stop()
-	slog.Info("MCP client initialized", "name", name)
+	slog.Debug("MCP client initialized", "name", name)
 	return session, nil
 }
 

internal/agent/tools/mcp/prompts.go ๐Ÿ”—

@@ -49,7 +49,7 @@ func GetPromptMessages(ctx context.Context, clientName, promptName string, args
 func RefreshPrompts(ctx context.Context, name string) {
 	session, ok := sessions.Get(name)
 	if !ok {
-		slog.Warn("refresh prompts: no session", "name", name)
+		slog.Warn("Refresh prompts: no session", "name", name)
 		return
 	}
 

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

@@ -111,7 +111,7 @@ func RunTool(ctx context.Context, name, toolName string, input string) (ToolResu
 func RefreshTools(ctx context.Context, name string) {
 	session, ok := sessions.Get(name)
 	if !ok {
-		slog.Warn("refresh tools: no session", "name", name)
+		slog.Warn("Refresh tools: no session", "name", name)
 		return
 	}
 

internal/agent/tools/multiedit.go ๐Ÿ”—

@@ -58,7 +58,13 @@ const MultiEditToolName = "multiedit"
 //go:embed multiedit.md
 var multieditDescription []byte
 
-func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
+func NewMultiEditTool(
+	lspClients *csync.Map[string, *lsp.Client],
+	permissions permission.Service,
+	files history.Service,
+	filetracker filetracker.Service,
+	workingDir string,
+) fantasy.AgentTool {
 	return fantasy.NewAgentTool(
 		MultiEditToolName,
 		string(multieditDescription),
@@ -81,7 +87,7 @@ func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions pe
 			var response fantasy.ToolResponse
 			var err error
 
-			editCtx := editContext{ctx, permissions, files, workingDir}
+			editCtx := editContext{ctx, permissions, files, filetracker, workingDir}
 			// Handle file creation case (first edit has empty old_string)
 			if len(params.Edits) > 0 && params.Edits[0].OldString == "" {
 				response, err = processMultiEditWithCreation(editCtx, params, call)
@@ -210,8 +216,7 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call
 		slog.Error("Error creating file history version", "error", err)
 	}
 
-	filetracker.RecordWrite(params.FilePath)
-	filetracker.RecordRead(params.FilePath)
+	edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath)
 
 	var message string
 	if len(failedEdits) > 0 {
@@ -247,14 +252,19 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
 		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
 	}
 
+	sessionID := GetSessionFromContext(edit.ctx)
+	if sessionID == "" {
+		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file")
+	}
+
 	// Check if file was read before editing
-	if filetracker.LastReadTime(params.FilePath).IsZero() {
+	lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, params.FilePath)
+	if lastRead.IsZero() {
 		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
 	}
 
-	// Check if file was modified since last read
-	modTime := fileInfo.ModTime()
-	lastRead := filetracker.LastReadTime(params.FilePath)
+	// Check if file was modified since last read.
+	modTime := fileInfo.ModTime().Truncate(time.Second)
 	if modTime.After(lastRead) {
 		return fantasy.NewTextErrorResponse(
 			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
@@ -301,12 +311,6 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
 		return fantasy.NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
 	}
 
-	// Get session and message IDs
-	sessionID := GetSessionFromContext(edit.ctx)
-	if sessionID == "" {
-		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file")
-	}
-
 	// Generate diff and check permissions
 	_, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
 
@@ -369,8 +373,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
 		slog.Error("Error creating file history version", "error", err)
 	}
 
-	filetracker.RecordWrite(params.FilePath)
-	filetracker.RecordRead(params.FilePath)
+	edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath)
 
 	var message string
 	if len(failedEdits) > 0 {

internal/agent/tools/multiedit_test.go ๐Ÿ”—

@@ -6,10 +6,7 @@ import (
 	"path/filepath"
 	"testing"
 
-	"github.com/charmbracelet/crush/internal/csync"
-	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/history"
-	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/stretchr/testify/require"
@@ -111,17 +108,6 @@ func TestMultiEditSequentialApplication(t *testing.T) {
 	err := os.WriteFile(testFile, []byte(content), 0o644)
 	require.NoError(t, err)
 
-	// Mock components.
-	lspClients := csync.NewMap[string, *lsp.Client]()
-	permissions := &mockPermissionService{Broker: pubsub.NewBroker[permission.PermissionRequest]()}
-	files := &mockHistoryService{Broker: pubsub.NewBroker[history.File]()}
-
-	// Create multiedit tool.
-	_ = NewMultiEditTool(lspClients, permissions, files, tmpDir)
-
-	// Simulate reading the file first.
-	filetracker.RecordRead(testFile)
-
 	// Manually test the sequential application logic.
 	currentContent := content
 

internal/agent/tools/sourcegraph.go ๐Ÿ”—

@@ -33,13 +33,14 @@ var sourcegraphDescription []byte
 
 func NewSourcegraphTool(client *http.Client) fantasy.AgentTool {
 	if client == nil {
+		transport := http.DefaultTransport.(*http.Transport).Clone()
+		transport.MaxIdleConns = 100
+		transport.MaxIdleConnsPerHost = 10
+		transport.IdleConnTimeout = 90 * time.Second
+
 		client = &http.Client{
-			Timeout: 30 * time.Second,
-			Transport: &http.Transport{
-				MaxIdleConns:        100,
-				MaxIdleConnsPerHost: 10,
-				IdleConnTimeout:     90 * time.Second,
-			},
+			Timeout:   30 * time.Second,
+			Transport: transport,
 		}
 	}
 	return fantasy.NewParallelAgentTool(

internal/agent/tools/view.go ๐Ÿ”—

@@ -47,7 +47,13 @@ const (
 	MaxLineLength    = 2000
 )
 
-func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string, skillsPaths ...string) fantasy.AgentTool {
+func NewViewTool(
+	lspClients *csync.Map[string, *lsp.Client],
+	permissions permission.Service,
+	filetracker filetracker.Service,
+	workingDir string,
+	skillsPaths ...string,
+) fantasy.AgentTool {
 	return fantasy.NewAgentTool(
 		ViewToolName,
 		string(viewDescription),
@@ -74,13 +80,13 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
 			isOutsideWorkDir := err != nil || strings.HasPrefix(relPath, "..")
 			isSkillFile := isInSkillsPath(absFilePath, skillsPaths)
 
+			sessionID := GetSessionFromContext(ctx)
+			if sessionID == "" {
+				return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
+			}
+
 			// Request permission for files outside working directory, unless it's a skill file.
 			if isOutsideWorkDir && !isSkillFile {
-				sessionID := GetSessionFromContext(ctx)
-				if sessionID == "" {
-					return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
-				}
-
 				granted, err := permissions.Request(ctx,
 					permission.CreatePermissionRequest{
 						SessionID:   sessionID,
@@ -190,7 +196,7 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
 			}
 			output += "\n</file>\n"
 			output += getDiagnostics(filePath, lspClients)
-			filetracker.RecordRead(filePath)
+			filetracker.RecordRead(ctx, sessionID, filePath)
 			return fantasy.WithResponseMetadata(
 				fantasy.NewTextResponse(output),
 				ViewResponseMetadata{

internal/agent/tools/web_fetch.go ๐Ÿ”—

@@ -18,13 +18,14 @@ var webFetchToolDescription []byte
 // NewWebFetchTool creates a simple web fetch tool for sub-agents (no permissions needed).
 func NewWebFetchTool(workingDir string, client *http.Client) fantasy.AgentTool {
 	if client == nil {
+		transport := http.DefaultTransport.(*http.Transport).Clone()
+		transport.MaxIdleConns = 100
+		transport.MaxIdleConnsPerHost = 10
+		transport.IdleConnTimeout = 90 * time.Second
+
 		client = &http.Client{
-			Timeout: 30 * time.Second,
-			Transport: &http.Transport{
-				MaxIdleConns:        100,
-				MaxIdleConnsPerHost: 10,
-				IdleConnTimeout:     90 * time.Second,
-			},
+			Timeout:   30 * time.Second,
+			Transport: transport,
 		}
 	}
 

internal/agent/tools/web_search.go ๐Ÿ”—

@@ -16,13 +16,14 @@ var webSearchToolDescription []byte
 // NewWebSearchTool creates a web search tool for sub-agents (no permissions needed).
 func NewWebSearchTool(client *http.Client) fantasy.AgentTool {
 	if client == nil {
+		transport := http.DefaultTransport.(*http.Transport).Clone()
+		transport.MaxIdleConns = 100
+		transport.MaxIdleConnsPerHost = 10
+		transport.IdleConnTimeout = 90 * time.Second
+
 		client = &http.Client{
-			Timeout: 30 * time.Second,
-			Transport: &http.Transport{
-				MaxIdleConns:        100,
-				MaxIdleConnsPerHost: 10,
-				IdleConnTimeout:     90 * time.Second,
-			},
+			Timeout:   30 * time.Second,
+			Transport: transport,
 		}
 	}
 

internal/agent/tools/write.go ๐Ÿ”—

@@ -44,7 +44,13 @@ type WriteResponseMetadata struct {
 
 const WriteToolName = "write"
 
-func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
+func NewWriteTool(
+	lspClients *csync.Map[string, *lsp.Client],
+	permissions permission.Service,
+	files history.Service,
+	filetracker filetracker.Service,
+	workingDir string,
+) fantasy.AgentTool {
 	return fantasy.NewAgentTool(
 		WriteToolName,
 		string(writeDescription),
@@ -57,6 +63,11 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
 				return fantasy.NewTextErrorResponse("content is required"), nil
 			}
 
+			sessionID := GetSessionFromContext(ctx)
+			if sessionID == "" {
+				return fantasy.ToolResponse{}, fmt.Errorf("session_id is required")
+			}
+
 			filePath := filepathext.SmartJoin(workingDir, params.FilePath)
 
 			fileInfo, err := os.Stat(filePath)
@@ -65,8 +76,8 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
 					return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
 				}
 
-				modTime := fileInfo.ModTime()
-				lastRead := filetracker.LastReadTime(filePath)
+				modTime := fileInfo.ModTime().Truncate(time.Second)
+				lastRead := filetracker.LastReadTime(ctx, sessionID, filePath)
 				if modTime.After(lastRead) {
 					return fantasy.NewTextErrorResponse(fmt.Sprintf("File %s has been modified since it was last read.\nLast modification: %s\nLast read: %s\n\nPlease read the file again before modifying it.",
 						filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
@@ -93,11 +104,6 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
 				}
 			}
 
-			sessionID := GetSessionFromContext(ctx)
-			if sessionID == "" {
-				return fantasy.ToolResponse{}, fmt.Errorf("session_id is required")
-			}
-
 			diff, additions, removals := diff.GenerateDiff(
 				oldContent,
 				params.Content,
@@ -153,8 +159,7 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
 				slog.Error("Error creating file history version", "error", err)
 			}
 
-			filetracker.RecordWrite(filePath)
-			filetracker.RecordRead(filePath)
+			filetracker.RecordRead(ctx, sessionID, filePath)
 
 			notifyLSPs(ctx, lspClients, params.FilePath)
 

internal/app/app.go ๐Ÿ”—

@@ -15,14 +15,15 @@ import (
 	"time"
 
 	tea "charm.land/bubbletea/v2"
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/fantasy"
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/agent"
 	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/db"
+	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/format"
 	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/log"
@@ -53,6 +54,7 @@ type App struct {
 	Messages    message.Service
 	History     history.Service
 	Permissions permission.Service
+	FileTracker filetracker.Service
 
 	AgentCoordinator agent.Coordinator
 
@@ -87,6 +89,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
 		Messages:    messages,
 		History:     files,
 		Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
+		FileTracker: filetracker.NewService(q),
 		LSPClients:  csync.NewMap[string, *lsp.Client](),
 
 		globalCtx: ctx,
@@ -101,15 +104,12 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
 	app.setupEvents()
 
 	// Initialize LSP clients in the background.
-	app.initLSPClients(ctx)
+	go app.initLSPClients(ctx)
 
 	// Check for updates in the background.
 	go app.checkForUpdates(ctx)
 
-	go func() {
-		slog.Info("Initializing MCP clients")
-		mcp.Initialize(ctx, app.Permissions, cfg)
-	}()
+	go mcp.Initialize(ctx, app.Permissions, cfg)
 
 	// cleanup database upon app shutdown
 	app.cleanupFuncs = append(app.cleanupFuncs, conn.Close, mcp.Close)
@@ -132,7 +132,7 @@ func (app *App) Config() *config.Config {
 
 // RunNonInteractive runs the application in non-interactive mode with the
 // given prompt, printing to stdout.
-func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, quiet bool) error {
+func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool) error {
 	slog.Info("Running in non-interactive mode")
 
 	ctx, cancel := context.WithCancel(ctx)
@@ -149,6 +149,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
 		stdoutTTY bool
 		stderrTTY bool
 		stdinTTY  bool
+		progress  bool
 	)
 
 	if f, ok := output.(*os.File); ok {
@@ -156,8 +157,9 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
 	}
 	stderrTTY = term.IsTerminal(os.Stderr.Fd())
 	stdinTTY = term.IsTerminal(os.Stdin.Fd())
+	progress = app.config.Options.Progress == nil || *app.config.Options.Progress
 
-	if !quiet && stderrTTY {
+	if !hideSpinner && stderrTTY {
 		t := styles.CurrentTheme()
 
 		// Detect background color to set the appropriate color for the
@@ -182,7 +184,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
 
 	// Helper function to stop spinner once.
 	stopSpinner := func() {
-		if !quiet && spinner != nil {
+		if !hideSpinner && spinner != nil {
 			spinner.Stop()
 			spinner = nil
 		}
@@ -239,9 +241,10 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
 
 	messageEvents := app.Messages.Subscribe(ctx)
 	messageReadBytes := make(map[string]int)
+	var printed bool
 
 	defer func() {
-		if stderrTTY {
+		if progress && stderrTTY {
 			_, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
 		}
 
@@ -251,7 +254,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
 	}()
 
 	for {
-		if stderrTTY {
+		if progress && stderrTTY {
 			// HACK: Reinitialize the terminal progress bar on every iteration
 			// so it doesn't get hidden by the terminal due to inactivity.
 			_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
@@ -262,7 +265,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
 			stopSpinner()
 			if result.err != nil {
 				if errors.Is(result.err, context.Canceled) || errors.Is(result.err, agent.ErrRequestCancelled) {
-					slog.Info("Non-interactive: agent processing cancelled", "session_id", sess.ID)
+					slog.Debug("Non-interactive: agent processing cancelled", "session_id", sess.ID)
 					return nil
 				}
 				return fmt.Errorf("agent processing failed: %w", result.err)
@@ -288,7 +291,11 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
 				if readBytes == 0 {
 					part = strings.TrimLeft(part, " \t")
 				}
-				fmt.Fprint(output, part)
+				// Ignore initial whitespace-only messages.
+				if printed || strings.TrimSpace(part) != "" {
+					printed = true
+					fmt.Fprint(output, part)
+				}
 				messageReadBytes[msg.ID] = len(content)
 			}
 
@@ -427,20 +434,20 @@ func setupSubscriber[T any](
 			select {
 			case event, ok := <-subCh:
 				if !ok {
-					slog.Debug("subscription channel closed", "name", name)
+					slog.Debug("Subscription channel closed", "name", name)
 					return
 				}
 				var msg tea.Msg = event
 				select {
 				case outputCh <- msg:
 				case <-time.After(2 * time.Second):
-					slog.Warn("message dropped due to slow consumer", "name", name)
+					slog.Debug("Message dropped due to slow consumer", "name", name)
 				case <-ctx.Done():
-					slog.Debug("subscription cancelled", "name", name)
+					slog.Debug("Subscription cancelled", "name", name)
 					return
 				}
 			case <-ctx.Done():
-				slog.Debug("subscription cancelled", "name", name)
+				slog.Debug("Subscription cancelled", "name", name)
 				return
 			}
 		}
@@ -460,6 +467,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error {
 		app.Messages,
 		app.Permissions,
 		app.History,
+		app.FileTracker,
 		app.LSPClients,
 	)
 	if err != nil {
@@ -504,7 +512,7 @@ func (app *App) Subscribe(program *tea.Program) {
 // Shutdown performs a graceful shutdown of the application.
 func (app *App) Shutdown() {
 	start := time.Now()
-	defer func() { slog.Info("Shutdown took " + time.Since(start).String()) }()
+	defer func() { slog.Debug("Shutdown took " + time.Since(start).String()) }()
 
 	// First, cancel all agents and wait for them to finish. This must complete
 	// before closing the DB so agents can finish writing their state.

internal/app/lsp.go ๐Ÿ”—

@@ -1,43 +1,126 @@
 package app
 
 import (
+	"cmp"
 	"context"
 	"log/slog"
+	"os/exec"
+	"slices"
+	"sync"
 	"time"
 
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/lsp"
+	powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
 )
 
 // initLSPClients initializes LSP clients.
 func (app *App) initLSPClients(ctx context.Context) {
+	slog.Info("LSP clients initialization started")
+
+	manager := powernapconfig.NewManager()
+	manager.LoadDefaults()
+
+	var userConfiguredLSPs []string
 	for name, clientConfig := range app.config.LSP {
 		if clientConfig.Disabled {
 			slog.Info("Skipping disabled LSP client", "name", name)
+			manager.RemoveServer(name)
+			continue
+		}
+
+		// HACK: the user might have the command name in their config, instead
+		// of the actual name. This finds out these cases, and adjusts the name
+		// accordingly.
+		if _, ok := manager.GetServer(name); !ok {
+			for sname, server := range manager.GetServers() {
+				if server.Command == name {
+					name = sname
+					break
+				}
+			}
+		}
+		userConfiguredLSPs = append(userConfiguredLSPs, name)
+		manager.AddServer(name, &powernapconfig.ServerConfig{
+			Command:     clientConfig.Command,
+			Args:        clientConfig.Args,
+			Environment: clientConfig.Env,
+			FileTypes:   clientConfig.FileTypes,
+			RootMarkers: clientConfig.RootMarkers,
+			InitOptions: clientConfig.InitOptions,
+			Settings:    clientConfig.Options,
+		})
+	}
+
+	servers := manager.GetServers()
+	filtered := lsp.FilterMatching(app.config.WorkingDir(), servers)
+
+	for _, name := range userConfiguredLSPs {
+		if _, ok := filtered[name]; !ok {
+			updateLSPState(name, lsp.StateDisabled, nil, nil, 0)
+		}
+	}
+
+	var wg sync.WaitGroup
+	for name, server := range filtered {
+		if app.config.Options.AutoLSP != nil && !*app.config.Options.AutoLSP && !slices.Contains(userConfiguredLSPs, name) {
+			slog.Debug("Ignoring non user-define LSP client due to AutoLSP being disabled", "name", name)
 			continue
 		}
-		go app.createAndStartLSPClient(ctx, name, clientConfig)
+		wg.Go(func() {
+			app.createAndStartLSPClient(
+				ctx, name,
+				toOurConfig(server, app.config.LSP[name]),
+				slices.Contains(userConfiguredLSPs, name),
+			)
+		})
+	}
+	wg.Wait()
+
+	if app.AgentCoordinator != nil {
+		if err := app.AgentCoordinator.UpdateModels(ctx); err != nil {
+			slog.Error("Failed to refresh tools after LSP startup", "error", err)
+		}
 	}
-	slog.Info("LSP clients initialization started in background")
 }
 
-// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher
-func (app *App) createAndStartLSPClient(ctx context.Context, name string, config config.LSPConfig) {
-	slog.Debug("Creating LSP client", "name", name, "command", config.Command, "fileTypes", config.FileTypes, "args", config.Args)
+// toOurConfig merges powernap default config with user config.
+// If user config is zero value, it means no user override exists.
+func toOurConfig(in *powernapconfig.ServerConfig, user config.LSPConfig) config.LSPConfig {
+	return config.LSPConfig{
+		Command:     in.Command,
+		Args:        in.Args,
+		Env:         in.Environment,
+		FileTypes:   in.FileTypes,
+		RootMarkers: in.RootMarkers,
+		InitOptions: in.InitOptions,
+		Options:     in.Settings,
+		Timeout:     user.Timeout,
+	}
+}
 
-	// Check if any root markers exist in the working directory (config now has defaults)
-	if !lsp.HasRootMarkers(app.config.WorkingDir(), config.RootMarkers) {
-		slog.Debug("Skipping LSP client: no root markers found", "name", name, "rootMarkers", config.RootMarkers)
-		updateLSPState(name, lsp.StateDisabled, nil, nil, 0)
-		return
+// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher.
+func (app *App) createAndStartLSPClient(ctx context.Context, name string, config config.LSPConfig, userConfigured bool) {
+	if !userConfigured {
+		if _, err := exec.LookPath(config.Command); err != nil {
+			slog.Warn("Default LSP config skipped: server not installed", "name", name, "error", err)
+			return
+		}
 	}
 
-	// Update state to starting
+	slog.Debug("Creating LSP client", "name", name, "command", config.Command, "fileTypes", config.FileTypes, "args", config.Args)
+
+	// Update state to starting.
 	updateLSPState(name, lsp.StateStarting, nil, nil, 0)
 
 	// Create LSP client.
 	lspClient, err := lsp.New(ctx, name, config, app.config.Resolver())
 	if err != nil {
+		if !userConfigured {
+			slog.Warn("Default LSP config skipped due to error", "name", name, "error", err)
+			updateLSPState(name, lsp.StateDisabled, nil, nil, 0)
+			return
+		}
 		slog.Error("Failed to create LSP client for", "name", name, "error", err)
 		updateLSPState(name, lsp.StateError, err, nil, 0)
 		return
@@ -47,7 +130,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config
 	lspClient.SetDiagnosticsCallback(updateLSPDiagnostics)
 
 	// Increase initialization timeout as some servers take more time to start.
-	initCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
+	initCtx, cancel := context.WithTimeout(ctx, time.Duration(cmp.Or(config.Timeout, 30))*time.Second)
 	defer cancel()
 
 	// Initialize LSP client.
@@ -73,7 +156,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config
 		updateLSPState(name, lsp.StateReady, nil, lspClient, 0)
 	}
 
-	slog.Info("LSP client initialized", "name", name)
+	slog.Debug("LSP client initialized", "name", name)
 
 	// Add to map with mutex protection before starting goroutine
 	app.LSPClients.Set(name, lspClient)

internal/app/provider_test.go ๐Ÿ”—

@@ -3,7 +3,7 @@ package app
 import (
 	"testing"
 
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/stretchr/testify/require"
 )

internal/cmd/models.go ๐Ÿ”—

@@ -7,8 +7,8 @@ import (
 	"sort"
 	"strings"
 
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/lipgloss/v2/tree"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/mattn/go-isatty"
 	"github.com/spf13/cobra"

internal/cmd/root.go ๐Ÿ”—

@@ -93,12 +93,16 @@ crush -y
 		// Set up the TUI.
 		var env uv.Environ = os.Environ()
 
+		newUI := true
+		if v, err := strconv.ParseBool(env.Getenv("CRUSH_NEW_UI")); err == nil {
+			newUI = v
+		}
+
 		var model tea.Model
-		if v, _ := strconv.ParseBool(env.Getenv("CRUSH_NEW_UI")); v {
+		if newUI {
 			slog.Info("New UI in control!")
 			com := common.DefaultCommon(app)
 			ui := ui.New(com)
-			ui.QueryCapabilities = shouldQueryCapabilities(env)
 			model = ui
 		} else {
 			ui := tui.New(app)
@@ -180,12 +184,19 @@ func supportsProgressBar() bool {
 }
 
 func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
-	if supportsProgressBar() {
+	app, err := setupApp(cmd)
+	if err != nil {
+		return nil, err
+	}
+
+	// Check if progress bar is enabled in config (defaults to true if nil)
+	progressEnabled := app.Config().Options.Progress == nil || *app.Config().Options.Progress
+	if progressEnabled && supportsProgressBar() {
 		_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
 		defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
 	}
 
-	return setupApp(cmd)
+	return app, nil
 }
 
 // setupApp handles the common setup logic for both interactive and non-interactive modes.
@@ -303,6 +314,7 @@ func createDotCrushDir(dir string) error {
 	return nil
 }
 
+// TODO: Remove me after dropping the old TUI.
 func shouldQueryCapabilities(env uv.Environ) bool {
 	const osVendorTypeApple = "Apple"
 	termType := env.Getenv("TERM")

internal/cmd/run.go ๐Ÿ”—

@@ -8,6 +8,7 @@ import (
 	"os/signal"
 	"strings"
 
+	"charm.land/log/v2"
 	"github.com/charmbracelet/crush/internal/event"
 	"github.com/spf13/cobra"
 )
@@ -29,9 +30,13 @@ crush run "What is this code doing?" <<< prrr.go
 
 # Run in quiet mode (hide the spinner)
 crush run --quiet "Generate a README for this project"
+
+# Run in verbose mode
+crush run --verbose "Generate a README for this project"
   `,
 	RunE: func(cmd *cobra.Command, args []string) error {
 		quiet, _ := cmd.Flags().GetBool("quiet")
+		verbose, _ := cmd.Flags().GetBool("verbose")
 		largeModel, _ := cmd.Flags().GetString("model")
 		smallModel, _ := cmd.Flags().GetString("small-model")
 
@@ -49,6 +54,10 @@ crush run --quiet "Generate a README for this project"
 			return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
 		}
 
+		if verbose {
+			slog.SetDefault(slog.New(log.New(os.Stderr)))
+		}
+
 		prompt := strings.Join(args, " ")
 
 		prompt, err = MaybePrependStdin(prompt)
@@ -64,7 +73,7 @@ crush run --quiet "Generate a README for this project"
 		event.SetNonInteractive(true)
 		event.AppInitialized()
 
-		return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet)
+		return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet || verbose)
 	},
 	PostRun: func(cmd *cobra.Command, args []string) {
 		event.AppExited()
@@ -73,6 +82,7 @@ crush run --quiet "Generate a README for this project"
 
 func init() {
 	runCmd.Flags().BoolP("quiet", "q", false, "Hide spinner")
+	runCmd.Flags().BoolP("verbose", "v", false, "Show logs")
 	runCmd.Flags().StringP("model", "m", "", "Model to use. Accepts 'model' or 'provider/model' to disambiguate models with the same name across providers")
 	runCmd.Flags().String("small-model", "", "Small model to use. If not provided, uses the default small model for the provider")
 }

internal/cmd/stats/index.css ๐Ÿ”—

@@ -189,20 +189,15 @@ body {
 }
 
 .chart-row {
-  display: grid;
-  grid-template-columns: repeat(2, 1fr);
+  display: flex;
+  flex-wrap: wrap;
   gap: 1.5rem;
   width: 100%;
 }
 
 .chart-row .chart-card {
-  width: 100%;
-}
-
-@media (max-width: 1024px) {
-  .chart-row {
-    grid-template-columns: 1fr;
-  }
+  flex: 1 1 300px;
+  max-width: calc((100% - 1.5rem) / 2);
 }
 
 .chart-card h2 {

internal/cmd/stats/index.html ๐Ÿ”—

@@ -28,7 +28,7 @@
       </div>
 
       <div class="header-info">
-        Generated by {{.Username}} for {{.ProjectName}} in {{.GeneratedAt}}.
+        Generated by {{.Username}} for {{.ProjectName}} on {{.GeneratedAt}}.
       </div>
 
       <div class="stats-grid">

internal/config/catwalk.go ๐Ÿ”—

@@ -7,8 +7,8 @@ import (
 	"sync"
 	"sync/atomic"
 
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/catwalk/pkg/embedded"
+	"charm.land/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/embedded"
 )
 
 type catwalkClient interface {

internal/config/catwalk_test.go ๐Ÿ”—

@@ -7,7 +7,7 @@ import (
 	"os"
 	"testing"
 
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/stretchr/testify/require"
 )
 

internal/config/config.go ๐Ÿ”—

@@ -14,7 +14,7 @@ import (
 	"strings"
 	"time"
 
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/env"
@@ -187,13 +187,14 @@ type MCPConfig struct {
 
 type LSPConfig struct {
 	Disabled    bool              `json:"disabled,omitempty" jsonschema:"description=Whether this LSP server is disabled,default=false"`
-	Command     string            `json:"command,omitempty" jsonschema:"required,description=Command to execute for the LSP server,example=gopls"`
+	Command     string            `json:"command,omitempty" jsonschema:"description=Command to execute for the LSP server,example=gopls"`
 	Args        []string          `json:"args,omitempty" jsonschema:"description=Arguments to pass to the LSP server command"`
 	Env         map[string]string `json:"env,omitempty" jsonschema:"description=Environment variables to set to the LSP server command"`
 	FileTypes   []string          `json:"filetypes,omitempty" jsonschema:"description=File types this LSP server handles,example=go,example=mod,example=rs,example=c,example=js,example=ts"`
 	RootMarkers []string          `json:"root_markers,omitempty" jsonschema:"description=Files or directories that indicate the project root,example=go.mod,example=package.json,example=Cargo.toml"`
 	InitOptions map[string]any    `json:"init_options,omitempty" jsonschema:"description=Initialization options passed to the LSP server during initialize request"`
 	Options     map[string]any    `json:"options,omitempty" jsonschema:"description=LSP server-specific settings passed during initialization"`
+	Timeout     int               `json:"timeout,omitempty" jsonschema:"description=Timeout in seconds for LSP server initialization,default=30,example=60,example=120"`
 }
 
 type TUIOptions struct {
@@ -203,6 +204,7 @@ type TUIOptions struct {
 	//
 
 	Completions Completions `json:"completions,omitzero" jsonschema:"description=Completions UI options"`
+	Transparent *bool       `json:"transparent,omitempty" jsonschema:"description=Enable transparent background for the TUI interface,default=false"`
 }
 
 // Completions defines options for the completions UI.
@@ -257,6 +259,8 @@ type Options struct {
 	Attribution               *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"`
 	DisableMetrics            bool         `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"`
 	InitializeAs              string       `json:"initialize_as,omitempty" jsonschema:"description=Name of the context file to create/update during project initialization,default=AGENTS.md,example=AGENTS.md,example=CRUSH.md,example=CLAUDE.md,example=docs/LLMs.md"`
+	AutoLSP                   *bool        `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"`
+	Progress                  *bool        `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"`
 }
 
 type MCPs map[string]MCPConfig
@@ -315,7 +319,7 @@ func (m MCPConfig) ResolvedHeaders() map[string]string {
 		var err error
 		m.Headers[e], err = resolver.ResolveValue(v)
 		if err != nil {
-			slog.Error("error resolving header variable", "error", err, "variable", e, "value", v)
+			slog.Error("Error resolving header variable", "error", err, "variable", e, "value", v)
 			continue
 		}
 	}
@@ -346,7 +350,7 @@ type Agent struct {
 }
 
 type Tools struct {
-	Ls ToolLs `json:"ls,omitzero"`
+	Ls ToolLs `json:"ls,omitempty"`
 }
 
 type ToolLs struct {
@@ -379,7 +383,7 @@ type Config struct {
 
 	Permissions *Permissions `json:"permissions,omitempty" jsonschema:"description=Permission settings for tool usage"`
 
-	Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"`
+	Tools Tools `json:"tools,omitempty" jsonschema:"description=Tool configurations"`
 
 	Agents map[string]Agent `json:"-"`
 
@@ -838,7 +842,7 @@ func resolveEnvs(envs map[string]string) []string {
 		var err error
 		envs[e], err = resolver.ResolveValue(v)
 		if err != nil {
-			slog.Error("error resolving environment variable", "error", err, "variable", e, "value", v)
+			slog.Error("Error resolving environment variable", "error", err, "variable", e, "value", v)
 			continue
 		}
 	}

internal/config/copilot.go ๐Ÿ”—

@@ -6,7 +6,7 @@ import (
 	"log/slog"
 	"testing"
 
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/oauth"
 	"github.com/charmbracelet/crush/internal/oauth/copilot"
 )

internal/config/hyper.go ๐Ÿ”—

@@ -11,7 +11,7 @@ import (
 	"sync/atomic"
 	"time"
 
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/agent/hyper"
 	xetag "github.com/charmbracelet/x/etag"
 )

internal/config/hyper_test.go ๐Ÿ”—

@@ -7,7 +7,7 @@ import (
 	"os"
 	"testing"
 
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/stretchr/testify/require"
 )
 

internal/config/load.go ๐Ÿ”—

@@ -16,7 +16,7 @@ import (
 	"strings"
 	"testing"
 
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/agent/hyper"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/env"
@@ -62,6 +62,11 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) {
 		assignIfNil(&cfg.Options.TUI.Completions.MaxItems, items)
 	}
 
+	if isAppleTerminal() {
+		slog.Warn("Detected Apple Terminal, enabling transparent mode")
+		assignIfNil(&cfg.Options.TUI.Transparent, true)
+	}
+
 	// Load known providers, this loads the config from catwalk
 	providers, err := Providers(cfg)
 	if err != nil {
@@ -792,3 +797,5 @@ func GlobalSkillsDirs() []string {
 		filepath.Join(configBase, "agents", "skills"),
 	}
 }
+
+func isAppleTerminal() bool { return os.Getenv("TERM_PROGRAM") == "Apple_Terminal" }

internal/config/load_test.go ๐Ÿ”—

@@ -7,7 +7,7 @@ import (
 	"path/filepath"
 	"testing"
 
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/env"
 	"github.com/stretchr/testify/assert"

internal/config/provider.go ๐Ÿ”—

@@ -15,8 +15,8 @@ import (
 	"sync"
 	"time"
 
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/catwalk/pkg/embedded"
+	"charm.land/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/embedded"
 	"github.com/charmbracelet/crush/internal/agent/hyper"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/home"

internal/config/provider_test.go ๐Ÿ”—

@@ -7,7 +7,7 @@ import (
 	"sync"
 	"testing"
 
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/stretchr/testify/require"
 )
 

internal/db/db.go ๐Ÿ”—

@@ -57,6 +57,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
 	if q.getFileByPathAndSessionStmt, err = db.PrepareContext(ctx, getFileByPathAndSession); err != nil {
 		return nil, fmt.Errorf("error preparing query GetFileByPathAndSession: %w", err)
 	}
+	if q.getFileReadStmt, err = db.PrepareContext(ctx, getFileRead); err != nil {
+		return nil, fmt.Errorf("error preparing query GetFileRead: %w", err)
+	}
 	if q.getHourDayHeatmapStmt, err = db.PrepareContext(ctx, getHourDayHeatmap); err != nil {
 		return nil, fmt.Errorf("error preparing query GetHourDayHeatmap: %w", err)
 	}
@@ -87,6 +90,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
 	if q.getUsageByModelStmt, err = db.PrepareContext(ctx, getUsageByModel); err != nil {
 		return nil, fmt.Errorf("error preparing query GetUsageByModel: %w", err)
 	}
+	if q.listAllUserMessagesStmt, err = db.PrepareContext(ctx, listAllUserMessages); err != nil {
+		return nil, fmt.Errorf("error preparing query ListAllUserMessages: %w", err)
+	}
 	if q.listFilesByPathStmt, err = db.PrepareContext(ctx, listFilesByPath); err != nil {
 		return nil, fmt.Errorf("error preparing query ListFilesByPath: %w", err)
 	}
@@ -105,6 +111,12 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
 	if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil {
 		return nil, fmt.Errorf("error preparing query ListSessions: %w", err)
 	}
+	if q.listUserMessagesBySessionStmt, err = db.PrepareContext(ctx, listUserMessagesBySession); err != nil {
+		return nil, fmt.Errorf("error preparing query ListUserMessagesBySession: %w", err)
+	}
+	if q.recordFileReadStmt, err = db.PrepareContext(ctx, recordFileRead); err != nil {
+		return nil, fmt.Errorf("error preparing query RecordFileRead: %w", err)
+	}
 	if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil {
 		return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err)
 	}
@@ -174,6 +186,11 @@ func (q *Queries) Close() error {
 			err = fmt.Errorf("error closing getFileByPathAndSessionStmt: %w", cerr)
 		}
 	}
+	if q.getFileReadStmt != nil {
+		if cerr := q.getFileReadStmt.Close(); cerr != nil {
+			err = fmt.Errorf("error closing getFileReadStmt: %w", cerr)
+		}
+	}
 	if q.getHourDayHeatmapStmt != nil {
 		if cerr := q.getHourDayHeatmapStmt.Close(); cerr != nil {
 			err = fmt.Errorf("error closing getHourDayHeatmapStmt: %w", cerr)
@@ -224,6 +241,11 @@ func (q *Queries) Close() error {
 			err = fmt.Errorf("error closing getUsageByModelStmt: %w", cerr)
 		}
 	}
+	if q.listAllUserMessagesStmt != nil {
+		if cerr := q.listAllUserMessagesStmt.Close(); cerr != nil {
+			err = fmt.Errorf("error closing listAllUserMessagesStmt: %w", cerr)
+		}
+	}
 	if q.listFilesByPathStmt != nil {
 		if cerr := q.listFilesByPathStmt.Close(); cerr != nil {
 			err = fmt.Errorf("error closing listFilesByPathStmt: %w", cerr)
@@ -254,6 +276,16 @@ func (q *Queries) Close() error {
 			err = fmt.Errorf("error closing listSessionsStmt: %w", cerr)
 		}
 	}
+	if q.listUserMessagesBySessionStmt != nil {
+		if cerr := q.listUserMessagesBySessionStmt.Close(); cerr != nil {
+			err = fmt.Errorf("error closing listUserMessagesBySessionStmt: %w", cerr)
+		}
+	}
+	if q.recordFileReadStmt != nil {
+		if cerr := q.recordFileReadStmt.Close(); cerr != nil {
+			err = fmt.Errorf("error closing recordFileReadStmt: %w", cerr)
+		}
+	}
 	if q.updateMessageStmt != nil {
 		if cerr := q.updateMessageStmt.Close(); cerr != nil {
 			err = fmt.Errorf("error closing updateMessageStmt: %w", cerr)
@@ -319,6 +351,7 @@ type Queries struct {
 	getAverageResponseTimeStmt     *sql.Stmt
 	getFileStmt                    *sql.Stmt
 	getFileByPathAndSessionStmt    *sql.Stmt
+	getFileReadStmt                *sql.Stmt
 	getHourDayHeatmapStmt          *sql.Stmt
 	getMessageStmt                 *sql.Stmt
 	getRecentActivityStmt          *sql.Stmt
@@ -329,12 +362,15 @@ type Queries struct {
 	getUsageByDayOfWeekStmt        *sql.Stmt
 	getUsageByHourStmt             *sql.Stmt
 	getUsageByModelStmt            *sql.Stmt
+	listAllUserMessagesStmt        *sql.Stmt
 	listFilesByPathStmt            *sql.Stmt
 	listFilesBySessionStmt         *sql.Stmt
 	listLatestSessionFilesStmt     *sql.Stmt
 	listMessagesBySessionStmt      *sql.Stmt
 	listNewFilesStmt               *sql.Stmt
 	listSessionsStmt               *sql.Stmt
+	listUserMessagesBySessionStmt  *sql.Stmt
+	recordFileReadStmt             *sql.Stmt
 	updateMessageStmt              *sql.Stmt
 	updateSessionStmt              *sql.Stmt
 	updateSessionTitleAndUsageStmt *sql.Stmt
@@ -355,6 +391,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
 		getAverageResponseTimeStmt:     q.getAverageResponseTimeStmt,
 		getFileStmt:                    q.getFileStmt,
 		getFileByPathAndSessionStmt:    q.getFileByPathAndSessionStmt,
+		getFileReadStmt:                q.getFileReadStmt,
 		getHourDayHeatmapStmt:          q.getHourDayHeatmapStmt,
 		getMessageStmt:                 q.getMessageStmt,
 		getRecentActivityStmt:          q.getRecentActivityStmt,
@@ -365,12 +402,15 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
 		getUsageByDayOfWeekStmt:        q.getUsageByDayOfWeekStmt,
 		getUsageByHourStmt:             q.getUsageByHourStmt,
 		getUsageByModelStmt:            q.getUsageByModelStmt,
+		listAllUserMessagesStmt:        q.listAllUserMessagesStmt,
 		listFilesByPathStmt:            q.listFilesByPathStmt,
 		listFilesBySessionStmt:         q.listFilesBySessionStmt,
 		listLatestSessionFilesStmt:     q.listLatestSessionFilesStmt,
 		listMessagesBySessionStmt:      q.listMessagesBySessionStmt,
 		listNewFilesStmt:               q.listNewFilesStmt,
 		listSessionsStmt:               q.listSessionsStmt,
+		listUserMessagesBySessionStmt:  q.listUserMessagesBySessionStmt,
+		recordFileReadStmt:             q.recordFileReadStmt,
 		updateMessageStmt:              q.updateMessageStmt,
 		updateSessionStmt:              q.updateSessionStmt,
 		updateSessionTitleAndUsageStmt: q.updateSessionTitleAndUsageStmt,

internal/db/messages.sql.go ๐Ÿ”—

@@ -107,6 +107,47 @@ func (q *Queries) GetMessage(ctx context.Context, id string) (Message, error) {
 	return i, err
 }
 
+const listAllUserMessages = `-- name: ListAllUserMessages :many
+SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message
+FROM messages
+WHERE role = 'user'
+ORDER BY created_at DESC
+`
+
+func (q *Queries) ListAllUserMessages(ctx context.Context) ([]Message, error) {
+	rows, err := q.query(ctx, q.listAllUserMessagesStmt, listAllUserMessages)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	items := []Message{}
+	for rows.Next() {
+		var i Message
+		if err := rows.Scan(
+			&i.ID,
+			&i.SessionID,
+			&i.Role,
+			&i.Parts,
+			&i.Model,
+			&i.CreatedAt,
+			&i.UpdatedAt,
+			&i.FinishedAt,
+			&i.Provider,
+			&i.IsSummaryMessage,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
 const listMessagesBySession = `-- name: ListMessagesBySession :many
 SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message
 FROM messages
@@ -148,6 +189,47 @@ func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) (
 	return items, nil
 }
 
+const listUserMessagesBySession = `-- name: ListUserMessagesBySession :many
+SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message
+FROM messages
+WHERE session_id = ? AND role = 'user'
+ORDER BY created_at DESC
+`
+
+func (q *Queries) ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error) {
+	rows, err := q.query(ctx, q.listUserMessagesBySessionStmt, listUserMessagesBySession, sessionID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	items := []Message{}
+	for rows.Next() {
+		var i Message
+		if err := rows.Scan(
+			&i.ID,
+			&i.SessionID,
+			&i.Role,
+			&i.Parts,
+			&i.Model,
+			&i.CreatedAt,
+			&i.UpdatedAt,
+			&i.FinishedAt,
+			&i.Provider,
+			&i.IsSummaryMessage,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
 const updateMessage = `-- name: UpdateMessage :exec
 UPDATE messages
 SET

internal/db/migrations/20260127000000_add_read_files_table.sql ๐Ÿ”—

@@ -0,0 +1,20 @@
+-- +goose Up
+-- +goose StatementBegin
+CREATE TABLE IF NOT EXISTS read_files (
+    session_id TEXT NOT NULL CHECK (session_id != ''),
+    path TEXT NOT NULL CHECK (path != ''),
+    read_at INTEGER NOT NULL,  -- Unix timestamp in seconds when file was last read
+    FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE,
+    PRIMARY KEY (path, session_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_read_files_session_id ON read_files (session_id);
+CREATE INDEX IF NOT EXISTS idx_read_files_path ON read_files (path);
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+DROP INDEX IF EXISTS idx_read_files_path;
+DROP INDEX IF EXISTS idx_read_files_session_id;
+DROP TABLE IF EXISTS read_files;
+-- +goose StatementEnd

internal/db/models.go ๐Ÿ”—

@@ -31,6 +31,12 @@ type Message struct {
 	IsSummaryMessage int64          `json:"is_summary_message"`
 }
 
+type ReadFile struct {
+	SessionID string `json:"session_id"`
+	Path      string `json:"path"`
+	ReadAt    int64  `json:"read_at"` // Unix timestamp when file was last read
+}
+
 type Session struct {
 	ID               string         `json:"id"`
 	ParentSessionID  sql.NullString `json:"parent_session_id"`

internal/db/querier.go ๐Ÿ”—

@@ -20,6 +20,7 @@ type Querier interface {
 	GetAverageResponseTime(ctx context.Context) (int64, error)
 	GetFile(ctx context.Context, id string) (File, error)
 	GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error)
+	GetFileRead(ctx context.Context, arg GetFileReadParams) (ReadFile, error)
 	GetHourDayHeatmap(ctx context.Context) ([]GetHourDayHeatmapRow, error)
 	GetMessage(ctx context.Context, id string) (Message, error)
 	GetRecentActivity(ctx context.Context) ([]GetRecentActivityRow, error)
@@ -30,12 +31,15 @@ type Querier interface {
 	GetUsageByDayOfWeek(ctx context.Context) ([]GetUsageByDayOfWeekRow, error)
 	GetUsageByHour(ctx context.Context) ([]GetUsageByHourRow, error)
 	GetUsageByModel(ctx context.Context) ([]GetUsageByModelRow, error)
+	ListAllUserMessages(ctx context.Context) ([]Message, error)
 	ListFilesByPath(ctx context.Context, path string) ([]File, error)
 	ListFilesBySession(ctx context.Context, sessionID string) ([]File, error)
 	ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
 	ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
 	ListNewFiles(ctx context.Context) ([]File, error)
 	ListSessions(ctx context.Context) ([]Session, error)
+	ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
+	RecordFileRead(ctx context.Context, arg RecordFileReadParams) error
 	UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
 	UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
 	UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error

internal/db/read_files.sql.go ๐Ÿ”—

@@ -0,0 +1,57 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+//   sqlc v1.30.0
+// source: read_files.sql
+
+package db
+
+import (
+	"context"
+)
+
+const getFileRead = `-- name: GetFileRead :one
+SELECT session_id, path, read_at FROM read_files
+WHERE session_id = ? AND path = ? LIMIT 1
+`
+
+type GetFileReadParams struct {
+	SessionID string `json:"session_id"`
+	Path      string `json:"path"`
+}
+
+func (q *Queries) GetFileRead(ctx context.Context, arg GetFileReadParams) (ReadFile, error) {
+	row := q.queryRow(ctx, q.getFileReadStmt, getFileRead, arg.SessionID, arg.Path)
+	var i ReadFile
+	err := row.Scan(
+		&i.SessionID,
+		&i.Path,
+		&i.ReadAt,
+	)
+	return i, err
+}
+
+const recordFileRead = `-- name: RecordFileRead :exec
+INSERT INTO read_files (
+    session_id,
+    path,
+    read_at
+) VALUES (
+    ?,
+    ?,
+    strftime('%s', 'now')
+) ON CONFLICT(path, session_id) DO UPDATE SET
+    read_at = excluded.read_at
+`
+
+type RecordFileReadParams struct {
+	SessionID string `json:"session_id"`
+	Path      string `json:"path"`
+}
+
+func (q *Queries) RecordFileRead(ctx context.Context, arg RecordFileReadParams) error {
+	_, err := q.exec(ctx, q.recordFileReadStmt, recordFileRead,
+		arg.SessionID,
+		arg.Path,
+	)
+	return err
+}

internal/db/sql/messages.sql ๐Ÿ”—

@@ -41,3 +41,15 @@ WHERE id = ?;
 -- name: DeleteSessionMessages :exec
 DELETE FROM messages
 WHERE session_id = ?;
+
+-- name: ListUserMessagesBySession :many
+SELECT *
+FROM messages
+WHERE session_id = ? AND role = 'user'
+ORDER BY created_at DESC;
+
+-- name: ListAllUserMessages :many
+SELECT *
+FROM messages
+WHERE role = 'user'
+ORDER BY created_at DESC;

internal/db/sql/read_files.sql ๐Ÿ”—

@@ -0,0 +1,15 @@
+-- name: RecordFileRead :exec
+INSERT INTO read_files (
+    session_id,
+    path,
+    read_at
+) VALUES (
+    ?,
+    ?,
+    strftime('%s', 'now')
+) ON CONFLICT(path, session_id) DO UPDATE SET
+    read_at = excluded.read_at;
+
+-- name: GetFileRead :one
+SELECT * FROM read_files
+WHERE session_id = ? AND path = ? LIMIT 1;

internal/event/event.go ๐Ÿ”—

@@ -82,18 +82,18 @@ func send(event string, props ...any) {
 }
 
 // Error logs an error event to PostHog with the error type and message.
-func Error(err any, props ...any) {
+func Error(errToLog any, props ...any) {
 	if client == nil {
 		return
 	}
 	posthogErr := client.Enqueue(posthog.NewDefaultException(
 		time.Now(),
 		distinctId,
-		reflect.TypeOf(err).String(),
-		fmt.Sprintf("%v", err),
+		reflect.TypeOf(errToLog).String(),
+		fmt.Sprintf("%v", errToLog),
 	))
-	if err != nil {
-		slog.Error("Failed to enqueue PostHog error", "err", err, "props", props, "posthogErr", posthogErr)
+	if posthogErr != nil {
+		slog.Error("Failed to enqueue PostHog error", "err", errToLog, "props", props, "posthogErr", posthogErr)
 		return
 	}
 }

internal/event/event_test.go ๐Ÿ”—

@@ -0,0 +1,74 @@
+package event
+
+// These tests verify that the Error function correctly handles various
+// scenarios. These tests will not log anything.
+
+import (
+	"testing"
+)
+
+func TestError(t *testing.T) {
+	t.Run("returns early when client is nil", func(t *testing.T) {
+		// This test verifies that when the PostHog client is not initialized
+		// the Error function safely returns early without attempting to
+		// enqueue any events. This is important during initialization or when
+		// metrics are disabled, as we don't want the error reporting mechanism
+		// itself to cause panics.
+		originalClient := client
+		defer func() {
+			client = originalClient
+		}()
+
+		client = nil
+		Error("test error", "key", "value")
+	})
+
+	t.Run("handles nil client without panicking", func(t *testing.T) {
+		// This test covers various edge cases where the error value might be
+		// nil, a string, or an error type.
+		originalClient := client
+		defer func() {
+			client = originalClient
+		}()
+
+		client = nil
+		Error(nil)
+		Error("some error")
+		Error(newDefaultTestError("runtime error"), "key", "value")
+	})
+
+	t.Run("handles error with properties", func(t *testing.T) {
+		// This test verifies that the Error function can handle additional
+		// key-value properties that provide context about the error. These
+		// properties are typically passed when recovering from panics (i.e.,
+		// panic name, function name).
+		//
+		// Even with these additional properties, the function should handle
+		// them gracefully without panicking.
+		originalClient := client
+		defer func() {
+			client = originalClient
+		}()
+
+		client = nil
+		Error("test error",
+			"type", "test",
+			"severity", "high",
+			"source", "unit-test",
+		)
+	})
+}
+
+// newDefaultTestError creates a test error that mimics runtime panic
+// errors. This helps us testing that the Error function can handle various
+// error types, including those that might be passed from a panic recovery
+// scenario.
+func newDefaultTestError(s string) error {
+	return testError(s)
+}
+
+type testError string
+
+func (e testError) Error() string {
+	return string(e)
+}

internal/filetracker/filetracker.go ๐Ÿ”—

@@ -1,70 +0,0 @@
-// Package filetracker tracks file read/write times to prevent editing files
-// that haven't been read, and to detect external modifications.
-//
-// TODO: Consider moving this to persistent storage (e.g., the database) to
-// preserve file access history across sessions.
-// We would need to make sure to handle the case where we reload a session and the underlying files did change.
-package filetracker
-
-import (
-	"sync"
-	"time"
-)
-
-// record tracks when a file was read/written.
-type record struct {
-	path      string
-	readTime  time.Time
-	writeTime time.Time
-}
-
-var (
-	records     = make(map[string]record)
-	recordMutex sync.RWMutex
-)
-
-// RecordRead records when a file was read.
-func RecordRead(path string) {
-	recordMutex.Lock()
-	defer recordMutex.Unlock()
-
-	rec, exists := records[path]
-	if !exists {
-		rec = record{path: path}
-	}
-	rec.readTime = time.Now()
-	records[path] = rec
-}
-
-// LastReadTime returns when a file was last read. Returns zero time if never
-// read.
-func LastReadTime(path string) time.Time {
-	recordMutex.RLock()
-	defer recordMutex.RUnlock()
-
-	rec, exists := records[path]
-	if !exists {
-		return time.Time{}
-	}
-	return rec.readTime
-}
-
-// RecordWrite records when a file was written.
-func RecordWrite(path string) {
-	recordMutex.Lock()
-	defer recordMutex.Unlock()
-
-	rec, exists := records[path]
-	if !exists {
-		rec = record{path: path}
-	}
-	rec.writeTime = time.Now()
-	records[path] = rec
-}
-
-// Reset clears all file tracking records. Useful for testing.
-func Reset() {
-	recordMutex.Lock()
-	defer recordMutex.Unlock()
-	records = make(map[string]record)
-}

internal/filetracker/service.go ๐Ÿ”—

@@ -0,0 +1,70 @@
+// Package filetracker provides functionality to track file reads in sessions.
+package filetracker
+
+import (
+	"context"
+	"log/slog"
+	"os"
+	"path/filepath"
+	"time"
+
+	"github.com/charmbracelet/crush/internal/db"
+)
+
+// Service defines the interface for tracking file reads in sessions.
+type Service interface {
+	// RecordRead records when a file was read.
+	RecordRead(ctx context.Context, sessionID, path string)
+
+	// LastReadTime returns when a file was last read.
+	// Returns zero time if never read.
+	LastReadTime(ctx context.Context, sessionID, path string) time.Time
+}
+
+type service struct {
+	q *db.Queries
+}
+
+// NewService creates a new file tracker service.
+func NewService(q *db.Queries) Service {
+	return &service{q: q}
+}
+
+// RecordRead records when a file was read.
+func (s *service) RecordRead(ctx context.Context, sessionID, path string) {
+	if err := s.q.RecordFileRead(ctx, db.RecordFileReadParams{
+		SessionID: sessionID,
+		Path:      relpath(path),
+	}); err != nil {
+		slog.Error("Error recording file read", "error", err, "file", path)
+	}
+}
+
+// LastReadTime returns when a file was last read.
+// Returns zero time if never read.
+func (s *service) LastReadTime(ctx context.Context, sessionID, path string) time.Time {
+	readFile, err := s.q.GetFileRead(ctx, db.GetFileReadParams{
+		SessionID: sessionID,
+		Path:      relpath(path),
+	})
+	if err != nil {
+		return time.Time{}
+	}
+
+	return time.Unix(readFile.ReadAt, 0)
+}
+
+func relpath(path string) string {
+	path = filepath.Clean(path)
+	basepath, err := os.Getwd()
+	if err != nil {
+		slog.Warn("Error getting basepath", "error", err)
+		return path
+	}
+	relpath, err := filepath.Rel(basepath, path)
+	if err != nil {
+		slog.Warn("Error getting relpath", "error", err)
+		return path
+	}
+	return relpath
+}

internal/filetracker/service_test.go ๐Ÿ”—

@@ -0,0 +1,116 @@
+package filetracker
+
+import (
+	"context"
+	"testing"
+	"testing/synctest"
+	"time"
+
+	"github.com/charmbracelet/crush/internal/db"
+	"github.com/stretchr/testify/require"
+)
+
+type testEnv struct {
+	ctx context.Context
+	q   *db.Queries
+	svc Service
+}
+
+func setupTest(t *testing.T) *testEnv {
+	t.Helper()
+
+	conn, err := db.Connect(t.Context(), t.TempDir())
+	require.NoError(t, err)
+	t.Cleanup(func() { conn.Close() })
+
+	q := db.New(conn)
+	return &testEnv{
+		ctx: t.Context(),
+		q:   q,
+		svc: NewService(q),
+	}
+}
+
+func (e *testEnv) createSession(t *testing.T, sessionID string) {
+	t.Helper()
+	_, err := e.q.CreateSession(e.ctx, db.CreateSessionParams{
+		ID:    sessionID,
+		Title: "Test Session",
+	})
+	require.NoError(t, err)
+}
+
+func TestService_RecordRead(t *testing.T) {
+	env := setupTest(t)
+
+	sessionID := "test-session-1"
+	path := "/path/to/file.go"
+	env.createSession(t, sessionID)
+
+	env.svc.RecordRead(env.ctx, sessionID, path)
+
+	lastRead := env.svc.LastReadTime(env.ctx, sessionID, path)
+	require.False(t, lastRead.IsZero(), "expected non-zero time after recording read")
+	require.WithinDuration(t, time.Now(), lastRead, 2*time.Second)
+}
+
+func TestService_LastReadTime_NotFound(t *testing.T) {
+	env := setupTest(t)
+
+	lastRead := env.svc.LastReadTime(env.ctx, "nonexistent-session", "/nonexistent/path")
+	require.True(t, lastRead.IsZero(), "expected zero time for unread file")
+}
+
+func TestService_RecordRead_UpdatesTimestamp(t *testing.T) {
+	env := setupTest(t)
+
+	sessionID := "test-session-2"
+	path := "/path/to/file.go"
+	env.createSession(t, sessionID)
+
+	env.svc.RecordRead(env.ctx, sessionID, path)
+	firstRead := env.svc.LastReadTime(env.ctx, sessionID, path)
+	require.False(t, firstRead.IsZero())
+
+	synctest.Test(t, func(t *testing.T) {
+		time.Sleep(100 * time.Millisecond)
+		synctest.Wait()
+		env.svc.RecordRead(env.ctx, sessionID, path)
+		secondRead := env.svc.LastReadTime(env.ctx, sessionID, path)
+
+		require.False(t, secondRead.Before(firstRead), "second read time should not be before first")
+	})
+}
+
+func TestService_RecordRead_DifferentSessions(t *testing.T) {
+	env := setupTest(t)
+
+	path := "/shared/file.go"
+	session1, session2 := "session-1", "session-2"
+	env.createSession(t, session1)
+	env.createSession(t, session2)
+
+	env.svc.RecordRead(env.ctx, session1, path)
+
+	lastRead1 := env.svc.LastReadTime(env.ctx, session1, path)
+	require.False(t, lastRead1.IsZero())
+
+	lastRead2 := env.svc.LastReadTime(env.ctx, session2, path)
+	require.True(t, lastRead2.IsZero(), "session 2 should not see session 1's read")
+}
+
+func TestService_RecordRead_DifferentPaths(t *testing.T) {
+	env := setupTest(t)
+
+	sessionID := "test-session-3"
+	path1, path2 := "/path/to/file1.go", "/path/to/file2.go"
+	env.createSession(t, sessionID)
+
+	env.svc.RecordRead(env.ctx, sessionID, path1)
+
+	lastRead1 := env.svc.LastReadTime(env.ctx, sessionID, path1)
+	require.False(t, lastRead1.IsZero())
+
+	lastRead2 := env.svc.LastReadTime(env.ctx, sessionID, path2)
+	require.True(t, lastRead2.IsZero(), "path2 should not be recorded")
+}

internal/fsext/ls.go ๐Ÿ”—

@@ -144,20 +144,20 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo
 	}
 
 	if commonIgnorePatterns().MatchesPath(relPath) {
-		slog.Debug("ignoring common pattern", "path", relPath)
+		slog.Debug("Ignoring common pattern", "path", relPath)
 		return true
 	}
 
 	parentDir := filepath.Dir(path)
 	ignoreParser := dl.getIgnore(parentDir)
 	if ignoreParser.MatchesPath(relPath) {
-		slog.Debug("ignoring dir pattern", "path", relPath, "dir", parentDir)
+		slog.Debug("Ignoring dir pattern", "path", relPath, "dir", parentDir)
 		return true
 	}
 
 	// For directories, also check with trailing slash (gitignore convention)
 	if ignoreParser.MatchesPath(relPath + "/") {
-		slog.Debug("ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir)
+		slog.Debug("Ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir)
 		return true
 	}
 
@@ -166,7 +166,7 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo
 	}
 
 	if homeIgnore().MatchesPath(relPath) {
-		slog.Debug("ignoring home dir pattern", "path", relPath)
+		slog.Debug("Ignoring home dir pattern", "path", relPath)
 		return true
 	}
 
@@ -177,7 +177,7 @@ func (dl *directoryLister) checkParentIgnores(path string) bool {
 	parent := filepath.Dir(filepath.Dir(path))
 	for parent != "." && path != "." {
 		if dl.getIgnore(parent).MatchesPath(path) {
-			slog.Debug("ingoring parent dir pattern", "path", path, "dir", parent)
+			slog.Debug("Ignoring parent dir pattern", "path", path, "dir", parent)
 			return true
 		}
 		if parent == dl.rootPath {
@@ -210,7 +210,7 @@ func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int
 	found := csync.NewSlice[string]()
 	dl := NewDirectoryLister(initialPath)
 
-	slog.Debug("listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns)
+	slog.Debug("Listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns)
 
 	conf := fastwalk.Config{
 		Follow:   true,

internal/fsext/paste.go ๐Ÿ”—

@@ -0,0 +1,129 @@
+package fsext
+
+import (
+	"os"
+	"strings"
+)
+
+func ParsePastedFiles(s string) []string {
+	s = strings.TrimSpace(s)
+
+	// NOTE: Rio on Windows adds NULL chars for some reason.
+	s = strings.ReplaceAll(s, "\x00", "")
+
+	switch {
+	case attemptStat(s):
+		return strings.Split(s, "\n")
+	case os.Getenv("WT_SESSION") != "":
+		return windowsTerminalParsePastedFiles(s)
+	default:
+		return unixParsePastedFiles(s)
+	}
+}
+
+func attemptStat(s string) bool {
+	for path := range strings.SplitSeq(s, "\n") {
+		if info, err := os.Stat(path); err != nil || info.IsDir() {
+			return false
+		}
+	}
+	return true
+}
+
+func windowsTerminalParsePastedFiles(s string) []string {
+	if strings.TrimSpace(s) == "" {
+		return nil
+	}
+
+	var (
+		paths    []string
+		current  strings.Builder
+		inQuotes = false
+	)
+	for i := range len(s) {
+		ch := s[i]
+
+		switch {
+		case ch == '"':
+			if inQuotes {
+				// End of quoted section
+				if current.Len() > 0 {
+					paths = append(paths, current.String())
+					current.Reset()
+				}
+				inQuotes = false
+			} else {
+				// Start of quoted section
+				inQuotes = true
+			}
+		case inQuotes:
+			current.WriteByte(ch)
+		case ch != ' ':
+			// Text outside quotes is not allowed
+			return nil
+		}
+	}
+
+	// Add any remaining content if quotes were properly closed
+	if current.Len() > 0 && !inQuotes {
+		paths = append(paths, current.String())
+	}
+
+	// If quotes were not closed, return empty (malformed input)
+	if inQuotes {
+		return nil
+	}
+
+	return paths
+}
+
+func unixParsePastedFiles(s string) []string {
+	if strings.TrimSpace(s) == "" {
+		return nil
+	}
+
+	var (
+		paths   []string
+		current strings.Builder
+		escaped = false
+	)
+	for i := range len(s) {
+		ch := s[i]
+
+		switch {
+		case escaped:
+			// After a backslash, add the character as-is (including space)
+			current.WriteByte(ch)
+			escaped = false
+		case ch == '\\':
+			// Check if this backslash is at the end of the string
+			if i == len(s)-1 {
+				// Trailing backslash, treat as literal
+				current.WriteByte(ch)
+			} else {
+				// Start of escape sequence
+				escaped = true
+			}
+		case ch == ' ':
+			// Space separates paths (unless escaped)
+			if current.Len() > 0 {
+				paths = append(paths, current.String())
+				current.Reset()
+			}
+		default:
+			current.WriteByte(ch)
+		}
+	}
+
+	// Handle trailing backslash if present
+	if escaped {
+		current.WriteByte('\\')
+	}
+
+	// Add the last path if any
+	if current.Len() > 0 {
+		paths = append(paths, current.String())
+	}
+
+	return paths
+}

internal/fsext/paste_test.go ๐Ÿ”—

@@ -0,0 +1,149 @@
+package fsext
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestParsePastedFiles(t *testing.T) {
+	t.Run("WindowsTerminal", func(t *testing.T) {
+		tests := []struct {
+			name     string
+			input    string
+			expected []string
+		}{
+			{
+				name:     "single path",
+				input:    `"C:\path\my-screenshot-one.png"`,
+				expected: []string{`C:\path\my-screenshot-one.png`},
+			},
+			{
+				name:     "multiple paths no spaces",
+				input:    `"C:\path\my-screenshot-one.png" "C:\path\my-screenshot-two.png" "C:\path\my-screenshot-three.png"`,
+				expected: []string{`C:\path\my-screenshot-one.png`, `C:\path\my-screenshot-two.png`, `C:\path\my-screenshot-three.png`},
+			},
+			{
+				name:     "single with spaces",
+				input:    `"C:\path\my screenshot one.png"`,
+				expected: []string{`C:\path\my screenshot one.png`},
+			},
+			{
+				name:     "multiple paths with spaces",
+				input:    `"C:\path\my screenshot one.png" "C:\path\my screenshot two.png" "C:\path\my screenshot three.png"`,
+				expected: []string{`C:\path\my screenshot one.png`, `C:\path\my screenshot two.png`, `C:\path\my screenshot three.png`},
+			},
+			{
+				name:     "empty string",
+				input:    "",
+				expected: nil,
+			},
+			{
+				name:     "unclosed quotes",
+				input:    `"C:\path\file.png`,
+				expected: nil,
+			},
+			{
+				name:     "text outside quotes",
+				input:    `"C:\path\file.png" some random text "C:\path\file2.png"`,
+				expected: nil,
+			},
+			{
+				name:     "multiple spaces between paths",
+				input:    `"C:\path\file1.png"    "C:\path\file2.png"`,
+				expected: []string{`C:\path\file1.png`, `C:\path\file2.png`},
+			},
+			{
+				name:     "just whitespace",
+				input:    "   ",
+				expected: nil,
+			},
+			{
+				name:     "consecutive quoted sections",
+				input:    `"C:\path1""C:\path2"`,
+				expected: []string{`C:\path1`, `C:\path2`},
+			},
+		}
+		for _, tt := range tests {
+			t.Run(tt.name, func(t *testing.T) {
+				result := windowsTerminalParsePastedFiles(tt.input)
+				require.Equal(t, tt.expected, result)
+			})
+		}
+	})
+
+	t.Run("Unix", func(t *testing.T) {
+		tests := []struct {
+			name     string
+			input    string
+			expected []string
+		}{
+			{
+				name:     "single path",
+				input:    `/path/my-screenshot.png`,
+				expected: []string{"/path/my-screenshot.png"},
+			},
+			{
+				name:     "multiple paths no spaces",
+				input:    `/path/screenshot-one.png /path/screenshot-two.png /path/screenshot-three.png`,
+				expected: []string{"/path/screenshot-one.png", "/path/screenshot-two.png", "/path/screenshot-three.png"},
+			},
+			{
+				name:     "sigle with spaces",
+				input:    `/path/my\ screenshot\ one.png`,
+				expected: []string{"/path/my screenshot one.png"},
+			},
+			{
+				name:     "multiple paths with spaces",
+				input:    `/path/my\ screenshot\ one.png /path/my\ screenshot\ two.png /path/my\ screenshot\ three.png`,
+				expected: []string{"/path/my screenshot one.png", "/path/my screenshot two.png", "/path/my screenshot three.png"},
+			},
+			{
+				name:     "empty string",
+				input:    "",
+				expected: nil,
+			},
+			{
+				name:     "double backslash escapes",
+				input:    `/path/my\\file.png`,
+				expected: []string{"/path/my\\file.png"},
+			},
+			{
+				name:     "trailing backslash",
+				input:    `/path/file\`,
+				expected: []string{`/path/file\`},
+			},
+			{
+				name:     "multiple consecutive escaped spaces",
+				input:    `/path/file\ \ with\ \ many\ \ spaces.png`,
+				expected: []string{"/path/file  with  many  spaces.png"},
+			},
+			{
+				name:     "multiple unescaped spaces",
+				input:    `/path/file1.png   /path/file2.png`,
+				expected: []string{"/path/file1.png", "/path/file2.png"},
+			},
+			{
+				name:     "just whitespace",
+				input:    "   ",
+				expected: nil,
+			},
+			{
+				name:     "tab characters",
+				input:    "/path/file1.png\t/path/file2.png",
+				expected: []string{"/path/file1.png\t/path/file2.png"},
+			},
+			{
+				name:     "newlines in input",
+				input:    "/path/file1.png\n/path/file2.png",
+				expected: []string{"/path/file1.png\n/path/file2.png"},
+			},
+		}
+		for _, tt := range tests {
+			t.Run(tt.name, func(t *testing.T) {
+				result := unixParsePastedFiles(tt.input)
+				require.Equal(t, tt.expected, result)
+			})
+		}
+	})
+}

internal/home/home.go ๐Ÿ”—

@@ -12,7 +12,7 @@ var homedir, homedirErr = os.UserHomeDir()
 
 func init() {
 	if homedirErr != nil {
-		slog.Error("failed to get user home directory", "error", homedirErr)
+		slog.Error("Failed to get user home directory", "error", homedirErr)
 	}
 }
 

internal/lsp/client.go ๐Ÿ”—

@@ -13,10 +13,12 @@ import (
 	"sync/atomic"
 	"time"
 
+	"github.com/bmatcuk/doublestar/v4"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/home"
+	powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
 	powernap "github.com/charmbracelet/x/powernap/pkg/lsp"
 	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
 	"github.com/charmbracelet/x/powernap/pkg/transport"
@@ -34,6 +36,9 @@ type Client struct {
 	client *powernap.Client
 	name   string
 
+	// Working directory this LSP is scoped to.
+	workDir string
+
 	// File types this LSP server handles (e.g., .go, .rs, .py)
 	fileTypes []string
 
@@ -133,6 +138,7 @@ func (c *Client) createPowernapClient() error {
 	}
 
 	rootURI := string(protocol.URIFromPath(workDir))
+	c.workDir = workDir
 
 	command, err := c.resolver.ResolveValue(c.config.Command)
 	if err != nil {
@@ -305,25 +311,39 @@ type OpenFileInfo struct {
 	URI     protocol.DocumentURI
 }
 
-// HandlesFile checks if this LSP client handles the given file based on its extension.
+// HandlesFile checks if this LSP client handles the given file based on its
+// extension and whether it's within the working directory.
 func (c *Client) HandlesFile(path string) bool {
-	// If no file types are specified, handle all files (backward compatibility)
+	// Check if file is within working directory.
+	absPath, err := filepath.Abs(path)
+	if err != nil {
+		slog.Debug("Cannot resolve path", "name", c.name, "file", path, "error", err)
+		return false
+	}
+	relPath, err := filepath.Rel(c.workDir, absPath)
+	if err != nil || strings.HasPrefix(relPath, "..") {
+		slog.Debug("File outside workspace", "name", c.name, "file", path, "workDir", c.workDir)
+		return false
+	}
+
+	// If no file types are specified, handle all files (backward compatibility).
 	if len(c.fileTypes) == 0 {
 		return true
 	}
 
+	kind := powernap.DetectLanguage(path)
 	name := strings.ToLower(filepath.Base(path))
 	for _, filetype := range c.fileTypes {
 		suffix := strings.ToLower(filetype)
 		if !strings.HasPrefix(suffix, ".") {
 			suffix = "." + suffix
 		}
-		if strings.HasSuffix(name, suffix) {
-			slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetype)
+		if strings.HasSuffix(name, suffix) || filetype == string(kind) {
+			slog.Debug("Handles file", "name", c.name, "file", name, "filetype", filetype, "kind", kind)
 			return true
 		}
 	}
-	slog.Debug("doesn't handle file", "name", c.name, "file", name)
+	slog.Debug("Doesn't handle file", "name", c.name, "file", name)
 	return false
 }
 
@@ -346,7 +366,7 @@ func (c *Client) OpenFile(ctx context.Context, filepath string) error {
 	}
 
 	// Notify the server about the opened document
-	if err = c.client.NotifyDidOpenTextDocument(ctx, uri, string(DetectLanguageID(uri)), 1, string(content)); err != nil {
+	if err = c.client.NotifyDidOpenTextDocument(ctx, uri, string(powernap.DetectLanguage(filepath)), 1, string(content)); err != nil {
 		return err
 	}
 
@@ -557,18 +577,71 @@ func (c *Client) FindReferences(ctx context.Context, filepath string, line, char
 	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 {
-	if len(rootMarkers) == 0 {
-		return true
+// FilterMatching gets a list of configs and only returns the ones with
+// matching root markers.
+func FilterMatching(dir string, servers map[string]*powernapconfig.ServerConfig) map[string]*powernapconfig.ServerConfig {
+	result := map[string]*powernapconfig.ServerConfig{}
+	if len(servers) == 0 {
+		return result
 	}
-	for _, pattern := range rootMarkers {
-		// Use fsext.GlobWithDoubleStar to find matches
-		matches, _, err := fsext.GlobWithDoubleStar(pattern, dir, 1)
-		if err == nil && len(matches) > 0 {
-			return true
+
+	type serverPatterns struct {
+		server   *powernapconfig.ServerConfig
+		patterns []string
+	}
+	normalized := make(map[string]serverPatterns, len(servers))
+	for name, server := range servers {
+		var patterns []string
+		for _, p := range server.RootMarkers {
+			if p == ".git" {
+				// ignore .git for discovery
+				continue
+			}
+			patterns = append(patterns, filepath.ToSlash(p))
+		}
+		if len(patterns) == 0 {
+			slog.Debug("ignoring lsp with no root markers", "name", name)
+			continue
 		}
+		normalized[name] = serverPatterns{server: server, patterns: patterns}
 	}
-	return false
+
+	walker := fsext.NewFastGlobWalker(dir)
+	_ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
+		if err != nil {
+			return nil
+		}
+
+		if walker.ShouldSkip(path) {
+			if d.IsDir() {
+				return filepath.SkipDir
+			}
+			return nil
+		}
+
+		relPath, err := filepath.Rel(dir, path)
+		if err != nil {
+			return nil
+		}
+		relPath = filepath.ToSlash(relPath)
+
+		for name, sp := range normalized {
+			for _, pattern := range sp.patterns {
+				matched, err := doublestar.Match(pattern, relPath)
+				if err != nil || !matched {
+					continue
+				}
+				result[name] = sp.server
+				delete(normalized, name)
+				break
+			}
+		}
+
+		if len(normalized) == 0 {
+			return filepath.SkipAll
+		}
+		return nil
+	})
+
+	return result
 }

internal/lsp/filtermatching_test.go ๐Ÿ”—

@@ -0,0 +1,111 @@
+package lsp
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
+	"github.com/stretchr/testify/require"
+)
+
+func TestFilterMatching(t *testing.T) {
+	t.Parallel()
+
+	t.Run("matches servers with existing root markers", func(t *testing.T) {
+		t.Parallel()
+		tmpDir := t.TempDir()
+
+		require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644))
+		require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "Cargo.toml"), []byte("[package]"), 0o644))
+
+		servers := map[string]*powernapconfig.ServerConfig{
+			"gopls":          {RootMarkers: []string{"go.mod", "go.work"}},
+			"rust-analyzer":  {RootMarkers: []string{"Cargo.toml"}},
+			"typescript-lsp": {RootMarkers: []string{"package.json", "tsconfig.json"}},
+		}
+
+		result := FilterMatching(tmpDir, servers)
+
+		require.Contains(t, result, "gopls")
+		require.Contains(t, result, "rust-analyzer")
+		require.NotContains(t, result, "typescript-lsp")
+	})
+
+	t.Run("returns empty for empty servers", func(t *testing.T) {
+		t.Parallel()
+		tmpDir := t.TempDir()
+
+		result := FilterMatching(tmpDir, map[string]*powernapconfig.ServerConfig{})
+
+		require.Empty(t, result)
+	})
+
+	t.Run("returns empty when no markers match", func(t *testing.T) {
+		t.Parallel()
+		tmpDir := t.TempDir()
+
+		servers := map[string]*powernapconfig.ServerConfig{
+			"gopls":  {RootMarkers: []string{"go.mod"}},
+			"python": {RootMarkers: []string{"pyproject.toml"}},
+		}
+
+		result := FilterMatching(tmpDir, servers)
+
+		require.Empty(t, result)
+	})
+
+	t.Run("glob patterns work", func(t *testing.T) {
+		t.Parallel()
+		tmpDir := t.TempDir()
+
+		require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "src"), 0o755))
+		require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "src", "main.go"), []byte("package main"), 0o644))
+
+		servers := map[string]*powernapconfig.ServerConfig{
+			"gopls":  {RootMarkers: []string{"**/*.go"}},
+			"python": {RootMarkers: []string{"**/*.py"}},
+		}
+
+		result := FilterMatching(tmpDir, servers)
+
+		require.Contains(t, result, "gopls")
+		require.NotContains(t, result, "python")
+	})
+
+	t.Run("servers with empty root markers are not included", func(t *testing.T) {
+		t.Parallel()
+		tmpDir := t.TempDir()
+
+		require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644))
+
+		servers := map[string]*powernapconfig.ServerConfig{
+			"gopls":   {RootMarkers: []string{"go.mod"}},
+			"generic": {RootMarkers: []string{}},
+		}
+
+		result := FilterMatching(tmpDir, servers)
+
+		require.Contains(t, result, "gopls")
+		require.NotContains(t, result, "generic")
+	})
+
+	t.Run("stops early when all servers match", func(t *testing.T) {
+		t.Parallel()
+		tmpDir := t.TempDir()
+
+		require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644))
+		require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "Cargo.toml"), []byte("[package]"), 0o644))
+
+		servers := map[string]*powernapconfig.ServerConfig{
+			"gopls":         {RootMarkers: []string{"go.mod"}},
+			"rust-analyzer": {RootMarkers: []string{"Cargo.toml"}},
+		}
+
+		result := FilterMatching(tmpDir, servers)
+
+		require.Len(t, result, 2)
+		require.Contains(t, result, "gopls")
+		require.Contains(t, result, "rust-analyzer")
+	})
+}

internal/lsp/language.go ๐Ÿ”—

@@ -1,132 +0,0 @@
-package lsp
-
-import (
-	"path/filepath"
-	"strings"
-
-	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
-)
-
-func DetectLanguageID(uri string) protocol.LanguageKind {
-	ext := strings.ToLower(filepath.Ext(uri))
-	switch ext {
-	case ".abap":
-		return protocol.LangABAP
-	case ".bat":
-		return protocol.LangWindowsBat
-	case ".bib", ".bibtex":
-		return protocol.LangBibTeX
-	case ".clj":
-		return protocol.LangClojure
-	case ".coffee":
-		return protocol.LangCoffeescript
-	case ".c":
-		return protocol.LangC
-	case ".cpp", ".cxx", ".cc", ".c++":
-		return protocol.LangCPP
-	case ".cs":
-		return protocol.LangCSharp
-	case ".css":
-		return protocol.LangCSS
-	case ".d":
-		return protocol.LangD
-	case ".pas", ".pascal":
-		return protocol.LangDelphi
-	case ".diff", ".patch":
-		return protocol.LangDiff
-	case ".dart":
-		return protocol.LangDart
-	case ".dockerfile":
-		return protocol.LangDockerfile
-	case ".ex", ".exs":
-		return protocol.LangElixir
-	case ".erl", ".hrl":
-		return protocol.LangErlang
-	case ".fs", ".fsi", ".fsx", ".fsscript":
-		return protocol.LangFSharp
-	case ".gitcommit":
-		return protocol.LangGitCommit
-	case ".gitrebase":
-		return protocol.LangGitRebase
-	case ".go":
-		return protocol.LangGo
-	case ".groovy":
-		return protocol.LangGroovy
-	case ".hbs", ".handlebars":
-		return protocol.LangHandlebars
-	case ".hs":
-		return protocol.LangHaskell
-	case ".html", ".htm":
-		return protocol.LangHTML
-	case ".ini":
-		return protocol.LangIni
-	case ".java":
-		return protocol.LangJava
-	case ".js":
-		return protocol.LangJavaScript
-	case ".jsx":
-		return protocol.LangJavaScriptReact
-	case ".json":
-		return protocol.LangJSON
-	case ".tex", ".latex":
-		return protocol.LangLaTeX
-	case ".less":
-		return protocol.LangLess
-	case ".lua":
-		return protocol.LangLua
-	case ".makefile", "makefile":
-		return protocol.LangMakefile
-	case ".md", ".markdown":
-		return protocol.LangMarkdown
-	case ".m":
-		return protocol.LangObjectiveC
-	case ".mm":
-		return protocol.LangObjectiveCPP
-	case ".pl":
-		return protocol.LangPerl
-	case ".pm":
-		return protocol.LangPerl6
-	case ".php":
-		return protocol.LangPHP
-	case ".ps1", ".psm1":
-		return protocol.LangPowershell
-	case ".pug", ".jade":
-		return protocol.LangPug
-	case ".py":
-		return protocol.LangPython
-	case ".r":
-		return protocol.LangR
-	case ".cshtml", ".razor":
-		return protocol.LangRazor
-	case ".rb":
-		return protocol.LangRuby
-	case ".rs":
-		return protocol.LangRust
-	case ".scss":
-		return protocol.LangSCSS
-	case ".sass":
-		return protocol.LangSASS
-	case ".scala":
-		return protocol.LangScala
-	case ".shader":
-		return protocol.LangShaderLab
-	case ".sh", ".bash", ".zsh", ".ksh":
-		return protocol.LangShellScript
-	case ".sql":
-		return protocol.LangSQL
-	case ".swift":
-		return protocol.LangSwift
-	case ".ts":
-		return protocol.LangTypeScript
-	case ".tsx":
-		return protocol.LangTypeScriptReact
-	case ".xml":
-		return protocol.LangXML
-	case ".xsl":
-		return protocol.LangXSL
-	case ".yaml", ".yml":
-		return protocol.LangYAML
-	default:
-		return protocol.LanguageKind("") // Unknown language
-	}
-}

internal/lsp/rootmarkers_test.go ๐Ÿ”—

@@ -1,37 +0,0 @@
-package lsp
-
-import (
-	"os"
-	"path/filepath"
-	"testing"
-
-	"github.com/stretchr/testify/require"
-)
-
-func TestHasRootMarkers(t *testing.T) {
-	t.Parallel()
-
-	// Create a temporary directory for testing
-	tmpDir := t.TempDir()
-
-	// Test with empty root markers (should return true)
-	require.True(t, HasRootMarkers(tmpDir, []string{}))
-
-	// Test with non-existent markers
-	require.False(t, HasRootMarkers(tmpDir, []string{"go.mod", "package.json"}))
-
-	// Create a go.mod file
-	goModPath := filepath.Join(tmpDir, "go.mod")
-	err := os.WriteFile(goModPath, []byte("module test"), 0o644)
-	require.NoError(t, err)
-
-	// Test with existing marker
-	require.True(t, HasRootMarkers(tmpDir, []string{"go.mod", "package.json"}))
-
-	// Test with only non-existent markers
-	require.False(t, HasRootMarkers(tmpDir, []string{"package.json", "Cargo.toml"}))
-
-	// Test with glob patterns
-	require.True(t, HasRootMarkers(tmpDir, []string{"*.mod"}))
-	require.False(t, HasRootMarkers(tmpDir, []string{"*.json"}))
-}

internal/message/content.go ๐Ÿ”—

@@ -8,11 +8,11 @@ import (
 	"strings"
 	"time"
 
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/fantasy"
 	"charm.land/fantasy/providers/anthropic"
 	"charm.land/fantasy/providers/google"
 	"charm.land/fantasy/providers/openai"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 )
 
 type MessageRole string

internal/message/message.go ๐Ÿ”—

@@ -26,6 +26,8 @@ type Service interface {
 	Update(ctx context.Context, message Message) error
 	Get(ctx context.Context, id string) (Message, error)
 	List(ctx context.Context, sessionID string) ([]Message, error)
+	ListUserMessages(ctx context.Context, sessionID string) ([]Message, error)
+	ListAllUserMessages(ctx context.Context) ([]Message, error)
 	Delete(ctx context.Context, id string) error
 	DeleteSessionMessages(ctx context.Context, sessionID string) error
 }
@@ -157,6 +159,36 @@ func (s *service) List(ctx context.Context, sessionID string) ([]Message, error)
 	return messages, nil
 }
 
+func (s *service) ListUserMessages(ctx context.Context, sessionID string) ([]Message, error) {
+	dbMessages, err := s.q.ListUserMessagesBySession(ctx, sessionID)
+	if err != nil {
+		return nil, err
+	}
+	messages := make([]Message, len(dbMessages))
+	for i, dbMessage := range dbMessages {
+		messages[i], err = s.fromDBItem(dbMessage)
+		if err != nil {
+			return nil, err
+		}
+	}
+	return messages, nil
+}
+
+func (s *service) ListAllUserMessages(ctx context.Context) ([]Message, error) {
+	dbMessages, err := s.q.ListAllUserMessages(ctx)
+	if err != nil {
+		return nil, err
+	}
+	messages := make([]Message, len(dbMessages))
+	for i, dbMessage := range dbMessages {
+		messages[i], err = s.fromDBItem(dbMessage)
+		if err != nil {
+			return nil, err
+		}
+	}
+	return messages, nil
+}
+
 func (s *service) fromDBItem(item db.Message) (Message, error) {
 	parts, err := unmarshalParts([]byte(item.Parts))
 	if err != nil {

internal/session/session.go ๐Ÿ”—

@@ -203,7 +203,7 @@ func (s *service) List(ctx context.Context) ([]Session, error) {
 func (s service) fromDBItem(item db.Session) Session {
 	todos, err := unmarshalTodos(item.Todos.String)
 	if err != nil {
-		slog.Error("failed to unmarshal todos", "session_id", item.ID, "error", err)
+		slog.Error("Failed to unmarshal todos", "session_id", item.ID, "error", err)
 	}
 	return Session{
 		ID:               item.ID,

internal/stringext/string.go ๐Ÿ”—

@@ -1,6 +1,8 @@
 package stringext
 
 import (
+	"strings"
+
 	"golang.org/x/text/cases"
 	"golang.org/x/text/language"
 )
@@ -8,3 +10,13 @@ import (
 func Capitalize(text string) string {
 	return cases.Title(language.English, cases.Compact).String(text)
 }
+
+// NormalizeSpace normalizes whitespace in the given content string.
+// It replaces Windows-style line endings with Unix-style line endings,
+// converts tabs to four spaces, and trims leading and trailing whitespace.
+func NormalizeSpace(content string) string {
+	content = strings.ReplaceAll(content, "\r\n", "\n")
+	content = strings.ReplaceAll(content, "\t", "    ")
+	content = strings.TrimSpace(content)
+	return content
+}

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

@@ -1,6 +1,7 @@
 package editor
 
 import (
+	"context"
 	"fmt"
 	"math/rand"
 	"net/http"
@@ -17,7 +18,6 @@ import (
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/app"
-	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/session"
@@ -66,6 +66,7 @@ type editorCmp struct {
 	x, y               int
 	app                *app.App
 	session            session.Session
+	sessionFileReads   []string
 	textarea           textarea.Model
 	attachments        []message.Attachment
 	deleteMode         bool
@@ -181,6 +182,9 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	var cmd tea.Cmd
 	var cmds []tea.Cmd
 	switch msg := msg.(type) {
+	case chat.SessionClearedMsg:
+		m.session = session.Session{}
+		m.sessionFileReads = nil
 	case tea.WindowSizeMsg:
 		return m, m.repositionCompletions
 	case filepicker.FilePickedMsg:
@@ -212,19 +216,27 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 				m.completionsStartIndex = 0
 			}
 			absPath, _ := filepath.Abs(item.Path)
+
+			ctx := context.Background()
+
 			// Skip attachment if file was already read and hasn't been modified.
-			lastRead := filetracker.LastReadTime(absPath)
-			if !lastRead.IsZero() {
-				if info, err := os.Stat(item.Path); err == nil && !info.ModTime().After(lastRead) {
-					return m, nil
+			if m.session.ID != "" {
+				lastRead := m.app.FileTracker.LastReadTime(ctx, m.session.ID, absPath)
+				if !lastRead.IsZero() {
+					if info, err := os.Stat(item.Path); err == nil && !info.ModTime().After(lastRead) {
+						return m, nil
+					}
 				}
+			} else if slices.Contains(m.sessionFileReads, absPath) {
+				return m, nil
 			}
+
+			m.sessionFileReads = append(m.sessionFileReads, absPath)
 			content, err := os.ReadFile(item.Path)
 			if err != nil {
 				// if it fails, let the LLM handle it later.
 				return m, nil
 			}
-			filetracker.RecordRead(absPath)
 			m.attachments = append(m.attachments, message.Attachment{
 				FilePath: item.Path,
 				FileName: filepath.Base(item.Path),
@@ -662,6 +674,9 @@ func (c *editorCmp) Bindings() []key.Binding {
 // we need to move some functionality to the page level
 func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
 	c.session = session
+	for _, path := range c.sessionFileReads {
+		c.app.FileTracker.RecordRead(context.Background(), session.ID, path)
+	}
 	return nil
 }
 

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

@@ -9,8 +9,8 @@ import (
 	"charm.land/bubbles/v2/key"
 	"charm.land/bubbles/v2/viewport"
 	tea "charm.land/bubbletea/v2"
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/x/ansi"
 	"github.com/charmbracelet/x/exp/ordered"
 	"github.com/google/uuid"

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

@@ -8,7 +8,6 @@ import (
 
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/diff"
@@ -480,8 +479,6 @@ func (m *sidebarCmp) filesBlock() string {
 func (m *sidebarCmp) lspBlock() string {
 	// Limit the number of LSPs shown
 	_, maxLSPs, _ := m.getDynamicLimits()
-	lspConfigs := config.Get().LSP.Sorted()
-	maxLSPs = min(len(lspConfigs), maxLSPs)
 
 	return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
 		MaxWidth:    m.getMaxWidth(),
@@ -550,7 +547,6 @@ func (s *sidebarCmp) currentModelBlock() string {
 	selectedModel := cfg.Models[agentCfg.Model]
 
 	model := config.Get().GetModelByType(agentCfg.Model)
-	modelProvider := config.Get().GetProviderForModel(agentCfg.Model)
 
 	t := styles.CurrentTheme()
 
@@ -562,15 +558,14 @@ func (s *sidebarCmp) currentModelBlock() string {
 	}
 	if model.CanReason {
 		reasoningInfoStyle := t.S().Subtle.PaddingLeft(2)
-		switch modelProvider.Type {
-		case catwalk.TypeAnthropic:
+		if len(model.ReasoningLevels) == 0 {
 			formatter := cases.Title(language.English, cases.NoLower)
 			if selectedModel.Think {
 				parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking on")))
 			} else {
 				parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking off")))
 			}
-		default:
+		} else {
 			reasoningEffort := model.DefaultReasoningEffort
 			if selectedModel.ReasoningEffort != "" {
 				reasoningEffort = selectedModel.ReasoningEffort

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

@@ -8,8 +8,8 @@ import (
 	"charm.land/bubbles/v2/key"
 	"charm.land/bubbles/v2/spinner"
 	tea "charm.land/bubbletea/v2"
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/agent"
 	hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
 	"github.com/charmbracelet/crush/internal/config"

internal/tui/components/dialogs/commands/commands.go ๐Ÿ”—

@@ -10,10 +10,8 @@ import (
 	"charm.land/bubbles/v2/key"
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 
 	"github.com/charmbracelet/crush/internal/agent"
-	"github.com/charmbracelet/crush/internal/agent/hyper"
 	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
@@ -364,7 +362,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
 			selectedModel := cfg.Models[agentCfg.Model]
 
 			// Anthropic models: thinking toggle
-			if providerCfg.Type == catwalk.TypeAnthropic || providerCfg.Type == catwalk.Type(hyper.Name) {
+			if model.CanReason && len(model.ReasoningLevels) == 0 {
 				status := "Enable"
 				if selectedModel.Think {
 					status = "Disable"

internal/tui/components/dialogs/models/list.go ๐Ÿ”—

@@ -7,7 +7,7 @@ import (
 	"strings"
 
 	tea "charm.land/bubbletea/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/tui/exp/list"
 	"github.com/charmbracelet/crush/internal/tui/styles"

internal/tui/components/dialogs/models/list_recent_test.go ๐Ÿ”—

@@ -9,7 +9,7 @@ import (
 	"testing"
 
 	tea "charm.land/bubbletea/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/log"
 	"github.com/charmbracelet/crush/internal/tui/exp/list"

internal/tui/components/dialogs/models/models.go ๐Ÿ”—

@@ -9,8 +9,8 @@ import (
 	"charm.land/bubbles/v2/key"
 	"charm.land/bubbles/v2/spinner"
 	tea "charm.land/bubbletea/v2"
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/tui/components/core"

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

@@ -2,6 +2,8 @@ package lsp
 
 import (
 	"fmt"
+	"maps"
+	"slices"
 	"strings"
 
 	"charm.land/lipgloss/v2"
@@ -35,32 +37,32 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
 		lspList = append(lspList, section, "")
 	}
 
-	lspConfigs := config.Get().LSP.Sorted()
-	if len(lspConfigs) == 0 {
+	// Get LSP states
+	lsps := slices.SortedFunc(maps.Values(app.GetLSPStates()), func(a, b app.LSPClientInfo) int {
+		return strings.Compare(a.Name, b.Name)
+	})
+	if len(lsps) == 0 {
 		lspList = append(lspList, t.S().Base.Foreground(t.Border).Render("None"))
 		return lspList
 	}
 
-	// Get LSP states
-	lspStates := app.GetLSPStates()
-
 	// Determine how many items to show
-	maxItems := len(lspConfigs)
+	maxItems := len(lsps)
 	if opts.MaxItems > 0 {
-		maxItems = min(opts.MaxItems, len(lspConfigs))
+		maxItems = min(opts.MaxItems, len(lsps))
 	}
 
-	for i, l := range lspConfigs {
+	for i, info := range lsps {
 		if i >= maxItems {
 			break
 		}
 
-		icon, description := iconAndDescription(l, t, lspStates)
+		icon, description := iconAndDescription(t, info)
 
 		// Calculate diagnostic counts if we have LSP clients
 		var extraContent string
 		if lspClients != nil {
-			if client, ok := lspClients.Get(l.Name); ok {
+			if client, ok := lspClients.Get(info.Name); ok {
 				counts := client.GetDiagnosticCounts()
 				errs := []string{}
 				if counts.Error > 0 {
@@ -83,7 +85,7 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
 			core.Status(
 				core.StatusOpts{
 					Icon:         icon.String(),
-					Title:        l.Name,
+					Title:        info.Name,
 					Description:  description,
 					ExtraContent: extraContent,
 				},
@@ -95,12 +97,7 @@ 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]
+func iconAndDescription(t *styles.Theme, info app.LSPClientInfo) (lipgloss.Style, string) {
 	switch info.State {
 	case lsp.StateStarting:
 		return t.ItemBusyIcon, t.S().Subtle.Render("starting...")

internal/tui/page/chat/chat.go ๐Ÿ”—

@@ -327,6 +327,9 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		u, cmd = p.chat.Update(msg)
 		p.chat = u.(chat.MessageListCmp)
 		cmds = append(cmds, cmd)
+		u, cmd = p.editor.Update(msg)
+		p.editor = u.(editor.Editor)
+		cmds = append(cmds, cmd)
 		return p, tea.Batch(cmds...)
 	case filepicker.FilePickedMsg,
 		completions.CompletionsClosedMsg,

internal/ui/chat/generic.go ๐Ÿ”—

@@ -0,0 +1,98 @@
+package chat
+
+import (
+	"encoding/json"
+	"strings"
+
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/stringext"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// GenericToolMessageItem is a message item that represents an unknown tool call.
+type GenericToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*GenericToolMessageItem)(nil)
+
+// NewGenericToolMessageItem creates a new [GenericToolMessageItem].
+func NewGenericToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &GenericToolRenderContext{}, canceled)
+}
+
+// GenericToolRenderContext renders unknown/generic tool messages.
+type GenericToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (g *GenericToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	name := genericPrettyName(opts.ToolCall.Name)
+
+	if opts.IsPending() {
+		return pendingTool(sty, name, opts.Anim)
+	}
+
+	var params map[string]any
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	var toolParams []string
+	if len(params) > 0 {
+		parsed, _ := json.Marshal(params)
+		toolParams = append(toolParams, string(parsed))
+	}
+
+	header := toolHeader(sty, opts.Status, name, cappedWidth, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if !opts.HasResult() || opts.Result.Content == "" {
+		return header
+	}
+
+	bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+
+	// Handle image data.
+	if opts.Result.Data != "" && strings.HasPrefix(opts.Result.MIMEType, "image/") {
+		body := sty.Tool.Body.Render(toolOutputImageContent(sty, opts.Result.Data, opts.Result.MIMEType))
+		return joinToolParts(header, body)
+	}
+
+	// Try to parse result as JSON for pretty display.
+	var result json.RawMessage
+	var body string
+	if err := json.Unmarshal([]byte(opts.Result.Content), &result); err == nil {
+		prettyResult, err := json.MarshalIndent(result, "", "  ")
+		if err == nil {
+			body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.json", string(prettyResult), 0, bodyWidth, opts.ExpandedContent))
+		} else {
+			body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+		}
+	} else if looksLikeMarkdown(opts.Result.Content) {
+		body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.md", opts.Result.Content, 0, bodyWidth, opts.ExpandedContent))
+	} else {
+		body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+	}
+
+	return joinToolParts(header, body)
+}
+
+// genericPrettyName converts a snake_case or kebab-case tool name to a
+// human-readable title case name.
+func genericPrettyName(name string) string {
+	name = strings.ReplaceAll(name, "_", " ")
+	name = strings.ReplaceAll(name, "-", " ")
+	return stringext.Capitalize(name)
+}

internal/ui/chat/messages.go ๐Ÿ”—

@@ -7,8 +7,8 @@ import (
 	"time"
 
 	tea "charm.land/bubbletea/v2"
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/ui/anim"
@@ -18,9 +18,9 @@ import (
 	"github.com/charmbracelet/crush/internal/ui/styles"
 )
 
-// this is the total width that is taken up by the border + padding
-// we also cap the width so text is readable to the maxTextWidth(120)
-const messageLeftPaddingTotal = 2
+// MessageLeftPaddingTotal is the total width that is taken up by the border +
+// padding. We also cap the width so text is readable to the maxTextWidth(120).
+const MessageLeftPaddingTotal = 2
 
 // maxTextWidth is the maximum width text messages can be
 const maxTextWidth = 120
@@ -38,7 +38,9 @@ type Animatable interface {
 
 // Expandable is an interface for items that can be expanded or collapsed.
 type Expandable interface {
-	ToggleExpanded()
+	// ToggleExpanded toggles the expanded state of the item. It returns
+	// whether the item is now expanded.
+	ToggleExpanded() bool
 }
 
 // KeyEventHandler is an interface for items that can handle key events.
@@ -100,7 +102,7 @@ func (h *highlightableMessageItem) renderHighlighted(content string, width, heig
 func (h *highlightableMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) {
 	// Adjust columns for the style's left inset (border + padding) since we
 	// highlight the content only.
-	offset := messageLeftPaddingTotal
+	offset := MessageLeftPaddingTotal
 	h.startLine = startLine
 	h.startCol = max(0, startCol-offset)
 	h.endLine = endLine
@@ -205,7 +207,7 @@ func (a *AssistantInfoItem) ID() string {
 
 // RawRender implements MessageItem.
 func (a *AssistantInfoItem) RawRender(width int) string {
-	innerWidth := max(0, width-messageLeftPaddingTotal)
+	innerWidth := max(0, width-MessageLeftPaddingTotal)
 	content, _, ok := a.getCachedRender(innerWidth)
 	if !ok {
 		content = a.renderContent(innerWidth)
@@ -245,7 +247,7 @@ func (a *AssistantInfoItem) renderContent(width int) string {
 
 // cappedMessageWidth returns the maximum width for message content for readability.
 func cappedMessageWidth(availableWidth int) int {
-	return min(availableWidth-messageLeftPaddingTotal, maxTextWidth)
+	return min(availableWidth-MessageLeftPaddingTotal, maxTextWidth)
 }
 
 // ExtractMessageItems extracts [MessageItem]s from a [message.Message]. It

internal/ui/chat/tools.go ๐Ÿ”—

@@ -15,6 +15,7 @@ import (
 	"github.com/charmbracelet/crush/internal/diff"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/stringext"
 	"github.com/charmbracelet/crush/internal/ui/anim"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/styles"
@@ -156,6 +157,8 @@ type baseToolMessageItem struct {
 	expandedContent bool
 }
 
+var _ Expandable = (*baseToolMessageItem)(nil)
+
 // newBaseToolMessageItem is the internal constructor for base tool message items.
 func newBaseToolMessageItem(
 	sty *styles.Styles,
@@ -255,14 +258,7 @@ func NewToolMessageItem(
 		if strings.HasPrefix(toolCall.Name, "mcp_") {
 			item = NewMCPToolMessageItem(sty, toolCall, result, canceled)
 		} else {
-			// TODO: Implement other tool items
-			item = newBaseToolMessageItem(
-				sty,
-				toolCall,
-				result,
-				&DefaultToolRenderContext{},
-				canceled,
-			)
+			item = NewGenericToolMessageItem(sty, toolCall, result, canceled)
 		}
 	}
 	item.SetMessageID(messageID)
@@ -298,7 +294,7 @@ func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
 
 // RawRender implements [MessageItem].
 func (t *baseToolMessageItem) RawRender(width int) string {
-	toolItemWidth := width - messageLeftPaddingTotal
+	toolItemWidth := width - MessageLeftPaddingTotal
 	if t.hasCappedWidth {
 		toolItemWidth = cappedMessageWidth(width)
 	}
@@ -404,18 +400,15 @@ func (t *baseToolMessageItem) SetSpinningFunc(fn SpinningFunc) {
 }
 
 // ToggleExpanded toggles the expanded state of the thinking box.
-func (t *baseToolMessageItem) ToggleExpanded() {
+func (t *baseToolMessageItem) ToggleExpanded() bool {
 	t.expandedContent = !t.expandedContent
 	t.clearCache()
+	return t.expandedContent
 }
 
 // HandleMouseClick implements MouseClickable.
 func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
-	if btn != ansi.MouseLeft {
-		return false
-	}
-	t.ToggleExpanded()
-	return true
+	return btn == ansi.MouseLeft
 }
 
 // HandleKeyEvent implements KeyEventHandler.
@@ -538,9 +531,7 @@ func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, n
 
 // toolOutputPlainContent renders plain text with optional expansion support.
 func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
-	content = strings.ReplaceAll(content, "\r\n", "\n")
-	content = strings.ReplaceAll(content, "\t", "    ")
-	content = strings.TrimSpace(content)
+	content = stringext.NormalizeSpace(content)
 	lines := strings.Split(content, "\n")
 
 	maxLines := responseContextHeight
@@ -573,8 +564,7 @@ func toolOutputPlainContent(sty *styles.Styles, content string, width int, expan
 
 // toolOutputCodeContent renders code with syntax highlighting and line numbers.
 func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, width int, expanded bool) string {
-	content = strings.ReplaceAll(content, "\r\n", "\n")
-	content = strings.ReplaceAll(content, "\t", "    ")
+	content = stringext.NormalizeSpace(content)
 
 	lines := strings.Split(content, "\n")
 	maxLines := responseContextHeight
@@ -598,19 +588,17 @@ func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, wid
 	numFmt := fmt.Sprintf("%%%dd", maxDigits)
 
 	bodyWidth := width - toolBodyLeftPaddingTotal
-	codeWidth := bodyWidth - maxDigits - 4 // -4 for line number padding
+	codeWidth := bodyWidth - maxDigits
 
 	var out []string
 	for i, ln := range highlightedLines {
 		lineNum := sty.Tool.ContentLineNumber.Render(fmt.Sprintf(numFmt, i+1+offset))
 
-		if lipgloss.Width(ln) > codeWidth {
-			ln = ansi.Truncate(ln, codeWidth, "โ€ฆ")
-		}
+		// Truncate accounting for padding that will be added.
+		ln = ansi.Truncate(ln, codeWidth-sty.Tool.ContentCodeLine.GetHorizontalPadding(), "โ€ฆ")
 
 		codeLine := sty.Tool.ContentCodeLine.
 			Width(codeWidth).
-			PaddingLeft(2).
 			Render(ln)
 
 		out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine))
@@ -619,7 +607,7 @@ func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, wid
 	// Add truncation message if needed.
 	if len(lines) > maxLines && !expanded {
 		out = append(out, sty.Tool.ContentCodeTruncation.
-			Width(bodyWidth).
+			Width(width).
 			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
 		)
 	}
@@ -699,7 +687,7 @@ func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent stri
 		truncMsg := sty.Tool.DiffTruncation.
 			Width(bodyWidth).
 			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
-		formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n")
+		formatted = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg
 	}
 
 	return sty.Tool.Body.Render(formatted)
@@ -783,9 +771,7 @@ func roundedEnumerator(lPadding, width int) tree.Enumerator {
 
 // toolOutputMarkdownContent renders markdown content with optional truncation.
 func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string {
-	content = strings.ReplaceAll(content, "\r\n", "\n")
-	content = strings.ReplaceAll(content, "\t", "    ")
-	content = strings.TrimSpace(content)
+	content = stringext.NormalizeSpace(content)
 
 	// Cap width for readability.
 	if width > maxTextWidth {
@@ -1130,7 +1116,7 @@ func (t *baseToolMessageItem) formatViewResultForCopy() string {
 
 	var result strings.Builder
 	if lang != "" {
-		result.WriteString(fmt.Sprintf("```%s\n", lang))
+		fmt.Fprintf(&result, "```%s\n", lang)
 	} else {
 		result.WriteString("```\n")
 	}
@@ -1166,7 +1152,7 @@ func (t *baseToolMessageItem) formatEditResultForCopy() string {
 		}
 		diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
 
-		result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
+		fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
 		result.WriteString("```diff\n")
 		result.WriteString(diffContent)
 		result.WriteString("\n```")
@@ -1200,7 +1186,7 @@ func (t *baseToolMessageItem) formatMultiEditResultForCopy() string {
 		}
 		diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
 
-		result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
+		fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
 		result.WriteString("```diff\n")
 		result.WriteString(diffContent)
 		result.WriteString("\n```")
@@ -1258,9 +1244,9 @@ func (t *baseToolMessageItem) formatWriteResultForCopy() string {
 	}
 
 	var result strings.Builder
-	result.WriteString(fmt.Sprintf("File: %s\n", fsext.PrettyPath(params.FilePath)))
+	fmt.Fprintf(&result, "File: %s\n", fsext.PrettyPath(params.FilePath))
 	if lang != "" {
-		result.WriteString(fmt.Sprintf("```%s\n", lang))
+		fmt.Fprintf(&result, "```%s\n", lang)
 	} else {
 		result.WriteString("```\n")
 	}
@@ -1283,13 +1269,13 @@ func (t *baseToolMessageItem) formatFetchResultForCopy() string {
 
 	var result strings.Builder
 	if params.URL != "" {
-		result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
+		fmt.Fprintf(&result, "URL: %s\n", params.URL)
 	}
 	if params.Format != "" {
-		result.WriteString(fmt.Sprintf("Format: %s\n", params.Format))
+		fmt.Fprintf(&result, "Format: %s\n", params.Format)
 	}
 	if params.Timeout > 0 {
-		result.WriteString(fmt.Sprintf("Timeout: %ds\n", params.Timeout))
+		fmt.Fprintf(&result, "Timeout: %ds\n", params.Timeout)
 	}
 	result.WriteString("\n")
 
@@ -1311,10 +1297,10 @@ func (t *baseToolMessageItem) formatAgenticFetchResultForCopy() string {
 
 	var result strings.Builder
 	if params.URL != "" {
-		result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
+		fmt.Fprintf(&result, "URL: %s\n", params.URL)
 	}
 	if params.Prompt != "" {
-		result.WriteString(fmt.Sprintf("Prompt: %s\n\n", params.Prompt))
+		fmt.Fprintf(&result, "Prompt: %s\n\n", params.Prompt)
 	}
 
 	result.WriteString("```markdown\n")
@@ -1399,6 +1385,6 @@ func prettifyToolName(name string) string {
 	case tools.WriteToolName:
 		return "Write"
 	default:
-		return name
+		return genericPrettyName(name)
 	}
 }

internal/ui/common/capabilities.go ๐Ÿ”—

@@ -0,0 +1,133 @@
+package common
+
+import (
+	"slices"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/colorprofile"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/x/ansi"
+	xstrings "github.com/charmbracelet/x/exp/strings"
+)
+
+// Capabilities define different terminal capabilities supported.
+type Capabilities struct {
+	// Profile is the terminal color profile used to determine how colors are
+	// rendered.
+	Profile colorprofile.Profile
+	// Columns is the number of character columns in the terminal.
+	Columns int
+	// Rows is the number of character rows in the terminal.
+	Rows int
+	// PixelX is the width of the terminal in pixels.
+	PixelX int
+	// PixelY is the height of the terminal in pixels.
+	PixelY int
+	// KittyGraphics indicates whether the terminal supports the Kitty graphics
+	// protocol.
+	KittyGraphics bool
+	// SixelGraphics indicates whether the terminal supports Sixel graphics.
+	SixelGraphics bool
+	// Env is the terminal environment variables.
+	Env uv.Environ
+	// TerminalVersion is the terminal version string.
+	TerminalVersion string
+	// ReportFocusEvents indicates whether the terminal supports focus events.
+	ReportFocusEvents bool
+}
+
+// Update updates the capabilities based on the given message.
+func (c *Capabilities) Update(msg any) {
+	switch m := msg.(type) {
+	case tea.EnvMsg:
+		c.Env = uv.Environ(m)
+	case tea.ColorProfileMsg:
+		c.Profile = m.Profile
+	case tea.WindowSizeMsg:
+		c.Columns = m.Width
+		c.Rows = m.Height
+	case uv.WindowPixelSizeEvent:
+		c.PixelX = m.Width
+		c.PixelY = m.Height
+	case uv.KittyGraphicsEvent:
+		c.KittyGraphics = true
+	case uv.PrimaryDeviceAttributesEvent:
+		if slices.Contains(m, 4) {
+			c.SixelGraphics = true
+		}
+	case tea.TerminalVersionMsg:
+		c.TerminalVersion = m.Name
+	case uv.ModeReportEvent:
+		switch m.Mode {
+		case ansi.ModeFocusEvent:
+			c.ReportFocusEvents = modeSupported(m.Value)
+		}
+	}
+}
+
+// QueryCmd returns a [tea.Cmd] that queries the terminal for different
+// capabilities.
+func QueryCmd(env uv.Environ) tea.Cmd {
+	var sb strings.Builder
+	sb.WriteString(ansi.RequestPrimaryDeviceAttributes)
+
+	// Queries that should only be sent to "smart" normal terminals.
+	shouldQueryFor := shouldQueryCapabilities(env)
+	if shouldQueryFor {
+		sb.WriteString(ansi.RequestNameVersion)
+		// sb.WriteString(ansi.RequestModeFocusEvent) // TODO: re-enable when we need notifications.
+		sb.WriteString(ansi.WindowOp(14)) // Window size in pixels
+		kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
+		if _, isTmux := env.LookupEnv("TMUX"); isTmux {
+			kittyReq = ansi.TmuxPassthrough(kittyReq)
+		}
+		sb.WriteString(kittyReq)
+	}
+
+	return tea.Raw(sb.String())
+}
+
+// SupportsTrueColor returns true if the terminal supports true color.
+func (c Capabilities) SupportsTrueColor() bool {
+	return c.Profile == colorprofile.TrueColor
+}
+
+// SupportsKittyGraphics returns true if the terminal supports Kitty graphics.
+func (c Capabilities) SupportsKittyGraphics() bool {
+	return c.KittyGraphics
+}
+
+// SupportsSixelGraphics returns true if the terminal supports Sixel graphics.
+func (c Capabilities) SupportsSixelGraphics() bool {
+	return c.SixelGraphics
+}
+
+// CellSize returns the size of a single terminal cell in pixels.
+func (c Capabilities) CellSize() (width, height int) {
+	if c.Columns == 0 || c.Rows == 0 {
+		return 0, 0
+	}
+	return c.PixelX / c.Columns, c.PixelY / c.Rows
+}
+
+func modeSupported(v ansi.ModeSetting) bool {
+	return v.IsSet() || v.IsReset()
+}
+
+// kittyTerminals defines terminals supporting querying capabilities.
+var kittyTerminals = []string{"alacritty", "ghostty", "kitty", "rio", "wezterm"}
+
+func shouldQueryCapabilities(env uv.Environ) bool {
+	const osVendorTypeApple = "Apple"
+	termType := env.Getenv("TERM")
+	termProg, okTermProg := env.LookupEnv("TERM_PROGRAM")
+	_, okSSHTTY := env.LookupEnv("SSH_TTY")
+	if okTermProg && strings.Contains(termProg, osVendorTypeApple) {
+		return false
+	}
+	return (!okTermProg && !okSSHTTY) ||
+		(!strings.Contains(termProg, osVendorTypeApple) && !okSSHTTY) ||
+		// Terminals that do support XTVERSION.
+		xstrings.ContainsAnyOf(termType, kittyTerminals...)
+}

internal/ui/dialog/actions.go ๐Ÿ”—

@@ -7,7 +7,7 @@ import (
 	"path/filepath"
 
 	tea "charm.land/bubbletea/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/commands"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/message"

internal/ui/dialog/api_key_input.go ๐Ÿ”—

@@ -10,7 +10,7 @@ import (
 	"charm.land/bubbles/v2/spinner"
 	"charm.land/bubbles/v2/textinput"
 	tea "charm.land/bubbletea/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/styles"

internal/ui/dialog/commands.go ๐Ÿ”—

@@ -9,8 +9,6 @@ import (
 	"charm.land/bubbles/v2/spinner"
 	"charm.land/bubbles/v2/textinput"
 	tea "charm.land/bubbletea/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/crush/internal/agent/hyper"
 	"github.com/charmbracelet/crush/internal/commands"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/ui/common"
@@ -29,8 +27,9 @@ type CommandType uint
 func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] }
 
 const (
-	sidebarCompactModeBreakpoint  = 120
-	defaultCommandsDialogMaxWidth = 70
+	sidebarCompactModeBreakpoint   = 120
+	defaultCommandsDialogMaxHeight = 20
+	defaultCommandsDialogMaxWidth  = 70
 )
 
 const (
@@ -241,8 +240,8 @@ func commandsRadioView(sty *styles.Styles, selected CommandType, hasUserCmds boo
 // Draw implements [Dialog].
 func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	t := c.com.Styles
-	width := max(0, min(defaultCommandsDialogMaxWidth, area.Dx()))
-	height := max(0, min(defaultDialogHeight, area.Dy()))
+	width := max(0, min(defaultCommandsDialogMaxWidth, area.Dx()-t.Dialog.View.GetHorizontalBorderSize()))
+	height := max(0, min(defaultCommandsDialogMaxHeight, area.Dy()-t.Dialog.View.GetVerticalBorderSize()))
 	if area.Dx() != c.windowWidth && c.selected == SystemCommands {
 		c.windowWidth = area.Dx()
 		// since some items in the list depend on width (e.g. toggle sidebar command),
@@ -405,7 +404,7 @@ func (c *Commands) defaultCommands() []*CommandItem {
 			selectedModel := cfg.Models[agentCfg.Model]
 
 			// Anthropic models: thinking toggle
-			if providerCfg.Type == catwalk.TypeAnthropic || providerCfg.Type == catwalk.Type(hyper.Name) {
+			if model.CanReason && len(model.ReasoningLevels) == 0 {
 				status := "Enable"
 				if selectedModel.Think {
 					status = "Disable"
@@ -422,7 +421,7 @@ func (c *Commands) defaultCommands() []*CommandItem {
 		}
 	}
 	// Only show toggle compact mode command if window width is larger than compact breakpoint (120)
-	if c.windowWidth > sidebarCompactModeBreakpoint && c.sessionID != "" {
+	if c.windowWidth >= sidebarCompactModeBreakpoint && c.sessionID != "" {
 		commands = append(commands, NewCommandItem(c.com.Styles, "toggle_sidebar", "Toggle Sidebar", "", ActionToggleCompactMode{}))
 	}
 	if c.sessionID != "" {

internal/ui/dialog/commands_item.go ๐Ÿ”—

@@ -66,11 +66,11 @@ func (c *CommandItem) Shortcut() string {
 
 // Render implements ListItem.
 func (c *CommandItem) Render(width int) string {
-	styles := ListIemStyles{
+	styles := ListItemStyles{
 		ItemBlurred:     c.t.Dialog.NormalItem,
 		ItemFocused:     c.t.Dialog.SelectedItem,
 		InfoTextBlurred: c.t.Base,
-		InfoTextFocused: c.t.Subtle,
+		InfoTextFocused: c.t.Base,
 	}
 	return renderItem(styles, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
 }

internal/ui/dialog/filepicker.go ๐Ÿ”—

@@ -29,7 +29,7 @@ type FilePicker struct {
 
 	imgEnc                      fimage.Encoding
 	imgPrevWidth, imgPrevHeight int
-	cellSize                    fimage.CellSize
+	cellSizeW, cellSizeH        int
 
 	fp              filepicker.Model
 	help            help.Model
@@ -47,6 +47,14 @@ type FilePicker struct {
 	}
 }
 
+// CellSize returns the cell size used for image rendering.
+func (f *FilePicker) CellSize() fimage.CellSize {
+	return fimage.CellSize{
+		Width:  f.cellSizeW,
+		Height: f.cellSizeH,
+	}
+}
+
 var _ Dialog = (*FilePicker)(nil)
 
 // NewFilePicker creates a new [FilePicker] dialog.
@@ -103,12 +111,12 @@ func NewFilePicker(com *common.Common) (*FilePicker, tea.Cmd) {
 }
 
 // SetImageCapabilities sets the image capabilities for the [FilePicker].
-func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) {
+func (f *FilePicker) SetImageCapabilities(caps *common.Capabilities) {
 	if caps != nil {
-		if caps.SupportsKittyGraphics {
+		if caps.SupportsKittyGraphics() {
 			f.imgEnc = fimage.EncodingKitty
 		}
-		f.cellSize = caps.CellSize()
+		f.cellSizeW, f.cellSizeH = caps.CellSize()
 		_, f.isTmux = caps.Env.LookupEnv("TMUX")
 	}
 }
@@ -186,7 +194,7 @@ func (f *FilePicker) HandleMsg(msg tea.Msg) Action {
 			img, err := loadImage(selFile)
 			if err == nil {
 				cmds = append(cmds, tea.Sequence(
-					f.imgEnc.Transmit(selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight, f.isTmux),
+					f.imgEnc.Transmit(selFile, img, f.CellSize(), f.imgPrevWidth, f.imgPrevHeight, f.isTmux),
 					func() tea.Msg {
 						f.previewingImage = true
 						return nil

internal/ui/dialog/models.go ๐Ÿ”—

@@ -10,7 +10,7 @@ import (
 	"charm.land/bubbles/v2/key"
 	"charm.land/bubbles/v2/textinput"
 	tea "charm.land/bubbletea/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/uiutil"
@@ -251,8 +251,8 @@ func (m *Models) modelTypeRadioView() string {
 // Draw implements [Dialog].
 func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	t := m.com.Styles
-	width := max(0, min(defaultModelsDialogMaxWidth, area.Dx()))
-	height := max(0, min(defaultDialogHeight, area.Dy()))
+	width := max(0, min(defaultModelsDialogMaxWidth, area.Dx()-t.Dialog.View.GetHorizontalBorderSize()))
+	height := max(0, min(defaultDialogHeight, area.Dy()-t.Dialog.View.GetVerticalBorderSize()))
 	innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
 	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
 		t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +

internal/ui/dialog/models_item.go ๐Ÿ”—

@@ -1,8 +1,8 @@
 package dialog
 
 import (
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/styles"
@@ -106,11 +106,11 @@ func (m *ModelItem) Render(width int) string {
 	if m.showProvider {
 		providerInfo = string(m.prov.Name)
 	}
-	styles := ListIemStyles{
+	styles := ListItemStyles{
 		ItemBlurred:     m.t.Dialog.NormalItem,
 		ItemFocused:     m.t.Dialog.SelectedItem,
 		InfoTextBlurred: m.t.Base,
-		InfoTextFocused: m.t.Subtle,
+		InfoTextFocused: m.t.Base,
 	}
 	return renderItem(styles, m.model.Name, providerInfo, m.focused, width, m.cache, &m.m)
 }

internal/ui/dialog/oauth.go ๐Ÿ”—

@@ -9,8 +9,8 @@ import (
 	"charm.land/bubbles/v2/key"
 	"charm.land/bubbles/v2/spinner"
 	tea "charm.land/bubbletea/v2"
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/oauth"
 	"github.com/charmbracelet/crush/internal/ui/common"

internal/ui/dialog/oauth_copilot.go ๐Ÿ”—

@@ -6,7 +6,7 @@ import (
 	"time"
 
 	tea "charm.land/bubbletea/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/oauth/copilot"
 	"github.com/charmbracelet/crush/internal/ui/common"

internal/ui/dialog/oauth_hyper.go ๐Ÿ”—

@@ -6,7 +6,7 @@ import (
 	"time"
 
 	tea "charm.land/bubbletea/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"charm.land/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/oauth/hyper"
 	"github.com/charmbracelet/crush/internal/ui/common"

internal/ui/dialog/permissions.go ๐Ÿ”—

@@ -48,7 +48,7 @@ const (
 	// layoutSpacingLines is the number of empty lines used for layout spacing.
 	layoutSpacingLines = 4
 	// minWindowWidth is the minimum window width before forcing fullscreen.
-	minWindowWidth = 60
+	minWindowWidth = 77
 	// minWindowHeight is the minimum window height before forcing fullscreen.
 	minWindowHeight = 20
 )
@@ -392,6 +392,7 @@ func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 		} else {
 			availableHeight = maxHeight - fixedHeight
 		}
+		availableHeight = max(availableHeight, 3)
 	} else {
 		availableHeight = maxHeight - headerHeight - buttonsHeight - helpHeight - frameHeight
 	}

internal/ui/dialog/reasoning.go ๐Ÿ”—

@@ -293,11 +293,11 @@ func (r *ReasoningItem) Render(width int) string {
 	if r.isCurrent {
 		info = "current"
 	}
-	styles := ListIemStyles{
+	styles := ListItemStyles{
 		ItemBlurred:     r.t.Dialog.NormalItem,
 		ItemFocused:     r.t.Dialog.SelectedItem,
 		InfoTextBlurred: r.t.Base,
-		InfoTextFocused: r.t.Subtle,
+		InfoTextFocused: r.t.Base,
 	}
 	return renderItem(styles, r.title, info, r.focused, width, r.cache, &r.m)
 }

internal/ui/dialog/sessions.go ๐Ÿ”—

@@ -261,11 +261,11 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 		rc.ViewStyle = t.Dialog.Sessions.DeletingView
 		rc.AddPart(t.Dialog.Sessions.DeletingMessage.Render("Delete this session?"))
 	case sessionsModeUpdating:
-		rc.TitleStyle = t.Dialog.Sessions.UpdatingTitle
-		rc.TitleGradientFromColor = t.Dialog.Sessions.UpdatingTitleGradientFromColor
-		rc.TitleGradientToColor = t.Dialog.Sessions.UpdatingTitleGradientToColor
-		rc.ViewStyle = t.Dialog.Sessions.UpdatingView
-		message := t.Dialog.Sessions.UpdatingMessage.Render("Rename this session?")
+		rc.TitleStyle = t.Dialog.Sessions.RenamingingTitle
+		rc.TitleGradientFromColor = t.Dialog.Sessions.RenamingTitleGradientFromColor
+		rc.TitleGradientToColor = t.Dialog.Sessions.RenamingTitleGradientToColor
+		rc.ViewStyle = t.Dialog.Sessions.RenamingView
+		message := t.Dialog.Sessions.RenamingingMessage.Render("Rename this session?")
 		rc.AddPart(message)
 		item := s.selectedSessionItem()
 		if item == nil {
@@ -279,8 +279,8 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 		start, end := s.list.VisibleItemIndices()
 		selectedIndex := s.list.Selected()
 
-		titleStyle := t.Dialog.Sessions.UpdatingTitle
-		dialogStyle := t.Dialog.Sessions.UpdatingView
+		titleStyle := t.Dialog.Sessions.RenamingingTitle
+		dialogStyle := t.Dialog.Sessions.RenamingView
 		inputStyle := t.Dialog.InputPrompt
 
 		// Adjust cursor position to account for dialog layout + message

internal/ui/dialog/sessions_item.go ๐Ÿ”—

@@ -76,7 +76,7 @@ func (s *SessionItem) Cursor() *tea.Cursor {
 // Render returns the string representation of the session item.
 func (s *SessionItem) Render(width int) string {
 	info := humanize.Time(time.Unix(s.UpdatedAt, 0))
-	styles := ListIemStyles{
+	styles := ListItemStyles{
 		ItemBlurred:     s.t.Dialog.NormalItem,
 		ItemFocused:     s.t.Dialog.SelectedItem,
 		InfoTextBlurred: s.t.Subtle,
@@ -88,8 +88,8 @@ func (s *SessionItem) Render(width int) string {
 		styles.ItemBlurred = s.t.Dialog.Sessions.DeletingItemBlurred
 		styles.ItemFocused = s.t.Dialog.Sessions.DeletingItemFocused
 	case sessionsModeUpdating:
-		styles.ItemBlurred = s.t.Dialog.Sessions.UpdatingItemBlurred
-		styles.ItemFocused = s.t.Dialog.Sessions.UpdatingItemFocused
+		styles.ItemBlurred = s.t.Dialog.Sessions.RenamingItemBlurred
+		styles.ItemFocused = s.t.Dialog.Sessions.RenamingingItemFocused
 		if s.focused {
 			inputWidth := width - styles.InfoTextFocused.GetHorizontalFrameSize()
 			s.updateTitleInput.SetWidth(inputWidth)
@@ -101,14 +101,14 @@ func (s *SessionItem) Render(width int) string {
 	return renderItem(styles, s.Title, info, s.focused, width, s.cache, &s.m)
 }
 
-type ListIemStyles struct {
+type ListItemStyles struct {
 	ItemBlurred     lipgloss.Style
 	ItemFocused     lipgloss.Style
 	InfoTextBlurred lipgloss.Style
 	InfoTextFocused lipgloss.Style
 }
 
-func renderItem(t ListIemStyles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
+func renderItem(t ListItemStyles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
 	if cache == nil {
 		cache = make(map[int]string)
 	}
@@ -141,14 +141,14 @@ func renderItem(t ListIemStyles, title string, info string, focused bool, width
 	titleWidth := lipgloss.Width(title)
 	gap := strings.Repeat(" ", max(0, lineWidth-titleWidth-infoWidth))
 	content := title
-	if matches := len(m.MatchedIndexes); matches > 0 {
+	if m != nil && len(m.MatchedIndexes) > 0 {
 		var lastPos int
 		parts := make([]string, 0)
 		ranges := matchedRanges(m.MatchedIndexes)
 		for _, rng := range ranges {
 			start, stop := bytePosToVisibleCharPos(title, rng)
 			if start > lastPos {
-				parts = append(parts, title[lastPos:start])
+				parts = append(parts, ansi.Cut(title, lastPos, start))
 			}
 			// NOTE: We're using [ansi.Style] here instead of [lipglosStyle]
 			// because we can control the underline start and stop more
@@ -157,13 +157,13 @@ func renderItem(t ListIemStyles, title string, info string, focused bool, width
 			// with other style
 			parts = append(parts,
 				ansi.NewStyle().Underline(true).String(),
-				title[start:stop+1],
+				ansi.Cut(title, start, stop+1),
 				ansi.NewStyle().Underline(false).String(),
 			)
 			lastPos = stop + 1
 		}
-		if lastPos < len(title) {
-			parts = append(parts, title[lastPos:])
+		if lastPos < ansi.StringWidth(title) {
+			parts = append(parts, ansi.Cut(title, lastPos, ansi.StringWidth(title)))
 		}
 
 		content = strings.Join(parts, "")
@@ -193,7 +193,7 @@ func sessionItems(t *styles.Styles, mode sessionsMode, sessions ...session.Sessi
 			item.updateTitleInput.SetVirtualCursor(false)
 			item.updateTitleInput.Prompt = ""
 			inputStyle := t.TextInput
-			inputStyle.Focused.Placeholder = inputStyle.Focused.Placeholder.Foreground(t.FgHalfMuted)
+			inputStyle.Focused.Placeholder = t.Dialog.Sessions.RenamingPlaceholder
 			item.updateTitleInput.SetStyles(inputStyle)
 			item.updateTitleInput.Focus()
 		}

internal/ui/image/image.go ๐Ÿ”—

@@ -13,62 +13,12 @@ import (
 
 	tea "charm.land/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/uiutil"
-	uv "github.com/charmbracelet/ultraviolet"
 	"github.com/charmbracelet/x/ansi"
 	"github.com/charmbracelet/x/ansi/kitty"
 	"github.com/disintegration/imaging"
 	paintbrush "github.com/jordanella/go-ansi-paintbrush"
 )
 
-// Capabilities represents the capabilities of displaying images on the
-// terminal.
-type Capabilities struct {
-	// Columns is the number of character columns in the terminal.
-	Columns int
-	// Rows is the number of character rows in the terminal.
-	Rows int
-	// PixelWidth is the width of the terminal in pixels.
-	PixelWidth int
-	// PixelHeight is the height of the terminal in pixels.
-	PixelHeight int
-	// SupportsKittyGraphics indicates whether the terminal supports the Kitty
-	// graphics protocol.
-	SupportsKittyGraphics bool
-	// Env is the terminal environment variables.
-	Env uv.Environ
-}
-
-// CellSize returns the size of a single terminal cell in pixels.
-func (c Capabilities) CellSize() CellSize {
-	return CalculateCellSize(c.PixelWidth, c.PixelHeight, c.Columns, c.Rows)
-}
-
-// CalculateCellSize calculates the size of a single terminal cell in pixels
-// based on the terminal's pixel dimensions and character dimensions.
-func CalculateCellSize(pixelWidth, pixelHeight, charWidth, charHeight int) CellSize {
-	if charWidth == 0 || charHeight == 0 {
-		return CellSize{}
-	}
-
-	return CellSize{
-		Width:  pixelWidth / charWidth,
-		Height: pixelHeight / charHeight,
-	}
-}
-
-// RequestCapabilities is a [tea.Cmd] that requests the terminal to report
-// its image related capabilities to the program.
-func RequestCapabilities(env uv.Environ) tea.Cmd {
-	winOpReq := ansi.WindowOp(14) // Window size in pixels
-	// ID 31 is just a random ID used to detect Kitty graphics support.
-	kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
-	if _, isTmux := env.LookupEnv("TMUX"); isTmux {
-		kittyReq = ansi.TmuxPassthrough(kittyReq)
-	}
-
-	return tea.Raw(winOpReq + kittyReq)
-}
-
 // TransmittedMsg is a message indicating that an image has been transmitted to
 // the terminal.
 type TransmittedMsg struct {
@@ -218,7 +168,7 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i
 				return chunk
 			},
 		}); err != nil {
-			slog.Error("failed to encode image for kitty graphics", "err", err)
+			slog.Error("Failed to encode image for kitty graphics", "err", err)
 			return uiutil.InfoMsg{
 				Type: uiutil.InfoTypeError,
 				Msg:  "failed to encode image",

internal/ui/list/highlight.go ๐Ÿ”—

@@ -5,6 +5,7 @@ import (
 	"strings"
 
 	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/stringext"
 	uv "github.com/charmbracelet/ultraviolet"
 )
 
@@ -53,6 +54,8 @@ func Highlight(content string, area image.Rectangle, startLine, startCol, endLin
 // HighlightBuffer highlights a region of text within the given content and
 // region, returning a [uv.ScreenBuffer].
 func HighlightBuffer(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) *uv.ScreenBuffer {
+	content = stringext.NormalizeSpace(content)
+
 	if startLine < 0 || startCol < 0 {
 		return nil
 	}

internal/ui/list/list.go ๐Ÿ”—

@@ -75,30 +75,26 @@ func (l *List) Gap() int {
 	return l.gap
 }
 
-// AtBottom returns whether the list is scrolled to the bottom.
+// AtBottom returns whether the list is showing the last item at the bottom.
 func (l *List) AtBottom() bool {
+	const margin = 2
+
 	if len(l.items) == 0 {
 		return true
 	}
 
-	// Calculate total height of all items from the bottom.
+	// Calculate the height from offsetIdx to the end.
 	var totalHeight int
-	for i := len(l.items) - 1; i >= 0; i-- {
-		item := l.getItem(i)
-		totalHeight += item.height
-		if l.gap > 0 && i < len(l.items)-1 {
-			totalHeight += l.gap
-		}
-		if totalHeight >= l.height {
-			// This is the expected bottom position.
-			expectedIdx := i
-			expectedLine := totalHeight - l.height
-			return l.offsetIdx == expectedIdx && l.offsetLine >= expectedLine
+	for idx := l.offsetIdx; idx < len(l.items); idx++ {
+		item := l.getItem(idx)
+		itemHeight := item.height
+		if l.gap > 0 && idx > l.offsetIdx {
+			itemHeight += l.gap
 		}
+		totalHeight += itemHeight
 	}
 
-	// All items fit in viewport - we're at bottom if at top.
-	return l.offsetIdx == 0 && l.offsetLine == 0
+	return totalHeight-l.offsetLine-margin <= l.height
 }
 
 // SetReverse shows the list in reverse order.
@@ -121,6 +117,30 @@ func (l *List) Len() int {
 	return len(l.items)
 }
 
+// lastOffsetItem returns the index and line offsets of the last item that can
+// be partially visible in the viewport.
+func (l *List) lastOffsetItem() (int, int, int) {
+	var totalHeight int
+	var idx int
+	for idx = len(l.items) - 1; idx >= 0; idx-- {
+		item := l.getItem(idx)
+		itemHeight := item.height
+		if l.gap > 0 && idx < len(l.items)-1 {
+			itemHeight += l.gap
+		}
+		totalHeight += itemHeight
+		if totalHeight > l.height {
+			break
+		}
+	}
+
+	// Calculate line offset within the item
+	lineOffset := max(totalHeight-l.height, 0)
+	idx = max(idx, 0)
+
+	return idx, lineOffset, totalHeight
+}
+
 // getItem renders (if needed) and returns the item at the given index.
 func (l *List) getItem(idx int) renderedItem {
 	if idx < 0 || idx >= len(l.items) {
@@ -171,44 +191,29 @@ func (l *List) ScrollBy(lines int) {
 
 	if lines > 0 {
 		// Scroll down
-		// Calculate from the bottom how many lines needed to anchor the last
-		// item to the bottom
-		var totalLines int
-		var lastItemIdx int // the last item that can be partially visible
-		for i := len(l.items) - 1; i >= 0; i-- {
-			item := l.getItem(i)
-			totalLines += item.height
-			if l.gap > 0 && i < len(l.items)-1 {
-				totalLines += l.gap
-			}
-			if totalLines > l.height-1 {
-				lastItemIdx = i
-				break
-			}
-		}
-
-		// Now scroll down by lines
-		var item renderedItem
 		l.offsetLine += lines
-		for {
-			item = l.getItem(l.offsetIdx)
-			totalHeight := item.height
+		currentItem := l.getItem(l.offsetIdx)
+		for l.offsetLine >= currentItem.height {
+			l.offsetLine -= currentItem.height
 			if l.gap > 0 {
-				totalHeight += l.gap
-			}
-
-			if l.offsetIdx >= lastItemIdx || l.offsetLine < totalHeight {
-				// Valid offset
-				break
+				l.offsetLine -= l.gap
 			}
 
 			// Move to next item
-			l.offsetLine -= totalHeight
 			l.offsetIdx++
+			if l.offsetIdx > len(l.items)-1 {
+				// Reached bottom
+				l.ScrollToBottom()
+				return
+			}
+			currentItem = l.getItem(l.offsetIdx)
 		}
 
-		if l.offsetLine >= item.height {
-			l.offsetLine = item.height
+		lastOffsetIdx, lastOffsetLine, _ := l.lastOffsetItem()
+		if l.offsetIdx > lastOffsetIdx || (l.offsetIdx == lastOffsetIdx && l.offsetLine > lastOffsetLine) {
+			// Clamp to bottom
+			l.offsetIdx = lastOffsetIdx
+			l.offsetLine = lastOffsetLine
 		}
 	} else if lines < 0 {
 		// Scroll up
@@ -408,24 +413,9 @@ func (l *List) ScrollToBottom() {
 		return
 	}
 
-	// Scroll to the last item
-	var totalHeight int
-	for i := len(l.items) - 1; i >= 0; i-- {
-		item := l.getItem(i)
-		totalHeight += item.height
-		if l.gap > 0 && i < len(l.items)-1 {
-			totalHeight += l.gap
-		}
-		if totalHeight >= l.height {
-			l.offsetIdx = i
-			l.offsetLine = totalHeight - l.height
-			break
-		}
-	}
-	if totalHeight < l.height {
-		// All items fit in the viewport
-		l.ScrollToTop()
-	}
+	lastOffsetIdx, lastOffsetLine, _ := l.lastOffsetItem()
+	l.offsetIdx = lastOffsetIdx
+	l.offsetLine = lastOffsetLine
 }
 
 // ScrollToSelected scrolls the list to the selected item.

internal/ui/model/chat.go ๐Ÿ”—

@@ -2,6 +2,7 @@ package model
 
 import (
 	"strings"
+	"time"
 
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
@@ -11,8 +12,24 @@ import (
 	"github.com/charmbracelet/crush/internal/ui/list"
 	uv "github.com/charmbracelet/ultraviolet"
 	"github.com/charmbracelet/x/ansi"
+	"github.com/clipperhouse/displaywidth"
+	"github.com/clipperhouse/uax29/v2/words"
 )
 
+// Constants for multi-click detection.
+const (
+	doubleClickThreshold = 400 * time.Millisecond // 0.4s is typical double-click threshold
+	clickTolerance       = 2                      // x,y tolerance for double/tripple click
+)
+
+// DelayedClickMsg is sent after the double-click threshold to trigger a
+// single-click action (like expansion) if no double-click occurred.
+type DelayedClickMsg struct {
+	ClickID int
+	ItemIdx int
+	X, Y    int
+}
+
 // Chat represents the chat UI model that handles chat interactions and
 // messages.
 type Chat struct {
@@ -33,6 +50,15 @@ type Chat struct {
 	mouseDragItem int // Current item index being dragged over
 	mouseDragX    int // Current X in item content
 	mouseDragY    int // Current Y in item
+
+	// Click tracking for double/triple clicks
+	lastClickTime time.Time
+	lastClickX    int
+	lastClickY    int
+	clickCount    int
+
+	// Pending single click action (delayed to detect double-click)
+	pendingClickID int // Incremented on each click to invalidate old pending clicks
 }
 
 // NewChat creates a new instance of [Chat] that handles chat interactions and
@@ -66,6 +92,10 @@ func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
 // SetSize sets the size of the chat view port.
 func (m *Chat) SetSize(width, height int) {
 	m.list.SetSize(width, height)
+	// Anchor to bottom if we were at the bottom.
+	if m.list.AtBottom() {
+		m.list.ScrollToBottom()
+	}
 }
 
 // Len returns the number of items in the chat list.
@@ -408,6 +438,7 @@ func (m *Chat) MessageItem(id string) chat.MessageItem {
 func (m *Chat) ToggleExpandedSelectedItem() {
 	if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok {
 		expandable.ToggleExpanded()
+		m.list.ScrollToIndex(m.list.Selected())
 	}
 }
 
@@ -422,35 +453,104 @@ func (m *Chat) HandleKeyMsg(key tea.KeyMsg) (bool, tea.Cmd) {
 }
 
 // HandleMouseDown handles mouse down events for the chat component.
-func (m *Chat) HandleMouseDown(x, y int) bool {
+// It detects single, double, and triple clicks for text selection.
+// Returns whether the click was handled and an optional command for delayed
+// single-click actions.
+func (m *Chat) HandleMouseDown(x, y int) (bool, tea.Cmd) {
 	if m.list.Len() == 0 {
-		return false
+		return false, nil
 	}
 
 	itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
 	if itemIdx < 0 {
-		return false
+		return false, nil
 	}
 	if !m.isSelectable(itemIdx) {
-		return false
+		return false, nil
 	}
 
-	m.mouseDown = true
-	m.mouseDownItem = itemIdx
-	m.mouseDownX = x
-	m.mouseDownY = itemY
-	m.mouseDragItem = itemIdx
-	m.mouseDragX = x
-	m.mouseDragY = itemY
+	// Increment pending click ID to invalidate any previous pending clicks.
+	m.pendingClickID++
+	clickID := m.pendingClickID
+
+	// Detect multi-click (double/triple)
+	now := time.Now()
+	if now.Sub(m.lastClickTime) <= doubleClickThreshold &&
+		abs(x-m.lastClickX) <= clickTolerance &&
+		abs(y-m.lastClickY) <= clickTolerance {
+		m.clickCount++
+	} else {
+		m.clickCount = 1
+	}
+	m.lastClickTime = now
+	m.lastClickX = x
+	m.lastClickY = y
 
 	// Select the item that was clicked
 	m.list.SetSelected(itemIdx)
 
-	if clickable, ok := m.list.SelectedItem().(list.MouseClickable); ok {
-		return clickable.HandleMouseClick(ansi.MouseButton1, x, itemY)
+	var cmd tea.Cmd
+
+	switch m.clickCount {
+	case 1:
+		// Single click - start selection and schedule delayed click action.
+		m.mouseDown = true
+		m.mouseDownItem = itemIdx
+		m.mouseDownX = x
+		m.mouseDownY = itemY
+		m.mouseDragItem = itemIdx
+		m.mouseDragX = x
+		m.mouseDragY = itemY
+
+		// Schedule delayed click action (e.g., expansion) after a short delay.
+		// If a double-click occurs, the clickID will be invalidated.
+		cmd = tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
+			return DelayedClickMsg{
+				ClickID: clickID,
+				ItemIdx: itemIdx,
+				X:       x,
+				Y:       itemY,
+			}
+		})
+	case 2:
+		// Double click - select word (no delayed action)
+		m.selectWord(itemIdx, x, itemY)
+	case 3:
+		// Triple click - select line (no delayed action)
+		m.selectLine(itemIdx, itemY)
+		m.clickCount = 0 // Reset after triple click
 	}
 
-	return true
+	return true, cmd
+}
+
+// HandleDelayedClick handles a delayed single-click action (like expansion).
+// It only executes if the click ID matches (i.e., no double-click occurred)
+// and no text selection was made (drag to select).
+func (m *Chat) HandleDelayedClick(msg DelayedClickMsg) bool {
+	// Ignore if this click was superseded by a newer click (double/triple).
+	if msg.ClickID != m.pendingClickID {
+		return false
+	}
+
+	// Don't expand if user dragged to select text.
+	if m.HasHighlight() {
+		return false
+	}
+
+	// Execute the click action (e.g., expansion).
+	selectedItem := m.list.SelectedItem()
+	if clickable, ok := selectedItem.(list.MouseClickable); ok {
+		handled := clickable.HandleMouseClick(ansi.MouseButton1, msg.X, msg.Y)
+		// Toggle expansion if applicable.
+		if expandable, ok := selectedItem.(chat.Expandable); ok {
+			expandable.ToggleExpanded()
+		}
+		m.list.ScrollToIndex(m.list.Selected())
+		return handled
+	}
+
+	return false
 }
 
 // HandleMouseUp handles mouse up events for the chat component.
@@ -531,6 +631,11 @@ func (m *Chat) ClearMouse() {
 	m.mouseDown = false
 	m.mouseDownItem = -1
 	m.mouseDragItem = -1
+	m.lastClickTime = time.Time{}
+	m.lastClickX = 0
+	m.lastClickY = 0
+	m.clickCount = 0
+	m.pendingClickID++ // Invalidate any pending delayed click
 }
 
 // applyHighlightRange applies the current highlight range to the chat items.
@@ -608,3 +713,144 @@ func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemId
 
 	return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
 }
+
+// selectWord selects the word at the given position within an item.
+func (m *Chat) selectWord(itemIdx, x, itemY int) {
+	item := m.list.ItemAt(itemIdx)
+	if item == nil {
+		return
+	}
+
+	// Get the rendered content for this item
+	var rendered string
+	if rr, ok := item.(list.RawRenderable); ok {
+		rendered = rr.RawRender(m.list.Width())
+	} else {
+		rendered = item.Render(m.list.Width())
+	}
+
+	lines := strings.Split(rendered, "\n")
+	if itemY < 0 || itemY >= len(lines) {
+		return
+	}
+
+	// Adjust x for the item's left padding (border + padding) to get content column.
+	// The mouse x is in viewport space, but we need content space for boundary detection.
+	offset := chat.MessageLeftPaddingTotal
+	contentX := x - offset
+	if contentX < 0 {
+		contentX = 0
+	}
+
+	line := ansi.Strip(lines[itemY])
+	startCol, endCol := findWordBoundaries(line, contentX)
+	if startCol == endCol {
+		// No word found at position, fallback to single click behavior
+		m.mouseDown = true
+		m.mouseDownItem = itemIdx
+		m.mouseDownX = x
+		m.mouseDownY = itemY
+		m.mouseDragItem = itemIdx
+		m.mouseDragX = x
+		m.mouseDragY = itemY
+		return
+	}
+
+	// Set selection to the word boundaries (convert back to viewport space).
+	// Keep mouseDown true so HandleMouseUp triggers the copy.
+	m.mouseDown = true
+	m.mouseDownItem = itemIdx
+	m.mouseDownX = startCol + offset
+	m.mouseDownY = itemY
+	m.mouseDragItem = itemIdx
+	m.mouseDragX = endCol + offset
+	m.mouseDragY = itemY
+}
+
+// selectLine selects the entire line at the given position within an item.
+func (m *Chat) selectLine(itemIdx, itemY int) {
+	item := m.list.ItemAt(itemIdx)
+	if item == nil {
+		return
+	}
+
+	// Get the rendered content for this item
+	var rendered string
+	if rr, ok := item.(list.RawRenderable); ok {
+		rendered = rr.RawRender(m.list.Width())
+	} else {
+		rendered = item.Render(m.list.Width())
+	}
+
+	lines := strings.Split(rendered, "\n")
+	if itemY < 0 || itemY >= len(lines) {
+		return
+	}
+
+	// Get line length (stripped of ANSI codes) and account for padding.
+	// SetHighlight will subtract the offset, so we need to add it here.
+	offset := chat.MessageLeftPaddingTotal
+	lineLen := ansi.StringWidth(lines[itemY])
+
+	// Set selection to the entire line.
+	// Keep mouseDown true so HandleMouseUp triggers the copy.
+	m.mouseDown = true
+	m.mouseDownItem = itemIdx
+	m.mouseDownX = 0
+	m.mouseDownY = itemY
+	m.mouseDragItem = itemIdx
+	m.mouseDragX = lineLen + offset
+	m.mouseDragY = itemY
+}
+
+// findWordBoundaries finds the start and end column of the word at the given column.
+// Returns (startCol, endCol) where endCol is exclusive.
+func findWordBoundaries(line string, col int) (startCol, endCol int) {
+	if line == "" || col < 0 {
+		return 0, 0
+	}
+
+	i := displaywidth.StringGraphemes(line)
+	for i.Next() {
+	}
+
+	// Segment the line into words using UAX#29.
+	lineCol := 0 // tracks the visited column widths
+	lastCol := 0 // tracks the start of the current token
+	iter := words.FromString(line)
+	for iter.Next() {
+		token := iter.Value()
+		tokenWidth := displaywidth.String(token)
+
+		graphemeStart := lineCol
+		graphemeEnd := lineCol + tokenWidth
+		lineCol += tokenWidth
+
+		// If clicked before this token, return the previous token boundaries.
+		if col < graphemeStart {
+			return lastCol, lastCol
+		}
+
+		// Update lastCol to the end of this token for next iteration.
+		lastCol = graphemeEnd
+
+		// If clicked within this token, return its boundaries.
+		if col >= graphemeStart && col < graphemeEnd {
+			// If clicked on whitespace, return empty selection.
+			if strings.TrimSpace(token) == "" {
+				return col, col
+			}
+			return graphemeStart, graphemeEnd
+		}
+	}
+
+	return col, col
+}
+
+// abs returns the absolute value of an integer.
+func abs(x int) int {
+	if x < 0 {
+		return -x
+	}
+	return x
+}

internal/ui/model/history.go ๐Ÿ”—

@@ -0,0 +1,184 @@
+package model
+
+import (
+	"context"
+	"log/slog"
+
+	tea "charm.land/bubbletea/v2"
+
+	"github.com/charmbracelet/crush/internal/message"
+)
+
+// promptHistoryLoadedMsg is sent when prompt history is loaded.
+type promptHistoryLoadedMsg struct {
+	messages []string
+}
+
+// loadPromptHistory loads user messages for history navigation.
+func (m *UI) loadPromptHistory() tea.Cmd {
+	return func() tea.Msg {
+		ctx := context.Background()
+		var messages []message.Message
+		var err error
+
+		if m.session != nil {
+			messages, err = m.com.App.Messages.ListUserMessages(ctx, m.session.ID)
+		} else {
+			messages, err = m.com.App.Messages.ListAllUserMessages(ctx)
+		}
+		if err != nil {
+			slog.Error("Failed to load prompt history", "error", err)
+			return promptHistoryLoadedMsg{messages: nil}
+		}
+
+		texts := make([]string, 0, len(messages))
+		for _, msg := range messages {
+			if text := msg.Content().Text; text != "" {
+				texts = append(texts, text)
+			}
+		}
+		return promptHistoryLoadedMsg{messages: texts}
+	}
+}
+
+// handleHistoryUp handles up arrow for history navigation.
+func (m *UI) handleHistoryUp(msg tea.Msg) tea.Cmd {
+	// Navigate to older history entry from cursor position (0,0).
+	if m.textarea.Length() == 0 || m.isAtEditorStart() {
+		if m.historyPrev() {
+			// we send this so that the textarea moves the view to the correct position
+			// without this the cursor will show up in the wrong place.
+			ta, cmd := m.textarea.Update(nil)
+			m.textarea = ta
+			return cmd
+		}
+	}
+
+	// First move cursor to start before entering history.
+	if m.textarea.Line() == 0 {
+		m.textarea.CursorStart()
+		return nil
+	}
+
+	// Let textarea handle normal cursor movement.
+	ta, cmd := m.textarea.Update(msg)
+	m.textarea = ta
+	return cmd
+}
+
+// handleHistoryDown handles down arrow for history navigation.
+func (m *UI) handleHistoryDown(msg tea.Msg) tea.Cmd {
+	// Navigate to newer history entry from end of text.
+	if m.isAtEditorEnd() {
+		if m.historyNext() {
+			// we send this so that the textarea moves the view to the correct position
+			// without this the cursor will show up in the wrong place.
+			ta, cmd := m.textarea.Update(nil)
+			m.textarea = ta
+			return cmd
+		}
+	}
+
+	// First move cursor to end before navigating history.
+	if m.textarea.Line() == max(m.textarea.LineCount()-1, 0) {
+		m.textarea.MoveToEnd()
+		ta, cmd := m.textarea.Update(nil)
+		m.textarea = ta
+		return cmd
+	}
+
+	// Let textarea handle normal cursor movement.
+	ta, cmd := m.textarea.Update(msg)
+	m.textarea = ta
+	return cmd
+}
+
+// handleHistoryEscape handles escape for exiting history navigation.
+func (m *UI) handleHistoryEscape(msg tea.Msg) tea.Cmd {
+	// Return to current draft when browsing history.
+	if m.promptHistory.index >= 0 {
+		m.promptHistory.index = -1
+		m.textarea.Reset()
+		m.textarea.InsertString(m.promptHistory.draft)
+		ta, cmd := m.textarea.Update(nil)
+		m.textarea = ta
+		return cmd
+	}
+
+	// Let textarea handle escape normally.
+	ta, cmd := m.textarea.Update(msg)
+	m.textarea = ta
+	return cmd
+}
+
+// updateHistoryDraft updates history state when text is modified.
+func (m *UI) updateHistoryDraft(oldValue string) {
+	if m.textarea.Value() != oldValue {
+		m.promptHistory.draft = m.textarea.Value()
+		m.promptHistory.index = -1
+	}
+}
+
+// historyPrev changes the text area content to the previous message in the history
+// it returns false if it could not find the previous message.
+func (m *UI) historyPrev() bool {
+	if len(m.promptHistory.messages) == 0 {
+		return false
+	}
+	if m.promptHistory.index == -1 {
+		m.promptHistory.draft = m.textarea.Value()
+	}
+	nextIndex := m.promptHistory.index + 1
+	if nextIndex >= len(m.promptHistory.messages) {
+		return false
+	}
+	m.promptHistory.index = nextIndex
+	m.textarea.Reset()
+	m.textarea.InsertString(m.promptHistory.messages[nextIndex])
+	m.textarea.MoveToBegin()
+	return true
+}
+
+// historyNext changes the text area content to the next message in the history
+// it returns false if it could not find the next message.
+func (m *UI) historyNext() bool {
+	if m.promptHistory.index < 0 {
+		return false
+	}
+	nextIndex := m.promptHistory.index - 1
+	if nextIndex < 0 {
+		m.promptHistory.index = -1
+		m.textarea.Reset()
+		m.textarea.InsertString(m.promptHistory.draft)
+		return true
+	}
+	m.promptHistory.index = nextIndex
+	m.textarea.Reset()
+	m.textarea.InsertString(m.promptHistory.messages[nextIndex])
+	return true
+}
+
+// historyReset resets the history, but does not clear the message
+// it just sets the current draft to empty and the position in the history.
+func (m *UI) historyReset() {
+	m.promptHistory.index = -1
+	m.promptHistory.draft = ""
+}
+
+// isAtEditorStart returns true if we are at the 0 line and 0 col in the textarea.
+func (m *UI) isAtEditorStart() bool {
+	return m.textarea.Line() == 0 && m.textarea.LineInfo().ColumnOffset == 0
+}
+
+// isAtEditorEnd returns true if we are in the last line and the last column in the textarea.
+func (m *UI) isAtEditorEnd() bool {
+	lineCount := m.textarea.LineCount()
+	if lineCount == 0 {
+		return true
+	}
+	if m.textarea.Line() != lineCount-1 {
+		return false
+	}
+	info := m.textarea.LineInfo()
+	return info.CharOffset >= info.CharWidth-1 || info.CharWidth == 0
+}

internal/ui/model/keys.go ๐Ÿ”—

@@ -10,11 +10,16 @@ type KeyMap struct {
 		Newline     key.Binding
 		AddImage    key.Binding
 		MentionFile key.Binding
+		Commands    key.Binding
 
 		// Attachments key maps
 		AttachmentDeleteMode key.Binding
 		Escape               key.Binding
 		DeleteAllAttachments key.Binding
+
+		// History navigation
+		HistoryPrev key.Binding
+		HistoryNext key.Binding
 	}
 
 	Chat struct {
@@ -119,6 +124,10 @@ func DefaultKeyMap() KeyMap {
 		key.WithKeys("@"),
 		key.WithHelp("@", "mention file"),
 	)
+	km.Editor.Commands = key.NewBinding(
+		key.WithKeys("/"),
+		key.WithHelp("/", "commands"),
+	)
 	km.Editor.AttachmentDeleteMode = key.NewBinding(
 		key.WithKeys("ctrl+r"),
 		key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
@@ -131,6 +140,12 @@ func DefaultKeyMap() KeyMap {
 		key.WithKeys("r"),
 		key.WithHelp("ctrl+r+r", "delete all attachments"),
 	)
+	km.Editor.HistoryPrev = key.NewBinding(
+		key.WithKeys("up"),
+	)
+	km.Editor.HistoryNext = key.NewBinding(
+		key.WithKeys("down"),
+	)
 
 	km.Chat.NewSession = key.NewBinding(
 		key.WithKeys("ctrl+n"),

internal/ui/model/lsp.go ๐Ÿ”—

@@ -2,6 +2,8 @@ package model
 
 import (
 	"fmt"
+	"maps"
+	"slices"
 	"strings"
 
 	"charm.land/lipgloss/v2"
@@ -21,16 +23,14 @@ type LSPInfo struct {
 // lspInfo renders the LSP status section showing active LSP clients and their
 // diagnostic counts.
 func (m *UI) lspInfo(width, maxItems int, isSection bool) string {
-	var lsps []LSPInfo
 	t := m.com.Styles
-	lspConfigs := m.com.Config().LSP.Sorted()
 
-	for _, cfg := range lspConfigs {
-		state, ok := m.lspStates[cfg.Name]
-		if !ok {
-			continue
-		}
+	states := slices.SortedFunc(maps.Values(m.lspStates), func(a, b app.LSPClientInfo) int {
+		return strings.Compare(a.Name, b.Name)
+	})
 
+	var lsps []LSPInfo
+	for _, state := range states {
 		client, ok := m.com.App.LSPClients.Get(state.Name)
 		if !ok {
 			continue

internal/ui/model/onboarding.go ๐Ÿ”—

@@ -48,9 +48,11 @@ func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 // initializeProject starts project initialization and transitions to the landing view.
 func (m *UI) initializeProject() tea.Cmd {
 	// clear the session
-	m.newSession()
-	cfg := m.com.Config()
 	var cmds []tea.Cmd
+	if cmd := m.newSession(); cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+	cfg := m.com.Config()
 
 	initialize := func() tea.Msg {
 		initPrompt, err := agent.InitializePrompt(*cfg)
@@ -68,8 +70,7 @@ func (m *UI) initializeProject() tea.Cmd {
 // skipInitializeProject skips project initialization and transitions to the landing view.
 func (m *UI) skipInitializeProject() tea.Cmd {
 	// TODO: initialize the project
-	m.state = uiLanding
-	m.focus = uiFocusEditor
+	m.setState(uiLanding, uiFocusEditor)
 	// mark the project as initialized
 	return m.markProjectInitialized
 }

internal/ui/model/pills.go ๐Ÿ”—

@@ -66,7 +66,8 @@ func queuePill(queue int, focused, panelFocused bool, t *styles.Styles) string {
 		triangles = triangles[:queue]
 	}
 
-	content := fmt.Sprintf("%s %d Queued", strings.Join(triangles, ""), queue)
+	text := t.Base.Render(fmt.Sprintf("%d Queued", queue))
+	content := fmt.Sprintf("%s %s", strings.Join(triangles, ""), text)
 	return pillStyle(focused, panelFocused, t).Render(content)
 }
 

internal/ui/model/sidebar.go ๐Ÿ”—

@@ -5,7 +5,6 @@ import (
 	"fmt"
 
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/logo"
 	uv "github.com/charmbracelet/ultraviolet"
@@ -28,14 +27,13 @@ func (m *UI) modelInfo(width int) string {
 
 			// Only check reasoning if model can reason
 			if model.CatwalkCfg.CanReason {
-				switch providerConfig.Type {
-				case catwalk.TypeAnthropic:
+				if model.ModelCfg.ReasoningEffort == "" {
 					if model.ModelCfg.Think {
 						reasoningInfo = "Thinking On"
 					} else {
 						reasoningInfo = "Thinking Off"
 					}
-				default:
+				} else {
 					formatter := cases.Title(language.English, cases.NoLower)
 					reasoningEffort := cmp.Or(model.ModelCfg.ReasoningEffort, model.CatwalkCfg.DefaultReasoningEffort)
 					reasoningInfo = formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))

internal/ui/model/ui.go ๐Ÿ”—

@@ -22,13 +22,13 @@ import (
 	"charm.land/bubbles/v2/spinner"
 	"charm.land/bubbles/v2/textarea"
 	tea "charm.land/bubbletea/v2"
+	"charm.land/catwalk/pkg/catwalk"
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/commands"
 	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/filetracker"
+	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/message"
@@ -41,7 +41,6 @@ import (
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/completions"
 	"github.com/charmbracelet/crush/internal/ui/dialog"
-	timage "github.com/charmbracelet/crush/internal/ui/image"
 	"github.com/charmbracelet/crush/internal/ui/logo"
 	"github.com/charmbracelet/crush/internal/ui/styles"
 	"github.com/charmbracelet/crush/internal/uiutil"
@@ -118,6 +117,9 @@ type UI struct {
 	session      *session.Session
 	sessionFiles []SessionFile
 
+	// keeps track of read files while we don't have a session id
+	sessionFileReads []string
+
 	lastUserMessageTime int64
 
 	// The width and height of the terminal in cells.
@@ -125,6 +127,8 @@ type UI struct {
 	height int
 	layout layout
 
+	isTransparent bool
+
 	focus uiFocusState
 	state uiState
 
@@ -142,11 +146,11 @@ type UI struct {
 
 	// sendProgressBar instructs the TUI to send progress bar updates to the
 	// terminal.
-	sendProgressBar bool
+	sendProgressBar    bool
+	progressBarEnabled bool
 
-	// QueryCapabilities instructs the TUI to query for the terminal version when it
-	// starts.
-	QueryCapabilities bool
+	// caps hold different terminal capabilities that we query for.
+	caps common.Capabilities
 
 	// Editor components
 	textarea textarea.Model
@@ -181,9 +185,6 @@ type UI struct {
 	// sidebarLogo keeps a cached version of the sidebar sidebarLogo.
 	sidebarLogo string
 
-	// imgCaps stores the terminal image capabilities.
-	imgCaps timage.Capabilities
-
 	// custom commands & mcp commands
 	customCommands []commands.CustomCommand
 	mcpPrompts     []commands.MCPPrompt
@@ -210,6 +211,13 @@ type UI struct {
 
 	// mouse highlighting related state
 	lastClickTime time.Time
+
+	// Prompt history for up/down navigation through previous messages.
+	promptHistory struct {
+		messages []string
+		index    int
+		draft    string
+	}
 }
 
 // New creates a new instance of the [UI] model.
@@ -257,8 +265,6 @@ func New(com *common.Common) *UI {
 		com:         com,
 		dialog:      dialog.NewOverlay(),
 		keyMap:      keyMap,
-		focus:       uiFocusNone,
-		state:       uiOnboarding,
 		textarea:    ta,
 		chat:        ch,
 		completions: comp,
@@ -270,25 +276,34 @@ func New(com *common.Common) *UI {
 
 	status := NewStatus(com, ui)
 
+	ui.setEditorPrompt(false)
+	ui.randomizePlaceholders()
+	ui.textarea.Placeholder = ui.readyPlaceholder
+	ui.status = status
+
+	// Initialize compact mode from config
+	ui.forceCompactMode = com.Config().Options.TUI.CompactMode
+
 	// set onboarding state defaults
 	ui.onboarding.yesInitializeSelected = true
 
+	desiredState := uiLanding
+	desiredFocus := uiFocusEditor
 	if !com.Config().IsConfigured() {
-		ui.state = uiOnboarding
+		desiredState = uiOnboarding
 	} else if n, _ := config.ProjectNeedsInitialization(); n {
-		ui.state = uiInitialize
-	} else {
-		ui.state = uiLanding
-		ui.focus = uiFocusEditor
+		desiredState = uiInitialize
 	}
 
-	ui.setEditorPrompt(false)
-	ui.randomizePlaceholders()
-	ui.textarea.Placeholder = ui.readyPlaceholder
-	ui.status = status
+	// set initial state
+	ui.setState(desiredState, desiredFocus)
 
-	// Initialize compact mode from config
-	ui.forceCompactMode = com.Config().Options.TUI.CompactMode
+	opts := com.Config().Options
+
+	// disable indeterminate progress bar
+	ui.progressBarEnabled = opts.Progress == nil || *opts.Progress
+	// enable transparent mode
+	ui.isTransparent = opts.TUI.Transparent != nil && *opts.TUI.Transparent
 
 	return ui
 }
@@ -296,9 +311,6 @@ func New(com *common.Common) *UI {
 // Init initializes the UI model.
 func (m *UI) Init() tea.Cmd {
 	var cmds []tea.Cmd
-	if m.QueryCapabilities {
-		cmds = append(cmds, tea.RequestTerminalVersion)
-	}
 	if m.state == uiOnboarding {
 		if cmd := m.openModelsDialog(); cmd != nil {
 			cmds = append(cmds, cmd)
@@ -306,15 +318,25 @@ func (m *UI) Init() tea.Cmd {
 	}
 	// load the user commands async
 	cmds = append(cmds, m.loadCustomCommands())
+	// load prompt history async
+	cmds = append(cmds, m.loadPromptHistory())
 	return tea.Batch(cmds...)
 }
 
+// setState changes the UI state and focus.
+func (m *UI) setState(state uiState, focus uiFocusState) {
+	m.state = state
+	m.focus = focus
+	// Changing the state may change layout, so update it.
+	m.updateLayoutAndSize()
+}
+
 // loadCustomCommands loads the custom commands asynchronously.
 func (m *UI) loadCustomCommands() tea.Cmd {
 	return func() tea.Msg {
 		customCommands, err := commands.LoadCustomCommands(m.com.Config())
 		if err != nil {
-			slog.Error("failed to load custom commands", "error", err)
+			slog.Error("Failed to load custom commands", "error", err)
 		}
 		return userCommandsLoadedMsg{Commands: customCommands}
 	}
@@ -325,7 +347,7 @@ func (m *UI) loadMCPrompts() tea.Cmd {
 	return func() tea.Msg {
 		prompts, err := commands.LoadMCPPrompts()
 		if err != nil {
-			slog.Error("failed to load mcp prompts", "error", err)
+			slog.Error("Failed to load MCP prompts", "error", err)
 		}
 		if prompts == nil {
 			// flag them as loaded even if there is none or an error
@@ -345,24 +367,20 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.updateLayoutAndSize()
 		}
 	}
+	// Update terminal capabilities
+	m.caps.Update(msg)
 	switch msg := msg.(type) {
 	case tea.EnvMsg:
 		// Is this Windows Terminal?
 		if !m.sendProgressBar {
 			m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
 		}
-		m.imgCaps.Env = uv.Environ(msg)
-		// Only query for image capabilities if the terminal is known to
-		// support Kitty graphics protocol. This prevents character bleeding
-		// on terminals that don't understand the APC escape sequences.
-		if m.QueryCapabilities {
-			cmds = append(cmds, timage.RequestCapabilities(m.imgCaps.Env))
-		}
+		cmds = append(cmds, common.QueryCmd(uv.Environ(msg)))
 	case loadSessionMsg:
-		m.state = uiChat
 		if m.forceCompactMode {
 			m.isCompact = true
 		}
+		m.setState(uiChat, m.focus)
 		m.session = msg.session
 		m.sessionFiles = msg.files
 		msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
@@ -381,6 +399,10 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 			m.updateLayoutAndSize()
 		}
+		// Reload prompt history for the new session.
+		m.historyReset()
+		cmds = append(cmds, m.loadPromptHistory())
+		m.updateLayoutAndSize()
 
 	case sendMessageMsg:
 		cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
@@ -408,13 +430,20 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			commands.SetMCPPrompts(m.mcpPrompts)
 		}
 
+	case promptHistoryLoadedMsg:
+		m.promptHistory.messages = msg.messages
+		m.promptHistory.index = -1
+		m.promptHistory.draft = ""
+
 	case closeDialogMsg:
 		m.dialog.CloseFrontDialog()
 
 	case pubsub.Event[session.Session]:
 		if msg.Type == pubsub.DeletedEvent {
 			if m.session != nil && m.session.ID == msg.Payload.ID {
-				m.newSession()
+				if cmd := m.newSession(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 			}
 			break
 		}
@@ -492,10 +521,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, nil
 	case tea.WindowSizeMsg:
 		m.width, m.height = msg.Width, msg.Height
-		m.handleCompactMode(m.width, m.height)
 		m.updateLayoutAndSize()
-		// XXX: We need to store cell dimensions for image rendering.
-		m.imgCaps.Columns, m.imgCaps.Rows = msg.Width, msg.Height
 	case tea.KeyboardEnhancementsMsg:
 		m.keyenh = msg
 		if msg.SupportsKeyDisambiguation() {
@@ -504,20 +530,33 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 	case copyChatHighlightMsg:
 		cmds = append(cmds, m.copyChatHighlight())
+	case DelayedClickMsg:
+		// Handle delayed single-click action (e.g., expansion).
+		m.chat.HandleDelayedClick(msg)
 	case tea.MouseClickMsg:
 		// Pass mouse events to dialogs first if any are open.
 		if m.dialog.HasDialogs() {
 			m.dialog.Update(msg)
 			return m, tea.Batch(cmds...)
 		}
+
+		if cmd := m.handleClickFocus(msg); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+
 		switch m.state {
 		case uiChat:
 			x, y := msg.X, msg.Y
 			// Adjust for chat area position
 			x -= m.layout.main.Min.X
 			y -= m.layout.main.Min.Y
-			if m.chat.HandleMouseDown(x, y) {
-				m.lastClickTime = time.Now()
+			if !image.Pt(msg.X, msg.Y).In(m.layout.sidebar) {
+				if handled, cmd := m.chat.HandleMouseDown(x, y); handled {
+					m.lastClickTime = time.Now()
+					if cmd != nil {
+						cmds = append(cmds, cmd)
+					}
+				}
 			}
 		}
 
@@ -565,7 +604,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.dialog.Update(msg)
 			return m, tea.Batch(cmds...)
 		}
-		const doubleClickThreshold = 500 * time.Millisecond
 
 		switch m.state {
 		case uiChat:
@@ -662,18 +700,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if m.completionsOpen {
 			m.completions.SetFiles(msg.Files)
 		}
-	case uv.WindowPixelSizeEvent:
-		// [timage.RequestCapabilities] requests the terminal to send a window
-		// size event to help determine pixel dimensions.
-		m.imgCaps.PixelWidth = msg.Width
-		m.imgCaps.PixelHeight = msg.Height
 	case uv.KittyGraphicsEvent:
-		// [timage.RequestCapabilities] sends a Kitty graphics query and this
-		// captures the response. Any response means the terminal understands
-		// the protocol.
-		m.imgCaps.SupportsKittyGraphics = true
 		if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
-			slog.Warn("unexpected Kitty graphics response",
+			slog.Warn("Unexpected Kitty graphics response",
 				"response", string(msg.Payload),
 				"options", msg.Options)
 		}
@@ -820,11 +849,14 @@ func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
 // if the message is a tool result it will update the corresponding tool call message
 func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 	var cmds []tea.Cmd
+	atBottom := m.chat.list.AtBottom()
+
 	existing := m.chat.MessageItem(msg.ID)
 	if existing != nil {
 		// message already exists, skip
 		return nil
 	}
+
 	switch msg.Role {
 	case message.User:
 		m.lastUserMessageTime = msg.CreatedAt
@@ -850,14 +882,18 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 			}
 		}
 		m.chat.AppendMessages(items...)
-		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
-			cmds = append(cmds, cmd)
+		if atBottom {
+			if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
 		}
 		if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
 			infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0))
 			m.chat.AppendMessages(infoItem)
-			if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
-				cmds = append(cmds, cmd)
+			if atBottom {
+				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 			}
 		}
 	case message.Tool:
@@ -869,12 +905,35 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 			}
 			if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
 				toolMsgItem.SetResult(&tr)
+				if atBottom {
+					if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
+				}
 			}
 		}
 	}
 	return tea.Batch(cmds...)
 }
 
+func (m *UI) handleClickFocus(msg tea.MouseClickMsg) (cmd tea.Cmd) {
+	switch {
+	case m.state != uiChat:
+		return nil
+	case image.Pt(msg.X, msg.Y).In(m.layout.sidebar):
+		return nil
+	case m.focus != uiFocusEditor && image.Pt(msg.X, msg.Y).In(m.layout.editor):
+		m.focus = uiFocusEditor
+		cmd = m.textarea.Focus()
+		m.chat.Blur()
+	case m.focus != uiFocusMain && image.Pt(msg.X, msg.Y).In(m.layout.main):
+		m.focus = uiFocusMain
+		m.textarea.Blur()
+		m.chat.Focus()
+	}
+	return cmd
+}
+
 // updateSessionMessage updates an existing message in the current session in the chat
 // when an assistant message is updated it may include updated tool calls as well
 // that is why we need to handle creating/updating each tool call message too
@@ -1087,7 +1146,9 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
 			break
 		}
-		m.newSession()
+		if cmd := m.newSession(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
 		m.dialog.CloseDialog(dialog.CommandsID)
 	case dialog.ActionSummarize:
 		if m.isAgentBusy() {
@@ -1116,11 +1177,6 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		cmds = append(cmds, m.toggleCompactMode())
 		m.dialog.CloseDialog(dialog.CommandsID)
 	case dialog.ActionToggleThinking:
-		if m.isAgentBusy() {
-			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
-			break
-		}
-
 		cmds = append(cmds, func() tea.Msg {
 			cfg := m.com.Config()
 			if cfg == nil {
@@ -1211,9 +1267,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		m.dialog.CloseDialog(dialog.ModelsID)
 
 		if isOnboarding {
-			m.state = uiLanding
-			m.focus = uiFocusEditor
-
+			m.setState(uiLanding, uiFocusEditor)
 			m.com.Config().SetupAgents()
 			if err := m.com.App.InitCoderAgent(context.TODO()); err != nil {
 				cmds = append(cmds, uiutil.ReportError(err))
@@ -1384,14 +1438,14 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 				return true
 			}
 		case key.Matches(msg, m.keyMap.Chat.PillLeft):
-			if m.state == uiChat && m.hasSession() && m.pillsExpanded {
+			if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
 				if cmd := m.switchPillSection(-1); cmd != nil {
 					cmds = append(cmds, cmd)
 				}
 				return true
 			}
 		case key.Matches(msg, m.keyMap.Chat.PillRight):
-			if m.state == uiChat && m.hasSession() && m.pillsExpanded {
+			if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
 				if cmd := m.switchPillSection(1); cmd != nil {
 					cmds = append(cmds, cmd)
 				}
@@ -1493,8 +1547,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 				}
 
 				m.randomizePlaceholders()
+				m.historyReset()
 
-				return m.sendMessage(value, attachments...)
+				return tea.Batch(m.sendMessage(value, attachments...), m.loadPromptHistory())
 			case key.Matches(msg, m.keyMap.Chat.NewSession):
 				if !m.hasSession() {
 					break
@@ -1503,10 +1558,12 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 					cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
 					break
 				}
-				m.newSession()
+				if cmd := m.newSession(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 			case key.Matches(msg, m.keyMap.Tab):
 				if m.state != uiLanding {
-					m.focus = uiFocusMain
+					m.setState(m.state, uiFocusMain)
 					m.textarea.Blur()
 					m.chat.Focus()
 					m.chat.SetSelected(m.chat.Len() - 1)
@@ -1523,6 +1580,25 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 				ta, cmd := m.textarea.Update(msg)
 				m.textarea = ta
 				cmds = append(cmds, cmd)
+			case key.Matches(msg, m.keyMap.Editor.HistoryPrev):
+				cmd := m.handleHistoryUp(msg)
+				if cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+			case key.Matches(msg, m.keyMap.Editor.HistoryNext):
+				cmd := m.handleHistoryDown(msg)
+				if cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+			case key.Matches(msg, m.keyMap.Editor.Escape):
+				cmd := m.handleHistoryEscape(msg)
+				if cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+			case key.Matches(msg, m.keyMap.Editor.Commands) && m.textarea.Value() == "":
+				if cmd := m.openCommandsDialog(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 			default:
 				if handleGlobalKeys(msg) {
 					// Handle global keys first before passing to textarea.
@@ -1556,6 +1632,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 				m.textarea = ta
 				cmds = append(cmds, cmd)
 
+				// Any text modification becomes the current draft.
+				m.updateHistoryDraft(curValue)
+
 				// After updating textarea, check if we need to filter completions.
 				// Skip filtering on the initial @ keystroke since items are loading async.
 				if m.completionsOpen && msg.String() != "@" {
@@ -1595,7 +1674,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 					break
 				}
 				m.focus = uiFocusEditor
-				m.newSession()
+				if cmd := m.newSession(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 			case key.Matches(msg, m.keyMap.Chat.Expand):
 				m.chat.ToggleExpandedSelectedItem()
 			case key.Matches(msg, m.keyMap.Chat.Up):
@@ -1809,7 +1890,9 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 func (m *UI) View() tea.View {
 	var v tea.View
 	v.AltScreen = true
-	v.BackgroundColor = m.com.Styles.Background
+	if !m.isTransparent {
+		v.BackgroundColor = m.com.Styles.Background
+	}
 	v.MouseMode = tea.MouseModeCellMotion
 	v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir())
 
@@ -1826,7 +1909,7 @@ func (m *UI) View() tea.View {
 	content = strings.Join(contentLines, "\n")
 
 	v.Content = content
-	if m.sendProgressBar && m.isAgentBusy() {
+	if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
 		// HACK: use a random percentage to prevent ghostty from hiding it
 		// after a timeout.
 		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
@@ -1841,7 +1924,7 @@ func (m *UI) ShortHelp() []key.Binding {
 	k := &m.keyMap
 	tab := k.Tab
 	commands := k.Commands
-	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
+	if m.focus == uiFocusEditor && m.textarea.Value() == "" {
 		commands.SetHelp("/ or ctrl+p", "commands")
 	}
 
@@ -1917,7 +2000,7 @@ func (m *UI) FullHelp() [][]key.Binding {
 	hasAttachments := len(m.attachments.List()) > 0
 	hasSession := m.hasSession()
 	commands := k.Commands
-	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
+	if m.focus == uiFocusEditor && m.textarea.Value() == "" {
 		commands.SetHelp("/ or ctrl+p", "commands")
 	}
 
@@ -2053,29 +2136,26 @@ func (m *UI) toggleCompactMode() tea.Cmd {
 		return uiutil.ReportError(err)
 	}
 
-	m.handleCompactMode(m.width, m.height)
 	m.updateLayoutAndSize()
 
 	return nil
 }
 
-// handleCompactMode updates the UI state based on window size and compact mode setting.
-func (m *UI) handleCompactMode(newWidth, newHeight int) {
+// updateLayoutAndSize updates the layout and sizes of UI components.
+func (m *UI) updateLayoutAndSize() {
+	// Determine if we should be in compact mode
 	if m.state == uiChat {
 		if m.forceCompactMode {
 			m.isCompact = true
 			return
 		}
-		if newWidth < compactModeWidthBreakpoint || newHeight < compactModeHeightBreakpoint {
+		if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
 			m.isCompact = true
 		} else {
 			m.isCompact = false
 		}
 	}
-}
 
-// updateLayoutAndSize updates the layout and sizes of UI components.
-func (m *UI) updateLayoutAndSize() {
 	m.layout = m.generateLayout(m.width, m.height)
 	m.updateSize()
 }
@@ -2120,7 +2200,7 @@ func (m *UI) generateLayout(w, h int) layout {
 	const landingHeaderHeight = 4
 
 	var helpKeyMap help.KeyMap = m
-	if m.status.ShowingAll() {
+	if m.status != nil && m.status.ShowingAll() {
 		for _, row := range helpKeyMap.FullHelp() {
 			helpHeight = max(helpHeight, len(row))
 		}
@@ -2246,7 +2326,9 @@ func (m *UI) generateLayout(w, h int) layout {
 
 	if !layout.editor.Empty() {
 		// Add editor margins 1 top and bottom
-		layout.editor.Min.Y += 1
+		if len(m.attachments.List()) == 0 {
+			layout.editor.Min.Y += 1
+		}
 		layout.editor.Max.Y -= 1
 	}
 
@@ -2393,21 +2475,27 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd {
 
 	return func() tea.Msg {
 		absPath, _ := filepath.Abs(path)
-		// Skip attachment if file was already read and hasn't been modified.
-		lastRead := filetracker.LastReadTime(absPath)
-		if !lastRead.IsZero() {
-			if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
-				return nil
+
+		if m.hasSession() {
+			// Skip attachment if file was already read and hasn't been modified.
+			lastRead := m.com.App.FileTracker.LastReadTime(context.Background(), m.session.ID, absPath)
+			if !lastRead.IsZero() {
+				if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
+					return nil
+				}
 			}
+		} else if slices.Contains(m.sessionFileReads, absPath) {
+			return nil
 		}
 
+		m.sessionFileReads = append(m.sessionFileReads, absPath)
+
 		// Add file as attachment.
 		content, err := os.ReadFile(path)
 		if err != nil {
 			// If it fails, let the LLM handle it later.
 			return nil
 		}
-		filetracker.RecordRead(absPath)
 
 		return message.Attachment{
 			FilePath: path,
@@ -2524,7 +2612,6 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
 		if err != nil {
 			return uiutil.ReportError(err)
 		}
-		m.state = uiChat
 		if m.forceCompactMode {
 			m.isCompact = true
 		}
@@ -2532,6 +2619,11 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
 			m.session = &newSession
 			cmds = append(cmds, m.loadSession(newSession.ID))
 		}
+		m.setState(uiChat, m.focus)
+	}
+
+	for _, path := range m.sessionFileReads {
+		m.com.App.FileTracker.RecordRead(context.Background(), m.session.ID, path)
 	}
 
 	// Capture session ID to avoid race with main goroutine updating m.session.
@@ -2732,7 +2824,7 @@ func (m *UI) openFilesDialog() tea.Cmd {
 	}
 
 	filePicker, cmd := dialog.NewFilePicker(m.com)
-	filePicker.SetImageCapabilities(&m.imgCaps)
+	filePicker.SetImageCapabilities(&m.caps)
 	m.dialog.OpenDialog(filePicker)
 
 	return cmd
@@ -2772,21 +2864,24 @@ func (m *UI) handlePermissionNotification(notification permission.PermissionNoti
 
 // newSession clears the current session state and prepares for a new session.
 // The actual session creation happens when the user sends their first message.
-func (m *UI) newSession() {
+// Returns a command to reload prompt history.
+func (m *UI) newSession() tea.Cmd {
 	if !m.hasSession() {
-		return
+		return nil
 	}
 
 	m.session = nil
 	m.sessionFiles = nil
-	m.state = uiLanding
-	m.focus = uiFocusEditor
+	m.sessionFileReads = nil
+	m.setState(uiLanding, uiFocusEditor)
 	m.textarea.Focus()
 	m.chat.Blur()
 	m.chat.ClearMessages()
 	m.pillsExpanded = false
 	m.promptQueue = 0
 	m.pillsView = ""
+	m.historyReset()
+	return m.loadPromptHistory()
 }
 
 // handlePasteMsg handles a paste message.
@@ -2817,34 +2912,53 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
 		}
 	}
 
-	var cmd tea.Cmd
-	path := strings.ReplaceAll(msg.Content, "\\ ", " ")
-	// Try to get an image.
-	path, err := filepath.Abs(strings.TrimSpace(path))
-	if err != nil {
-		m.textarea, cmd = m.textarea.Update(msg)
-		return cmd
-	}
+	// Attempt to parse pasted content as file paths. If possible to parse,
+	// all files exist and are valid, add as attachments.
+	// Otherwise, paste as text.
+	paths := fsext.ParsePastedFiles(msg.Content)
+	allExistsAndValid := func() bool {
+		for _, path := range paths {
+			if _, err := os.Stat(path); os.IsNotExist(err) {
+				return false
+			}
 
-	// Check if file has an allowed image extension.
-	isAllowedType := false
-	lowerPath := strings.ToLower(path)
-	for _, ext := range common.AllowedImageTypes {
-		if strings.HasSuffix(lowerPath, ext) {
-			isAllowedType = true
-			break
+			lowerPath := strings.ToLower(path)
+			isValid := false
+			for _, ext := range common.AllowedImageTypes {
+				if strings.HasSuffix(lowerPath, ext) {
+					isValid = true
+					break
+				}
+			}
+			if !isValid {
+				return false
+			}
 		}
+		return true
 	}
-	if !isAllowedType {
+	if !allExistsAndValid() {
+		var cmd tea.Cmd
 		m.textarea, cmd = m.textarea.Update(msg)
 		return cmd
 	}
 
+	var cmds []tea.Cmd
+	for _, path := range paths {
+		cmds = append(cmds, m.handleFilePathPaste(path))
+	}
+	return tea.Batch(cmds...)
+}
+
+// handleFilePathPaste handles a pasted file path.
+func (m *UI) handleFilePathPaste(path string) tea.Cmd {
 	return func() tea.Msg {
 		fileInfo, err := os.Stat(path)
 		if err != nil {
 			return uiutil.ReportError(err)
 		}
+		if fileInfo.IsDir() {
+			return uiutil.ReportWarn("Cannot attach a directory")
+		}
 		if fileInfo.Size() > common.MaxAttachmentSize {
 			return uiutil.ReportWarn("File is too big (>5mb)")
 		}

internal/ui/styles/styles.go ๐Ÿ”—

@@ -2,6 +2,7 @@ package styles
 
 import (
 	"image/color"
+	"strings"
 
 	"charm.land/bubbles/v2/filepicker"
 	"charm.land/bubbles/v2/help"
@@ -379,13 +380,14 @@ type Styles struct {
 			DeletingTitleGradientToColor   color.Color
 
 			// styles for when we are in update mode
-			UpdatingView                   lipgloss.Style
-			UpdatingItemFocused            lipgloss.Style
-			UpdatingItemBlurred            lipgloss.Style
-			UpdatingTitle                  lipgloss.Style
-			UpdatingMessage                lipgloss.Style
-			UpdatingTitleGradientFromColor color.Color
-			UpdatingTitleGradientToColor   color.Color
+			RenamingView                   lipgloss.Style
+			RenamingingItemFocused         lipgloss.Style
+			RenamingItemBlurred            lipgloss.Style
+			RenamingingTitle               lipgloss.Style
+			RenamingingMessage             lipgloss.Style
+			RenamingTitleGradientFromColor color.Color
+			RenamingTitleGradientToColor   color.Color
+			RenamingPlaceholder            lipgloss.Style
 		}
 	}
 
@@ -1113,7 +1115,7 @@ func DefaultStyles() Styles {
 	// Content rendering - prepared styles that accept width parameter
 	s.Tool.ContentLine = s.Muted.Background(bgBaseLighter)
 	s.Tool.ContentTruncation = s.Muted.Background(bgBaseLighter)
-	s.Tool.ContentCodeLine = s.Base.Background(bgBase)
+	s.Tool.ContentCodeLine = s.Base.Background(bgBase).PaddingLeft(2)
 	s.Tool.ContentCodeTruncation = s.Muted.Background(bgBase).PaddingLeft(2)
 	s.Tool.ContentCodeBg = bgBase
 	s.Tool.Body = base.PaddingLeft(2)
@@ -1295,15 +1297,16 @@ func DefaultStyles() Styles {
 	s.Dialog.Sessions.DeletingTitleGradientFromColor = red
 	s.Dialog.Sessions.DeletingTitleGradientToColor = s.Primary
 	s.Dialog.Sessions.DeletingItemBlurred = s.Dialog.NormalItem.Foreground(fgSubtle)
-	s.Dialog.Sessions.DeletingItemFocused = s.Dialog.SelectedItem.Background(red)
+	s.Dialog.Sessions.DeletingItemFocused = s.Dialog.SelectedItem.Background(red).Foreground(charmtone.Butter)
 
-	s.Dialog.Sessions.UpdatingTitle = s.Dialog.Title.Foreground(charmtone.Zest)
-	s.Dialog.Sessions.UpdatingView = s.Dialog.View.BorderForeground(charmtone.Zest)
-	s.Dialog.Sessions.UpdatingMessage = s.Base.Padding(1)
-	s.Dialog.Sessions.UpdatingTitleGradientFromColor = charmtone.Zest
-	s.Dialog.Sessions.UpdatingTitleGradientToColor = charmtone.Bok
-	s.Dialog.Sessions.UpdatingItemBlurred = s.Dialog.NormalItem.Foreground(fgSubtle)
-	s.Dialog.Sessions.UpdatingItemFocused = s.Dialog.SelectedItem.UnsetBackground().UnsetForeground()
+	s.Dialog.Sessions.RenamingingTitle = s.Dialog.Title.Foreground(charmtone.Zest)
+	s.Dialog.Sessions.RenamingView = s.Dialog.View.BorderForeground(charmtone.Zest)
+	s.Dialog.Sessions.RenamingingMessage = s.Base.Padding(1)
+	s.Dialog.Sessions.RenamingTitleGradientFromColor = charmtone.Zest
+	s.Dialog.Sessions.RenamingTitleGradientToColor = charmtone.Bok
+	s.Dialog.Sessions.RenamingItemBlurred = s.Dialog.NormalItem.Foreground(fgSubtle)
+	s.Dialog.Sessions.RenamingingItemFocused = s.Dialog.SelectedItem.UnsetBackground().UnsetForeground()
+	s.Dialog.Sessions.RenamingPlaceholder = base.Foreground(charmtone.Squid)
 
 	s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
 	s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")
@@ -1347,35 +1350,36 @@ func boolPtr(b bool) *bool       { return &b }
 func stringPtr(s string) *string { return &s }
 func uintPtr(u uint) *uint       { return &u }
 func chromaStyle(style ansi.StylePrimitive) string {
-	var s string
+	var s strings.Builder
 
 	if style.Color != nil {
-		s = *style.Color
+		s.WriteString(*style.Color)
 	}
 	if style.BackgroundColor != nil {
-		if s != "" {
-			s += " "
+		if s.Len() > 0 {
+			s.WriteString(" ")
 		}
-		s += "bg:" + *style.BackgroundColor
+		s.WriteString("bg:")
+		s.WriteString(*style.BackgroundColor)
 	}
 	if style.Italic != nil && *style.Italic {
-		if s != "" {
-			s += " "
+		if s.Len() > 0 {
+			s.WriteString(" ")
 		}
-		s += "italic"
+		s.WriteString("italic")
 	}
 	if style.Bold != nil && *style.Bold {
-		if s != "" {
-			s += " "
+		if s.Len() > 0 {
+			s.WriteString(" ")
 		}
-		s += "bold"
+		s.WriteString("bold")
 	}
 	if style.Underline != nil && *style.Underline {
-		if s != "" {
-			s += " "
+		if s.Len() > 0 {
+			s.WriteString(" ")
 		}
-		s += "underline"
+		s.WriteString("underline")
 	}
 
-	return s
+	return s.String()
 }

schema.json ๐Ÿ”—

@@ -92,10 +92,7 @@
         }
       },
       "additionalProperties": false,
-      "type": "object",
-      "required": [
-        "tools"
-      ]
+      "type": "object"
     },
     "LSPConfig": {
       "properties": {
@@ -159,13 +156,19 @@
         "options": {
           "type": "object",
           "description": "LSP server-specific settings passed during initialization"
+        },
+        "timeout": {
+          "type": "integer",
+          "description": "Timeout in seconds for LSP server initialization",
+          "default": 30,
+          "examples": [
+            60,
+            120
+          ]
         }
       },
       "additionalProperties": false,
-      "type": "object",
-      "required": [
-        "command"
-      ]
+      "type": "object"
     },
     "LSPs": {
       "additionalProperties": {
@@ -435,6 +438,16 @@
             "CLAUDE.md",
             "docs/LLMs.md"
           ]
+        },
+        "auto_lsp": {
+          "type": "boolean",
+          "description": "Automatically setup LSPs based on root markers",
+          "default": true
+        },
+        "progress": {
+          "type": "boolean",
+          "description": "Show indeterminate progress updates during long operations",
+          "default": true
         }
       },
       "additionalProperties": false,
@@ -637,6 +650,11 @@
         "completions": {
           "$ref": "#/$defs/Completions",
           "description": "Completions UI options"
+        },
+        "transparent": {
+          "type": "boolean",
+          "description": "Enable transparent background for the TUI interface",
+          "default": false
         }
       },
       "additionalProperties": false,
@@ -698,10 +716,7 @@
         }
       },
       "additionalProperties": false,
-      "type": "object",
-      "required": [
-        "ls"
-      ]
+      "type": "object"
     }
   }
 }

scripts/check_log_capitalization.sh ๐Ÿ”—

@@ -0,0 +1,5 @@
+#!/bin/bash
+if grep -rE 'slog\.(Error|Info|Warn|Debug|Fatal|Print|Println|Printf)\(["\"][a-z]' --include="*.go" . 2>/dev/null; then
+  echo "โŒ Log messages must start with a capital letter. Found lowercase logs above."
+  exit 1
+fi