Browse Source

Merge remote-tracking branch 'origin/main' into edit-tool-fixes

Kujtim Hoxha 3 weeks ago
parent
commit
a232eeecf4
100 changed files with 19195 additions and 72 deletions
  1. 32 0
      .github/cla-signatures.json
  2. 1 1
      .github/workflows/build.yml
  3. 1 1
      .github/workflows/schema-update.yml
  4. 6 6
      .github/workflows/security.yml
  5. 33 0
      .github/workflows/snapshot.yml
  6. 1 2
      .gitignore
  7. 3 0
      AGENTS.md
  8. 4 3
      README.md
  9. 4 0
      Taskfile.yaml
  10. 8 4
      go.mod
  11. 13 4
      go.sum
  12. 5 0
      internal/agent/coordinator.go
  13. 105 2
      internal/app/app.go
  14. 95 0
      internal/app/provider.go
  15. 210 0
      internal/app/provider_test.go
  16. 110 0
      internal/cmd/models.go
  17. 15 3
      internal/cmd/root.go
  18. 5 1
      internal/cmd/run.go
  19. 237 0
      internal/commands/commands.go
  20. 7 1
      internal/config/config.go
  21. 1 12
      internal/env/env.go
  22. 4 2
      internal/env/env_test.go
  23. 1 1
      internal/lsp/client.go
  24. 1 1
      internal/message/attachment.go
  25. 5 5
      internal/message/message.go
  26. 8 9
      internal/permission/permission.go
  27. 1 3
      internal/pubsub/broker.go
  28. 0 7
      internal/pubsub/events.go
  29. 1 1
      internal/shell/shell.go
  30. 4 2
      internal/tui/components/chat/editor/editor.go
  31. 3 1
      internal/tui/tui.go
  32. 61 0
      internal/ui/AGENTS.md
  33. 445 0
      internal/ui/anim/anim.go
  34. 135 0
      internal/ui/attachments/attachments.go
  35. 302 0
      internal/ui/chat/agent.go
  36. 257 0
      internal/ui/chat/assistant.go
  37. 248 0
      internal/ui/chat/bash.go
  38. 68 0
      internal/ui/chat/diagnostics.go
  39. 192 0
      internal/ui/chat/fetch.go
  40. 340 0
      internal/ui/chat/file.go
  41. 121 0
      internal/ui/chat/mcp.go
  42. 312 0
      internal/ui/chat/messages.go
  43. 63 0
      internal/ui/chat/references.go
  44. 256 0
      internal/ui/chat/search.go
  45. 192 0
      internal/ui/chat/todos.go
  46. 807 0
      internal/ui/chat/tools.go
  47. 94 0
      internal/ui/chat/user.go
  48. 69 0
      internal/ui/common/button.go
  49. 65 0
      internal/ui/common/common.go
  50. 16 0
      internal/ui/common/diff.go
  51. 190 0
      internal/ui/common/elements.go
  52. 57 0
      internal/ui/common/highlight.go
  53. 11 0
      internal/ui/common/interface.go
  54. 26 0
      internal/ui/common/markdown.go
  55. 46 0
      internal/ui/common/scrollbar.go
  56. 279 0
      internal/ui/completions/completions.go
  57. 185 0
      internal/ui/completions/item.go
  58. 74 0
      internal/ui/completions/keys.go
  59. 165 0
      internal/ui/dialog/actions.go
  60. 302 0
      internal/ui/dialog/api_key_input.go
  61. 399 0
      internal/ui/dialog/arguments.go
  62. 481 0
      internal/ui/dialog/commands.go
  63. 70 0
      internal/ui/dialog/commands_item.go
  64. 130 0
      internal/ui/dialog/common.go
  65. 197 0
      internal/ui/dialog/dialog.go
  66. 304 0
      internal/ui/dialog/filepicker.go
  67. 483 0
      internal/ui/dialog/models.go
  68. 124 0
      internal/ui/dialog/models_item.go
  69. 281 0
      internal/ui/dialog/models_list.go
  70. 369 0
      internal/ui/dialog/oauth.go
  71. 72 0
      internal/ui/dialog/oauth_copilot.go
  72. 90 0
      internal/ui/dialog/oauth_hyper.go
  73. 760 0
      internal/ui/dialog/permissions.go
  74. 133 0
      internal/ui/dialog/quit.go
  75. 297 0
      internal/ui/dialog/reasoning.go
  76. 201 0
      internal/ui/dialog/sessions.go
  77. 187 0
      internal/ui/dialog/sessions_item.go
  78. 331 0
      internal/ui/image/image.go
  79. 125 0
      internal/ui/list/filterable.go
  80. 13 0
      internal/ui/list/focus.go
  81. 208 0
      internal/ui/list/highlight.go
  82. 61 0
      internal/ui/list/item.go
  83. 660 0
      internal/ui/list/list.go
  84. 346 0
      internal/ui/logo/logo.go
  85. 24 0
      internal/ui/logo/rand.go
  86. 600 0
      internal/ui/model/chat.go
  87. 112 0
      internal/ui/model/header.go
  88. 246 0
      internal/ui/model/keys.go
  89. 50 0
      internal/ui/model/landing.go
  90. 125 0
      internal/ui/model/lsp.go
  91. 98 0
      internal/ui/model/mcp.go
  92. 114 0
      internal/ui/model/onboarding.go
  93. 283 0
      internal/ui/model/pills.go
  94. 244 0
      internal/ui/model/session.go
  95. 163 0
      internal/ui/model/sidebar.go
  96. 106 0
      internal/ui/model/status.go
  97. 2944 0
      internal/ui/model/ui.go
  98. 117 0
      internal/ui/styles/grad.go
  99. 1344 0
      internal/ui/styles/styles.go
  100. 1 0
      internal/uicmd/uicmd.go

+ 32 - 0
.github/cla-signatures.json

@@ -1071,6 +1071,38 @@
       "created_at": "2026-01-14T14:02:04Z",
       "repoId": 987670088,
       "pullRequestNo": 1870
+    },
+    {
+      "name": "huaiyuWangh",
+      "id": 34158348,
+      "comment_id": 3785195950,
+      "created_at": "2026-01-22T15:58:33Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1943
+    },
+    {
+      "name": "akitaonrails",
+      "id": 2840,
+      "comment_id": 3786408984,
+      "created_at": "2026-01-22T19:57:59Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1945
+    },
+    {
+      "name": "mcowger",
+      "id": 1929548,
+      "comment_id": 3787591535,
+      "created_at": "2026-01-23T00:44:49Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1950
+    },
+    {
+      "name": "jerilynzheng",
+      "id": 15837981,
+      "comment_id": 3788071777,
+      "created_at": "2026-01-23T04:00:52Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1951
     }
   ]
 }

+ 1 - 1
.github/workflows/build.yml

@@ -18,7 +18,7 @@ jobs:
       - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
         with:
           persist-credentials: false
-      - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
+      - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
         with:
           go-version-file: go.mod
       - run: go mod tidy

+ 1 - 1
.github/workflows/schema-update.yml

@@ -14,7 +14,7 @@ jobs:
       - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
         with:
           token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
-      - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
+      - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
         with:
           go-version-file: go.mod
       - run: go run . schema > ./schema.json

+ 6 - 6
.github/workflows/security.yml

@@ -30,11 +30,11 @@ jobs:
       - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
         with:
           persist-credentials: false
-      - uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
+      - uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
         with:
           languages: ${{ matrix.language }}
-      - uses: github/codeql-action/autobuild@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
-      - uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
+      - uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
+      - uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
 
   grype:
     runs-on: ubuntu-latest
@@ -46,13 +46,13 @@ jobs:
       - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
         with:
           persist-credentials: false
-      - uses: anchore/scan-action@40a61b52209e9d50e87917c5b901783d546b12d0 # v7.2.1
+      - uses: anchore/scan-action@62b74fb7bb810d2c45b1865f47a77655621862a5 # v7.2.3
         id: scan
         with:
           path: "."
           fail-build: true
           severity-cutoff: critical
-      - uses: github/codeql-action/upload-sarif@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
+      - uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
         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@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
+      - uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
         with:
           sarif_file: results.sarif
 

+ 33 - 0
.github/workflows/snapshot.yml

@@ -0,0 +1,33 @@
+name: snapshot
+
+on:
+  push:
+    branches:
+      - main
+
+permissions:
+  contents: read
+
+concurrency:
+  group: snapshot-${{ github.ref }}
+  cancel-in-progress: true
+
+jobs:
+  snapshot:
+    runs-on:
+      # Use our own large runners with more CPU and RAM for faster builds
+      group: releasers
+    steps:
+      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+        with:
+          fetch-depth: 0
+          persist-credentials: false
+      - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
+        with:
+          go-version-file: go.mod
+      - uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0
+        with:
+          version: "~> v2"
+          args: build --snapshot --clean
+        env:
+          GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}

+ 1 - 2
.gitignore

@@ -48,6 +48,5 @@ Thumbs.db
 /tmp/
 
 manpages/
-completions/
-!internal/tui/components/completions/
+completions/crush.*sh
 .prettierignore

+ 3 - 0
CRUSH.md → AGENTS.md

@@ -70,3 +70,6 @@ func TestYourFunction(t *testing.T) {
 - ALWAYS use semantic commits (`fix:`, `feat:`, `chore:`, `refactor:`, `docs:`, `sec:`, etc).
 - Try to keep commits to one line, not including your attribution. Only use
   multi-line commits when additional context is truly necessary.
+
+## Working on the TUI (UI)
+Anytime you need to work on the tui before starting work read the internal/ui/AGENTS.md file

+ 4 - 3
README.md

@@ -174,7 +174,7 @@ go install github.com/charmbracelet/crush@latest
 ## Getting Started
 
 The quickest way to get started is to grab an API key for your preferred
-provider such as Anthropic, OpenAI, Groq, or OpenRouter and just start
+provider such as Anthropic, OpenAI, Groq, OpenRouter, or Vercel AI Gateway and just start
 Crush. You'll be prompted to enter your API key.
 
 That said, you can also set environment variables for preferred providers.
@@ -184,6 +184,7 @@ That said, you can also set environment variables for preferred providers.
 | `ANTHROPIC_API_KEY`         | Anthropic                                          |
 | `OPENAI_API_KEY`            | OpenAI                                             |
 | `OPENROUTER_API_KEY`        | OpenRouter                                         |
+| `VERCEL_API_KEY`            | Vercel AI Gateway                                  |
 | `GEMINI_API_KEY`            | Google Gemini                                      |
 | `CEREBRAS_API_KEY`          | Cerebras                                           |
 | `HF_TOKEN`                  | Huggingface Inference                              |
@@ -735,8 +736,8 @@ Or by setting the following in your config:
 }
 ```
 
-Crush also respects the [`DO_NOT_TRACK`](https://consoledonottrack.com)
-convention which can be enabled via `export DO_NOT_TRACK=1`.
+Crush also respects the `DO_NOT_TRACK` convention which can be enabled via
+`export DO_NOT_TRACK=1`.
 
 ## Contributing
 

+ 4 - 0
Taskfile.yaml

@@ -122,6 +122,10 @@ tasks:
         msg: Not on main branch
       - sh: "[ $(git status --porcelain=2 | wc -l) = 0 ]"
         msg: "Git is dirty"
+      - sh: 'gh run list --workflow build.yml --commit $(git rev-parse HEAD) --status success --json conclusion -q ".[0].conclusion" | grep -q success'
+        msg: "Test build for this commit failed or not present"
+      - sh: 'gh run list --workflow snapshot.yml --commit $(git rev-parse HEAD) --status success --json conclusion -q ".[0].conclusion" | grep -q success'
+        msg: "Snapshot build for this commit failed or not present"
     cmds:
       - task: fetch-tags
       - git commit --allow-empty -m "{{.NEXT}}"

+ 8 - 4
go.mod

@@ -19,7 +19,7 @@ require (
 	github.com/aymanbagabas/go-udiff v0.3.1
 	github.com/bmatcuk/doublestar/v4 v4.9.2
 	github.com/charlievieth/fastwalk v1.0.14
-	github.com/charmbracelet/catwalk v0.14.2
+	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
@@ -30,14 +30,19 @@ require (
 	github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
 	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.0.0-20260119114936-fd556377ea59
 	github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b
 	github.com/charmbracelet/x/term v0.2.2
 	github.com/denisbrodbeck/machineid v1.0.1
 	github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
+	github.com/disintegration/imaging v1.6.2
+	github.com/dustin/go-humanize v1.0.1
 	github.com/google/uuid v1.6.0
 	github.com/invopop/jsonschema v0.13.0
 	github.com/joho/godotenv v1.5.1
+	github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d
 	github.com/lucasb-eyer/go-colorful v1.3.0
+	github.com/mattn/go-isatty v0.0.20
 	github.com/modelcontextprotocol/go-sdk v1.2.0
 	github.com/muesli/termenv v0.16.0
 	github.com/ncruces/go-sqlite3 v0.30.4
@@ -107,8 +112,7 @@ require (
 	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
-	github.com/dustin/go-humanize v1.0.1 // indirect
-	github.com/ebitengine/purego v0.10.0-alpha.3.0.20260115160133-57859678ab72 // indirect
+	github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect
 	github.com/fsnotify/fsnotify v1.9.0 // indirect
 	github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
@@ -118,6 +122,7 @@ require (
 	github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
 	github.com/goccy/go-json v0.10.5 // indirect
 	github.com/goccy/go-yaml v1.19.0 // 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
 	github.com/google/s2a-go v0.1.9 // indirect
@@ -135,7 +140,6 @@ require (
 	github.com/klauspost/cpuid/v2 v2.0.9 // indirect
 	github.com/klauspost/pgzip v1.2.6 // indirect
 	github.com/mailru/easyjson v0.7.7 // indirect
-	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/mattn/go-runewidth v0.0.19 // indirect
 	github.com/mfridman/interpolate v0.0.2 // indirect
 	github.com/microcosm-cc/bluemonday v1.0.27 // indirect

+ 13 - 4
go.sum

@@ -96,8 +96,8 @@ github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICg
 github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
 github.com/charmbracelet/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.14.2 h1:7st55IXbMbOaj8/m4Ceb0Ppvz6M879FfYK3e4loUhic=
-github.com/charmbracelet/catwalk v0.14.2/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
+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=
@@ -118,6 +118,8 @@ github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sB
 github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff h1:Uwr+/JS+qnRcO/++xjYEDtW7x+P5E4+4cBiOHTt2Xfk=
 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
+github.com/charmbracelet/x/exp/strings v0.0.0-20260119114936-fd556377ea59 h1:cvPMInXNmK/CHjQU8eXC/oSnGfEKpQmndsEykh03bt0=
+github.com/charmbracelet/x/exp/strings v0.0.0-20260119114936-fd556377ea59/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=
@@ -148,12 +150,14 @@ github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4G
 github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
+github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
+github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
 github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
 github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
-github.com/ebitengine/purego v0.10.0-alpha.3.0.20260115160133-57859678ab72 h1:7LxHj6bTGLfcjjDMZyTH8ZDB8nQrcwoFNr1s4yiWtac=
-github.com/ebitengine/purego v0.10.0-alpha.3.0.20260115160133-57859678ab72/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff h1:vAcU1VsCRstZ9ty11yD/L0WDyT73S/gVfmuWvcWX5DA=
+github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
 github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
 github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
 github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
@@ -183,6 +187,8 @@ 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/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=
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
 github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
 github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -215,6 +221,8 @@ github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcI
 github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+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=
@@ -395,6 +403,7 @@ golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
 golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
 golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
 golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=

+ 5 - 0
internal/agent/coordinator.go

@@ -117,6 +117,11 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
 		return nil, err
 	}
 
+	// refresh models before each run
+	if err := c.UpdateModels(ctx); err != nil {
+		return nil, fmt.Errorf("failed to update models: %w", err)
+	}
+
 	model := c.currentAgent.Model()
 	maxTokens := model.CatwalkCfg.DefaultMaxTokens
 	if model.ModelCfg.MaxTokens != 0 {

+ 105 - 2
internal/app/app.go

@@ -17,6 +17,7 @@ import (
 	tea "charm.land/bubbletea/v2"
 	"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"
@@ -40,6 +41,13 @@ import (
 	"github.com/charmbracelet/x/term"
 )
 
+// UpdateAvailableMsg is sent when a new version is available.
+type UpdateAvailableMsg struct {
+	CurrentVersion string
+	LatestVersion  string
+	IsDevelopment  bool
+}
+
 type App struct {
 	Sessions    session.Service
 	Messages    message.Service
@@ -124,12 +132,18 @@ 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 string, quiet bool) error {
+func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, quiet bool) error {
 	slog.Info("Running in non-interactive mode")
 
 	ctx, cancel := context.WithCancel(ctx)
 	defer cancel()
 
+	if largeModel != "" || smallModel != "" {
+		if err := app.overrideModelsForNonInteractive(ctx, largeModel, smallModel); err != nil {
+			return fmt.Errorf("failed to override models: %w", err)
+		}
+	}
+
 	var (
 		spinner   *format.Spinner
 		stdoutTTY bool
@@ -292,6 +306,95 @@ func (app *App) UpdateAgentModel(ctx context.Context) error {
 	return app.AgentCoordinator.UpdateModels(ctx)
 }
 
+// overrideModelsForNonInteractive parses the model strings and temporarily
+// overrides the model configurations, then rebuilds the agent.
+// Format: "model-name" (searches all providers) or "provider/model-name".
+// Model matching is case-insensitive.
+// If largeModel is provided but smallModel is not, the small model defaults to
+// the provider's default small model.
+func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel, smallModel string) error {
+	providers := app.config.Providers.Copy()
+
+	largeMatches, smallMatches, err := findModels(providers, largeModel, smallModel)
+	if err != nil {
+		return err
+	}
+
+	var largeProviderID string
+
+	// Override large model.
+	if largeModel != "" {
+		found, err := validateMatches(largeMatches, largeModel, "large")
+		if err != nil {
+			return err
+		}
+		largeProviderID = found.provider
+		slog.Info("Overriding large model for non-interactive run", "provider", found.provider, "model", found.modelID)
+		app.config.Models[config.SelectedModelTypeLarge] = config.SelectedModel{
+			Provider: found.provider,
+			Model:    found.modelID,
+		}
+	}
+
+	// Override small model.
+	switch {
+	case smallModel != "":
+		found, err := validateMatches(smallMatches, smallModel, "small")
+		if err != nil {
+			return err
+		}
+		slog.Info("Overriding small model for non-interactive run", "provider", found.provider, "model", found.modelID)
+		app.config.Models[config.SelectedModelTypeSmall] = config.SelectedModel{
+			Provider: found.provider,
+			Model:    found.modelID,
+		}
+
+	case largeModel != "":
+		// No small model specified, but large model was - use provider's default.
+		smallCfg := app.getDefaultSmallModel(largeProviderID)
+		app.config.Models[config.SelectedModelTypeSmall] = smallCfg
+	}
+
+	return app.AgentCoordinator.UpdateModels(ctx)
+}
+
+// provider. Falls back to the large model if no default is found.
+func (app *App) getDefaultSmallModel(providerID string) config.SelectedModel {
+	cfg := app.config
+	largeModelCfg := cfg.Models[config.SelectedModelTypeLarge]
+
+	// Find the provider in the known providers list to get its default small model.
+	knownProviders, _ := config.Providers(cfg)
+	var knownProvider *catwalk.Provider
+	for _, p := range knownProviders {
+		if string(p.ID) == providerID {
+			knownProvider = &p
+			break
+		}
+	}
+
+	// For unknown/local providers, use the large model as small.
+	if knownProvider == nil {
+		slog.Warn("Using large model as small model for unknown provider", "provider", providerID, "model", largeModelCfg.Model)
+		return largeModelCfg
+	}
+
+	defaultSmallModelID := knownProvider.DefaultSmallModelID
+	model := cfg.GetModel(providerID, defaultSmallModelID)
+	if model == nil {
+		slog.Warn("Default small model not found, using large model", "provider", providerID, "model", largeModelCfg.Model)
+		return largeModelCfg
+	}
+
+	slog.Info("Using provider default small model", "provider", providerID, "model", defaultSmallModelID)
+	return config.SelectedModel{
+		Provider:        providerID,
+		Model:           defaultSmallModelID,
+		MaxTokens:       model.DefaultMaxTokens,
+		ReasoningEffort: model.DefaultReasoningEffort,
+	}
+}
+
 func (app *App) setupEvents() {
 	ctx, cancel := context.WithCancel(app.globalCtx)
 	app.eventsCtx = ctx
@@ -452,7 +555,7 @@ func (app *App) checkForUpdates(ctx context.Context) {
 	if err != nil || !info.Available() {
 		return
 	}
-	app.events <- pubsub.UpdateAvailableMsg{
+	app.events <- UpdateAvailableMsg{
 		CurrentVersion: info.Current,
 		LatestVersion:  info.Latest,
 		IsDevelopment:  info.IsDevelopment(),

+ 95 - 0
internal/app/provider.go

@@ -0,0 +1,95 @@
+package app
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/charmbracelet/crush/internal/config"
+	xstrings "github.com/charmbracelet/x/exp/strings"
+)
+
+// parseModelStr parses a model string into provider filter and model ID.
+// Format: "model-name" or "provider/model-name" or "synthetic/moonshot/kimi-k2".
+// This function only checks if the first component is a valid provider name; if not,
+// it treats the entire string as a model ID (which may contain slashes).
+func parseModelStr(providers map[string]config.ProviderConfig, modelStr string) (providerFilter, modelID string) {
+	parts := strings.Split(modelStr, "/")
+	if len(parts) == 1 {
+		return "", parts[0]
+	}
+	// Check if the first part is a valid provider name
+	if _, ok := providers[parts[0]]; ok {
+		return parts[0], strings.Join(parts[1:], "/")
+	}
+
+	// First part is not a valid provider, treat entire string as model ID
+	return "", modelStr
+}
+
+// modelMatch represents a found model.
+type modelMatch struct {
+	provider string
+	modelID  string
+}
+
+func findModels(providers map[string]config.ProviderConfig, largeModel, smallModel string) ([]modelMatch, []modelMatch, error) {
+	largeProviderFilter, largeModelID := parseModelStr(providers, largeModel)
+	smallProviderFilter, smallModelID := parseModelStr(providers, smallModel)
+
+	// Validate provider filters exist.
+	for _, pf := range []struct {
+		filter, label string
+	}{
+		{largeProviderFilter, "large"},
+		{smallProviderFilter, "small"},
+	} {
+		if pf.filter != "" {
+			if _, ok := providers[pf.filter]; !ok {
+				return nil, nil, fmt.Errorf("%s model: provider %q not found in configuration. Use 'crush models' to list available models", pf.label, pf.filter)
+			}
+		}
+	}
+
+	// Find matching models in a single pass.
+	var largeMatches, smallMatches []modelMatch
+	for name, provider := range providers {
+		if provider.Disable {
+			continue
+		}
+		for _, m := range provider.Models {
+			if filter(largeModelID, largeProviderFilter, m.ID, name) {
+				largeMatches = append(largeMatches, modelMatch{provider: name, modelID: m.ID})
+			}
+			if filter(smallModelID, smallProviderFilter, m.ID, name) {
+				smallMatches = append(smallMatches, modelMatch{provider: name, modelID: m.ID})
+			}
+		}
+	}
+
+	return largeMatches, smallMatches, nil
+}
+
+func filter(modelFilter, providerFilter, model, provider string) bool {
+	return modelFilter != "" && model == modelFilter &&
+		(providerFilter == "" || provider == providerFilter)
+}
+
+// Validate and return a single match.
+func validateMatches(matches []modelMatch, modelID, label string) (modelMatch, error) {
+	switch {
+	case len(matches) == 0:
+		return modelMatch{}, fmt.Errorf("%s model %q not found", label, modelID)
+	case len(matches) > 1:
+		names := make([]string, len(matches))
+		for i, m := range matches {
+			names[i] = m.provider
+		}
+		return modelMatch{}, fmt.Errorf(
+			"%s model: model %q found in multiple providers: %s. Please specify provider using 'provider/model' format",
+			label,
+			modelID,
+			xstrings.EnglishJoin(names, true),
+		)
+	}
+	return matches[0], nil
+}

+ 210 - 0
internal/app/provider_test.go

@@ -0,0 +1,210 @@
+package app
+
+import (
+	"testing"
+
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/stretchr/testify/require"
+)
+
+func TestParseModelStr(t *testing.T) {
+	tests := []struct {
+		name            string
+		modelStr        string
+		expectedFilter  string
+		expectedModelID string
+		setupProviders  func() map[string]config.ProviderConfig
+	}{
+		{
+			name:            "simple model with no slashes",
+			modelStr:        "gpt-4o",
+			expectedFilter:  "",
+			expectedModelID: "gpt-4o",
+			setupProviders:  setupMockProviders,
+		},
+		{
+			name:            "valid provider and model",
+			modelStr:        "openai/gpt-4o",
+			expectedFilter:  "openai",
+			expectedModelID: "gpt-4o",
+			setupProviders:  setupMockProviders,
+		},
+		{
+			name:            "model with multiple slashes and first part is invalid provider",
+			modelStr:        "moonshot/kimi-k2",
+			expectedFilter:  "",
+			expectedModelID: "moonshot/kimi-k2",
+			setupProviders:  setupMockProviders,
+		},
+		{
+			name:            "full path with valid provider and model with slashes",
+			modelStr:        "synthetic/moonshot/kimi-k2",
+			expectedFilter:  "synthetic",
+			expectedModelID: "moonshot/kimi-k2",
+			setupProviders:  setupMockProvidersWithSlashes,
+		},
+		{
+			name:            "empty model string",
+			modelStr:        "",
+			expectedFilter:  "",
+			expectedModelID: "",
+			setupProviders:  setupMockProviders,
+		},
+		{
+			name:            "model with trailing slash but valid provider",
+			modelStr:        "openai/",
+			expectedFilter:  "openai",
+			expectedModelID: "",
+			setupProviders:  setupMockProviders,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			providers := tt.setupProviders()
+			filter, modelID := parseModelStr(providers, tt.modelStr)
+
+			require.Equal(t, tt.expectedFilter, filter, "provider filter mismatch")
+			require.Equal(t, tt.expectedModelID, modelID, "model ID mismatch")
+		})
+	}
+}
+
+func setupMockProviders() map[string]config.ProviderConfig {
+	return map[string]config.ProviderConfig{
+		"openai": {
+			ID:     "openai",
+			Name:   "OpenAI",
+			Models: []catwalk.Model{{ID: "gpt-4o"}, {ID: "gpt-4o-mini"}},
+		},
+		"anthropic": {
+			ID:     "anthropic",
+			Name:   "Anthropic",
+			Models: []catwalk.Model{{ID: "claude-3-sonnet"}, {ID: "claude-3-opus"}},
+		},
+	}
+}
+
+func setupMockProvidersWithSlashes() map[string]config.ProviderConfig {
+	return map[string]config.ProviderConfig{
+		"synthetic": {
+			ID:   "synthetic",
+			Name: "Synthetic",
+			Models: []catwalk.Model{
+				{ID: "moonshot/kimi-k2"},
+				{ID: "deepseek/deepseek-chat"},
+			},
+		},
+		"openai": {
+			ID:     "openai",
+			Name:   "OpenAI",
+			Models: []catwalk.Model{{ID: "gpt-4o"}},
+		},
+	}
+}
+
+func TestFindModels(t *testing.T) {
+	tests := []struct {
+		name             string
+		modelStr         string
+		expectedProvider string
+		expectedModelID  string
+		expectError      bool
+		errorContains    string
+		setupProviders   func() map[string]config.ProviderConfig
+	}{
+		{
+			name:             "simple model found in one provider",
+			modelStr:         "gpt-4o",
+			expectedProvider: "openai",
+			expectedModelID:  "gpt-4o",
+			expectError:      false,
+			setupProviders:   setupMockProviders,
+		},
+		{
+			name:             "model with slashes in ID",
+			modelStr:         "moonshot/kimi-k2",
+			expectedProvider: "synthetic",
+			expectedModelID:  "moonshot/kimi-k2",
+			expectError:      false,
+			setupProviders:   setupMockProvidersWithSlashes,
+		},
+		{
+			name:             "provider and model with slashes in ID",
+			modelStr:         "synthetic/moonshot/kimi-k2",
+			expectedProvider: "synthetic",
+			expectedModelID:  "moonshot/kimi-k2",
+			expectError:      false,
+			setupProviders:   setupMockProvidersWithSlashes,
+		},
+		{
+			name:           "model not found",
+			modelStr:       "nonexistent-model",
+			expectError:    true,
+			errorContains:  "not found",
+			setupProviders: setupMockProviders,
+		},
+		{
+			name:           "invalid provider specified",
+			modelStr:       "nonexistent-provider/gpt-4o",
+			expectError:    true,
+			errorContains:  "provider",
+			setupProviders: setupMockProviders,
+		},
+		{
+			name:          "model found in multiple providers without provider filter",
+			modelStr:      "shared-model",
+			expectError:   true,
+			errorContains: "multiple providers",
+			setupProviders: func() map[string]config.ProviderConfig {
+				return map[string]config.ProviderConfig{
+					"openai": {
+						ID:     "openai",
+						Models: []catwalk.Model{{ID: "shared-model"}},
+					},
+					"anthropic": {
+						ID:     "anthropic",
+						Models: []catwalk.Model{{ID: "shared-model"}},
+					},
+				}
+			},
+		},
+		{
+			name:           "empty model string",
+			modelStr:       "",
+			expectError:    true,
+			errorContains:  "not found",
+			setupProviders: setupMockProviders,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			providers := tt.setupProviders()
+
+			// Use findModels with the model as "large" and empty "small".
+			matches, _, err := findModels(providers, tt.modelStr, "")
+			if err != nil {
+				if tt.expectError {
+					require.Contains(t, err.Error(), tt.errorContains)
+				} else {
+					require.NoError(t, err)
+				}
+				return
+			}
+
+			// Validate the matches.
+			match, err := validateMatches(matches, tt.modelStr, "large")
+
+			if tt.expectError {
+				require.Error(t, err)
+				require.Contains(t, err.Error(), tt.errorContains)
+			} else {
+				require.NoError(t, err)
+				require.Equal(t, tt.expectedProvider, match.provider)
+				require.Equal(t, tt.expectedModelID, match.modelID)
+			}
+		})
+	}
+}

+ 110 - 0
internal/cmd/models.go

@@ -0,0 +1,110 @@
+package cmd
+
+import (
+	"fmt"
+	"os"
+	"slices"
+	"sort"
+	"strings"
+
+	"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"
+)
+
+var modelsCmd = &cobra.Command{
+	Use:   "models",
+	Short: "List all available models from configured providers",
+	Long:  `List all available models from configured providers. Shows provider name and model IDs.`,
+	Example: `# List all available models
+crush models
+
+# Search models
+crush models gpt5`,
+	Args: cobra.ArbitraryArgs,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		cwd, err := ResolveCwd(cmd)
+		if err != nil {
+			return err
+		}
+
+		dataDir, _ := cmd.Flags().GetString("data-dir")
+		debug, _ := cmd.Flags().GetBool("debug")
+
+		cfg, err := config.Init(cwd, dataDir, debug)
+		if err != nil {
+			return err
+		}
+
+		if !cfg.IsConfigured() {
+			return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
+		}
+
+		term := strings.ToLower(strings.Join(args, " "))
+		filter := func(p config.ProviderConfig, m catwalk.Model) bool {
+			for _, s := range []string{p.ID, p.Name, m.ID, m.Name} {
+				if term == "" || strings.Contains(strings.ToLower(s), term) {
+					return true
+				}
+			}
+			return false
+		}
+
+		var providerIDs []string
+		providerModels := make(map[string][]string)
+
+		for providerID, provider := range cfg.Providers.Seq2() {
+			if provider.Disable {
+				continue
+			}
+			var found bool
+			for _, model := range provider.Models {
+				if !filter(provider, model) {
+					continue
+				}
+				providerModels[providerID] = append(providerModels[providerID], model.ID)
+				found = true
+			}
+			if !found {
+				continue
+			}
+			slices.Sort(providerModels[providerID])
+			providerIDs = append(providerIDs, providerID)
+		}
+		sort.Strings(providerIDs)
+
+		if len(providerIDs) == 0 && len(args) == 0 {
+			return fmt.Errorf("no enabled providers found")
+		}
+		if len(providerIDs) == 0 {
+			return fmt.Errorf("no enabled providers found matching %q", term)
+		}
+
+		if !isatty.IsTerminal(os.Stdout.Fd()) {
+			for _, providerID := range providerIDs {
+				for _, modelID := range providerModels[providerID] {
+					fmt.Println(providerID + "/" + modelID)
+				}
+			}
+			return nil
+		}
+
+		t := tree.New()
+		for _, providerID := range providerIDs {
+			providerNode := tree.Root(providerID)
+			for _, modelID := range providerModels[providerID] {
+				providerNode.Child(modelID)
+			}
+			t.Child(providerNode)
+		}
+
+		cmd.Println(t)
+		return nil
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(modelsCmd)
+}

+ 15 - 3
internal/cmd/root.go

@@ -22,6 +22,8 @@ import (
 	"github.com/charmbracelet/crush/internal/projects"
 	"github.com/charmbracelet/crush/internal/stringext"
 	"github.com/charmbracelet/crush/internal/tui"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	ui "github.com/charmbracelet/crush/internal/ui/model"
 	"github.com/charmbracelet/crush/internal/version"
 	"github.com/charmbracelet/fang"
 	uv "github.com/charmbracelet/ultraviolet"
@@ -86,11 +88,21 @@ crush -y
 
 		// Set up the TUI.
 		var env uv.Environ = os.Environ()
-		ui := tui.New(app)
-		ui.QueryVersion = shouldQueryTerminalVersion(env)
 
+		var model tea.Model
+		if v, _ := strconv.ParseBool(env.Getenv("CRUSH_NEW_UI")); v {
+			slog.Info("New UI in control!")
+			com := common.DefaultCommon(app)
+			ui := ui.New(com)
+			ui.QueryVersion = shouldQueryTerminalVersion(env)
+			model = ui
+		} else {
+			ui := tui.New(app)
+			ui.QueryVersion = shouldQueryTerminalVersion(env)
+			model = ui
+		}
 		program := tea.NewProgram(
-			ui,
+			model,
 			tea.WithEnvironment(env),
 			tea.WithContext(cmd.Context()),
 			tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state

+ 5 - 1
internal/cmd/run.go

@@ -32,6 +32,8 @@ crush run --quiet "Generate a README for this project"
   `,
 	RunE: func(cmd *cobra.Command, args []string) error {
 		quiet, _ := cmd.Flags().GetBool("quiet")
+		largeModel, _ := cmd.Flags().GetString("model")
+		smallModel, _ := cmd.Flags().GetString("small-model")
 
 		// Cancel on SIGINT or SIGTERM.
 		ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
@@ -62,7 +64,7 @@ crush run --quiet "Generate a README for this project"
 		event.SetNonInteractive(true)
 		event.AppInitialized()
 
-		return app.RunNonInteractive(ctx, os.Stdout, prompt, quiet)
+		return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet)
 	},
 	PostRun: func(cmd *cobra.Command, args []string) {
 		event.AppExited()
@@ -71,4 +73,6 @@ crush run --quiet "Generate a README for this project"
 
 func init() {
 	runCmd.Flags().BoolP("quiet", "q", false, "Hide spinner")
+	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")
 }

+ 237 - 0
internal/commands/commands.go

@@ -0,0 +1,237 @@
+package commands
+
+import (
+	"context"
+	"io/fs"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+
+	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/home"
+)
+
+var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
+
+const (
+	userCommandPrefix    = "user:"
+	projectCommandPrefix = "project:"
+)
+
+// Argument represents a command argument with its metadata.
+type Argument struct {
+	ID          string
+	Title       string
+	Description string
+	Required    bool
+}
+
+// MCPPrompt represents a custom command loaded from an MCP server.
+type MCPPrompt struct {
+	ID          string
+	Title       string
+	Description string
+	PromptID    string
+	ClientID    string
+	Arguments   []Argument
+}
+
+// CustomCommand represents a user-defined custom command loaded from markdown files.
+type CustomCommand struct {
+	ID        string
+	Name      string
+	Content   string
+	Arguments []Argument
+}
+
+type commandSource struct {
+	path   string
+	prefix string
+}
+
+// LoadCustomCommands loads custom commands from multiple sources including
+// XDG config directory, home directory, and project directory.
+func LoadCustomCommands(cfg *config.Config) ([]CustomCommand, error) {
+	return loadAll(buildCommandSources(cfg))
+}
+
+// LoadMCPPrompts loads custom commands from available MCP servers.
+func LoadMCPPrompts() ([]MCPPrompt, error) {
+	var commands []MCPPrompt
+	for mcpName, prompts := range mcp.Prompts() {
+		for _, prompt := range prompts {
+			key := mcpName + ":" + prompt.Name
+			var args []Argument
+			for _, arg := range prompt.Arguments {
+				title := arg.Title
+				if title == "" {
+					title = arg.Name
+				}
+				args = append(args, Argument{
+					ID:          arg.Name,
+					Title:       title,
+					Description: arg.Description,
+					Required:    arg.Required,
+				})
+			}
+			commands = append(commands, MCPPrompt{
+				ID:          key,
+				Title:       prompt.Title,
+				Description: prompt.Description,
+				PromptID:    prompt.Name,
+				ClientID:    mcpName,
+				Arguments:   args,
+			})
+		}
+	}
+	return commands, nil
+}
+
+func buildCommandSources(cfg *config.Config) []commandSource {
+	var sources []commandSource
+
+	// XDG config directory
+	if dir := getXDGCommandsDir(); dir != "" {
+		sources = append(sources, commandSource{
+			path:   dir,
+			prefix: userCommandPrefix,
+		})
+	}
+
+	// Home directory
+	if home := home.Dir(); home != "" {
+		sources = append(sources, commandSource{
+			path:   filepath.Join(home, ".crush", "commands"),
+			prefix: userCommandPrefix,
+		})
+	}
+
+	// Project directory
+	sources = append(sources, commandSource{
+		path:   filepath.Join(cfg.Options.DataDirectory, "commands"),
+		prefix: projectCommandPrefix,
+	})
+
+	return sources
+}
+
+func loadAll(sources []commandSource) ([]CustomCommand, error) {
+	var commands []CustomCommand
+
+	for _, source := range sources {
+		if cmds, err := loadFromSource(source); err == nil {
+			commands = append(commands, cmds...)
+		}
+	}
+
+	return commands, nil
+}
+
+func loadFromSource(source commandSource) ([]CustomCommand, error) {
+	if err := ensureDir(source.path); err != nil {
+		return nil, err
+	}
+
+	var commands []CustomCommand
+
+	err := filepath.WalkDir(source.path, func(path string, d fs.DirEntry, err error) error {
+		if err != nil || d.IsDir() || !isMarkdownFile(d.Name()) {
+			return err
+		}
+
+		cmd, err := loadCommand(path, source.path, source.prefix)
+		if err != nil {
+			return nil // Skip invalid files
+		}
+
+		commands = append(commands, cmd)
+		return nil
+	})
+
+	return commands, err
+}
+
+func loadCommand(path, baseDir, prefix string) (CustomCommand, error) {
+	content, err := os.ReadFile(path)
+	if err != nil {
+		return CustomCommand{}, err
+	}
+
+	id := buildCommandID(path, baseDir, prefix)
+
+	return CustomCommand{
+		ID:        id,
+		Name:      id,
+		Content:   string(content),
+		Arguments: extractArgNames(string(content)),
+	}, nil
+}
+
+func extractArgNames(content string) []Argument {
+	matches := namedArgPattern.FindAllStringSubmatch(content, -1)
+	if len(matches) == 0 {
+		return nil
+	}
+
+	seen := make(map[string]bool)
+	var args []Argument
+
+	for _, match := range matches {
+		arg := match[1]
+		if !seen[arg] {
+			seen[arg] = true
+			// for normal custom commands, all args are required
+			args = append(args, Argument{ID: arg, Title: arg, Required: true})
+		}
+	}
+
+	return args
+}
+
+func buildCommandID(path, baseDir, prefix string) string {
+	relPath, _ := filepath.Rel(baseDir, path)
+	parts := strings.Split(relPath, string(filepath.Separator))
+
+	// Remove .md extension from last part
+	if len(parts) > 0 {
+		lastIdx := len(parts) - 1
+		parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], filepath.Ext(parts[lastIdx]))
+	}
+
+	return prefix + strings.Join(parts, ":")
+}
+
+func getXDGCommandsDir() string {
+	xdgHome := os.Getenv("XDG_CONFIG_HOME")
+	if xdgHome == "" {
+		if home := home.Dir(); home != "" {
+			xdgHome = filepath.Join(home, ".config")
+		}
+	}
+	if xdgHome != "" {
+		return filepath.Join(xdgHome, "crush", "commands")
+	}
+	return ""
+}
+
+func ensureDir(path string) error {
+	if _, err := os.Stat(path); os.IsNotExist(err) {
+		return os.MkdirAll(path, 0o755)
+	}
+	return nil
+}
+
+func isMarkdownFile(name string) bool {
+	return strings.HasSuffix(strings.ToLower(name), ".md")
+}
+
+func GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) {
+	// TODO: we should pass the context down
+	result, err := mcp.GetPromptMessages(context.Background(), clientID, promptID, args)
+	if err != nil {
+		return "", err
+	}
+	return strings.Join(result, " "), nil
+}

+ 7 - 1
internal/config/config.go

@@ -53,6 +53,11 @@ var defaultContextPaths = []string{
 
 type SelectedModelType string
 
+// String returns the string representation of the [SelectedModelType].
+func (s SelectedModelType) String() string {
+	return string(s)
+}
+
 const (
 	SelectedModelTypeLarge SelectedModelType = "large"
 	SelectedModelTypeSmall SelectedModelType = "small"
@@ -359,8 +364,9 @@ type Config struct {
 
 	// We currently only support large/small as values here.
 	Models map[SelectedModelType]SelectedModel `json:"models,omitempty" jsonschema:"description=Model configurations for different model types,example={\"large\":{\"model\":\"gpt-4o\",\"provider\":\"openai\"}}"`
+
 	// Recently used models stored in the data directory config.
-	RecentModels map[SelectedModelType][]SelectedModel `json:"recent_models,omitempty" jsonschema:"description=Recently used models sorted by most recent first"`
+	RecentModels map[SelectedModelType][]SelectedModel `json:"recent_models,omitempty" jsonschema:"-"`
 
 	// The providers that are configured
 	Providers *csync.Map[string, ProviderConfig] `json:"providers,omitempty" jsonschema:"description=AI provider configurations"`

+ 1 - 12
internal/env/env.go

@@ -2,7 +2,6 @@ package env
 
 import (
 	"os"
-	"testing"
 )
 
 type Env interface {
@@ -18,17 +17,10 @@ func (o *osEnv) Get(key string) string {
 }
 
 func (o *osEnv) Env() []string {
-	env := os.Environ()
-	if len(env) == 0 {
-		return nil
-	}
-	return env
+	return os.Environ()
 }
 
 func New() Env {
-	if testing.Testing() {
-		return NewFromMap(nil)
-	}
 	return &osEnv{}
 }
 
@@ -46,9 +38,6 @@ func (m *mapEnv) Get(key string) string {
 
 // Env implements Env.
 func (m *mapEnv) Env() []string {
-	if len(m.m) == 0 {
-		return nil
-	}
 	env := make([]string, 0, len(m.m))
 	for k, v := range m.m {
 		env = append(env, k+"="+v)

+ 4 - 2
internal/env/env_test.go

@@ -90,13 +90,15 @@ func TestMapEnv_Env(t *testing.T) {
 	t.Run("empty map", func(t *testing.T) {
 		env := NewFromMap(map[string]string{})
 		envVars := env.Env()
-		require.Nil(t, envVars)
+		require.NotNil(t, envVars)
+		require.Len(t, envVars, 0)
 	})
 
 	t.Run("nil map", func(t *testing.T) {
 		env := NewFromMap(nil)
 		envVars := env.Env()
-		require.Nil(t, envVars)
+		require.NotNil(t, envVars)
+		require.Len(t, envVars, 0)
 	})
 }
 

+ 1 - 1
internal/lsp/client.go

@@ -345,7 +345,7 @@ func (c *Client) CloseAllFiles(ctx context.Context) {
 			slog.Debug("Closing file", "file", uri)
 		}
 		if err := c.client.NotifyDidCloseTextDocument(ctx, uri); err != nil {
-			slog.Warn("Error closing rile", "uri", uri, "error", err)
+			slog.Warn("Error closing file", "uri", uri, "error", err)
 			continue
 		}
 		c.openFiles.Del(uri)

+ 1 - 1
internal/message/attachment.go

@@ -15,7 +15,7 @@ type Attachment struct {
 func (a Attachment) IsText() bool  { return strings.HasPrefix(a.MimeType, "text/") }
 func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") }
 
-// ContainsTextAttachment returns true if any of the attachments is a text attachments.
+// ContainsTextAttachment returns true if any of the attachments is a text attachment.
 func ContainsTextAttachment(attachments []Attachment) bool {
 	return slices.ContainsFunc(attachments, func(a Attachment) bool {
 		return a.IsText()

+ 5 - 5
internal/message/message.go

@@ -63,7 +63,7 @@ func (s *service) Create(ctx context.Context, sessionID string, params CreateMes
 			Reason: "stop",
 		})
 	}
-	partsJSON, err := marshallParts(params.Parts)
+	partsJSON, err := marshalParts(params.Parts)
 	if err != nil {
 		return Message{}, err
 	}
@@ -110,7 +110,7 @@ func (s *service) DeleteSessionMessages(ctx context.Context, sessionID string) e
 }
 
 func (s *service) Update(ctx context.Context, message Message) error {
-	parts, err := marshallParts(message.Parts)
+	parts, err := marshalParts(message.Parts)
 	if err != nil {
 		return err
 	}
@@ -158,7 +158,7 @@ func (s *service) List(ctx context.Context, sessionID string) ([]Message, error)
 }
 
 func (s *service) fromDBItem(item db.Message) (Message, error) {
-	parts, err := unmarshallParts([]byte(item.Parts))
+	parts, err := unmarshalParts([]byte(item.Parts))
 	if err != nil {
 		return Message{}, err
 	}
@@ -192,7 +192,7 @@ type partWrapper struct {
 	Data ContentPart `json:"data"`
 }
 
-func marshallParts(parts []ContentPart) ([]byte, error) {
+func marshalParts(parts []ContentPart) ([]byte, error) {
 	wrappedParts := make([]partWrapper, len(parts))
 
 	for i, part := range parts {
@@ -225,7 +225,7 @@ func marshallParts(parts []ContentPart) ([]byte, error) {
 	return json.Marshal(wrappedParts)
 }
 
-func unmarshallParts(data []byte) ([]ContentPart, error) {
+func unmarshalParts(data []byte) ([]ContentPart, error) {
 	temp := []json.RawMessage{}
 
 	if err := json.Unmarshal(data, &temp); err != nil {

+ 8 - 9
internal/permission/permission.go

@@ -152,6 +152,10 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe
 	s.autoApproveSessionsMu.RUnlock()
 
 	if autoApprove {
+		s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
+			ToolCallID: opts.ToolCallID,
+			Granted:    true,
+		})
 		return true, nil
 	}
 
@@ -183,15 +187,10 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe
 	for _, p := range s.sessionPermissions {
 		if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
 			s.sessionPermissionsMu.RUnlock()
-			return true, nil
-		}
-	}
-	s.sessionPermissionsMu.RUnlock()
-
-	s.sessionPermissionsMu.RLock()
-	for _, p := range s.sessionPermissions {
-		if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
-			s.sessionPermissionsMu.RUnlock()
+			s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
+				ToolCallID: opts.ToolCallID,
+				Granted:    true,
+			})
 			return true, nil
 		}
 	}

+ 1 - 3
internal/pubsub/broker.go

@@ -20,13 +20,11 @@ func NewBroker[T any]() *Broker[T] {
 }
 
 func NewBrokerWithOptions[T any](channelBufferSize, maxEvents int) *Broker[T] {
-	b := &Broker[T]{
+	return &Broker[T]{
 		subs:      make(map[chan Event[T]]struct{}),
 		done:      make(chan struct{}),
-		subCount:  0,
 		maxEvents: maxEvents,
 	}
-	return b
 }
 
 func (b *Broker[T]) Shutdown() {

+ 0 - 7
internal/pubsub/events.go

@@ -26,10 +26,3 @@ type (
 		Publish(EventType, T)
 	}
 )
-
-// UpdateAvailableMsg is sent when a new version is available.
-type UpdateAvailableMsg struct {
-	CurrentVersion string
-	LatestVersion  string
-	IsDevelopment  bool
-}

+ 1 - 1
internal/shell/shell.go

@@ -227,7 +227,7 @@ func (s *Shell) blockHandler() func(next interp.ExecHandlerFunc) interp.ExecHand
 
 			for _, blockFunc := range s.blockFuncs {
 				if blockFunc(args) {
-					return fmt.Errorf("command is not allowed for security reasons: %s", strings.Join(args, " "))
+					return fmt.Errorf("command is not allowed for security reasons: %q", args[0])
 				}
 			}
 

+ 4 - 2
internal/tui/components/chat/editor/editor.go

@@ -39,6 +39,9 @@ var (
 	errClipboardUnknownFormat       = fmt.Errorf("unknown clipboard format")
 )
 
+// If pasted text has more than 10 newlines, treat it as a file attachment.
+const pasteLinesThreshold = 10
+
 type Editor interface {
 	util.Model
 	layout.Sizeable
@@ -239,8 +242,7 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		m.textarea.SetValue(msg.Text)
 		m.textarea.MoveToEnd()
 	case tea.PasteMsg:
-		// If pasted text has more than 2 newlines, treat it as a file attachment.
-		if strings.Count(msg.Content, "\n") > 2 {
+		if strings.Count(msg.Content, "\n") > pasteLinesThreshold {
 			content := []byte(msg.Content)
 			if len(content) > maxAttachmentSize {
 				return m, util.ReportWarn("Paste is too big (>5mb)")

+ 3 - 1
internal/tui/tui.go

@@ -16,6 +16,7 @@ import (
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/event"
+	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/stringext"
@@ -385,7 +386,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 		return a, tea.Batch(cmds...)
 	// Update Available
-	case pubsub.UpdateAvailableMsg:
+	case app.UpdateAvailableMsg:
 		// Show update notification in status bar
 		statusMsg := fmt.Sprintf("Crush update available: v%s → v%s.", msg.CurrentVersion, msg.LatestVersion)
 		if msg.IsDevelopment {
@@ -592,6 +593,7 @@ func (a *appModel) View() tea.View {
 	view.AltScreen = true
 	view.MouseMode = tea.MouseModeCellMotion
 	view.BackgroundColor = t.BgBase
+	view.WindowTitle = "crush " + home.Short(config.Get().WorkingDir())
 	if a.wWidth < 25 || a.wHeight < 15 {
 		view.Content = t.S().Base.Width(a.wWidth).Height(a.wHeight).
 			Align(lipgloss.Center, lipgloss.Center).

+ 61 - 0
internal/ui/AGENTS.md

@@ -0,0 +1,61 @@
+# UI Development Instructions
+
+## General Guidelines
+- Never use commands to send messages when you can directly mutate children or state.
+- Keep things simple; do not overcomplicate.
+- Create files if needed to separate logic; do not nest models.
+- Always do IO in commands
+- Never change the model state inside of a command use messages and than update the state in the main loop
+
+## Architecture
+
+### Main Model (`model/ui.go`)
+Keep most of the logic and state in the main model. This is where:
+- Message routing happens
+- Focus and UI state is managed
+- Layout calculations are performed
+- Dialogs are orchestrated
+
+### Components Should Be Dumb
+Components should not handle bubbletea messages directly. Instead:
+- Expose methods for state changes
+- Return `tea.Cmd` from methods when side effects are needed
+- Handle their own rendering via `Render(width int) string`
+
+### Chat Logic (`model/chat.go`)
+Most chat-related logic belongs here. Individual chat items in `chat/` should be simple renderers that cache their output and invalidate when data changes (see `cachedMessageItem` in `chat/messages.go`).
+
+## Key Patterns
+
+### Composition Over Inheritance
+Use struct embedding for shared behaviors. See `chat/messages.go` for examples of reusable embedded structs for highlighting, caching, and focus.
+
+### Interfaces
+- List item interfaces are in `list/item.go`
+- Chat message interfaces are in `chat/messages.go`
+- Dialog interface is in `dialog/dialog.go`
+
+### Styling
+- All styles are defined in `styles/styles.go`
+- Access styles via `*common.Common` passed to components
+- Use semantic color fields rather than hardcoded colors
+
+### Dialogs
+- Implement the dialog interface in `dialog/dialog.go`
+- Return message types from `Update()` to signal actions to the main model
+- Use the overlay system for managing dialog lifecycle
+
+## File Organization
+- `model/` - Main UI model and major components (chat, sidebar, etc.)
+- `chat/` - Chat message item types and renderers
+- `dialog/` - Dialog implementations
+- `list/` - Generic list component with lazy rendering
+- `common/` - Shared utilities and the Common struct
+- `styles/` - All style definitions
+- `anim/` - Animation system
+- `logo/` - Logo rendering
+
+## Common Gotchas
+- Always account for padding/borders in width calculations
+- Use `tea.Batch()` when returning multiple commands
+- Pass `*common.Common` to components that need styles or app access

+ 445 - 0
internal/ui/anim/anim.go

@@ -0,0 +1,445 @@
+// Package anim provides an animated spinner.
+package anim
+
+import (
+	"fmt"
+	"image/color"
+	"math/rand/v2"
+	"strings"
+	"sync/atomic"
+	"time"
+
+	"github.com/zeebo/xxh3"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/lucasb-eyer/go-colorful"
+
+	"github.com/charmbracelet/crush/internal/csync"
+)
+
+const (
+	fps           = 20
+	initialChar   = '.'
+	labelGap      = " "
+	labelGapWidth = 1
+
+	// Periods of ellipsis animation speed in steps.
+	//
+	// If the FPS is 20 (50 milliseconds) this means that the ellipsis will
+	// change every 8 frames (400 milliseconds).
+	ellipsisAnimSpeed = 8
+
+	// The maximum amount of time that can pass before a character appears.
+	// This is used to create a staggered entrance effect.
+	maxBirthOffset = time.Second
+
+	// Number of frames to prerender for the animation. After this number
+	// of frames, the animation will loop. This only applies when color
+	// cycling is disabled.
+	prerenderedFrames = 10
+
+	// Default number of cycling chars.
+	defaultNumCyclingChars = 10
+)
+
+// Default colors for gradient.
+var (
+	defaultGradColorA = color.RGBA{R: 0xff, G: 0, B: 0, A: 0xff}
+	defaultGradColorB = color.RGBA{R: 0, G: 0, B: 0xff, A: 0xff}
+	defaultLabelColor = color.RGBA{R: 0xcc, G: 0xcc, B: 0xcc, A: 0xff}
+)
+
+var (
+	availableRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_")
+	ellipsisFrames = []string{".", "..", "...", ""}
+)
+
+// Internal ID management. Used during animating to ensure that frame messages
+// are received only by spinner components that sent them.
+var lastID int64
+
+func nextID() int {
+	return int(atomic.AddInt64(&lastID, 1))
+}
+
+// Cache for expensive animation calculations
+type animCache struct {
+	initialFrames  [][]string
+	cyclingFrames  [][]string
+	width          int
+	labelWidth     int
+	label          []string
+	ellipsisFrames []string
+}
+
+var animCacheMap = csync.NewMap[string, *animCache]()
+
+// settingsHash creates a hash key for the settings to use for caching
+func settingsHash(opts Settings) string {
+	h := xxh3.New()
+	fmt.Fprintf(h, "%d-%s-%v-%v-%v-%t",
+		opts.Size, opts.Label, opts.LabelColor, opts.GradColorA, opts.GradColorB, opts.CycleColors)
+	return fmt.Sprintf("%x", h.Sum(nil))
+}
+
+// StepMsg is a message type used to trigger the next step in the animation.
+type StepMsg struct{ ID string }
+
+// Settings defines settings for the animation.
+type Settings struct {
+	ID          string
+	Size        int
+	Label       string
+	LabelColor  color.Color
+	GradColorA  color.Color
+	GradColorB  color.Color
+	CycleColors bool
+}
+
+// Default settings.
+const ()
+
+// Anim is a Bubble for an animated spinner.
+type Anim struct {
+	width            int
+	cyclingCharWidth int
+	label            *csync.Slice[string]
+	labelWidth       int
+	labelColor       color.Color
+	startTime        time.Time
+	birthOffsets     []time.Duration
+	initialFrames    [][]string // frames for the initial characters
+	initialized      atomic.Bool
+	cyclingFrames    [][]string           // frames for the cycling characters
+	step             atomic.Int64         // current main frame step
+	ellipsisStep     atomic.Int64         // current ellipsis frame step
+	ellipsisFrames   *csync.Slice[string] // ellipsis animation frames
+	id               string
+}
+
+// New creates a new Anim instance with the specified width and label.
+func New(opts Settings) *Anim {
+	a := &Anim{}
+	// Validate settings.
+	if opts.Size < 1 {
+		opts.Size = defaultNumCyclingChars
+	}
+	if colorIsUnset(opts.GradColorA) {
+		opts.GradColorA = defaultGradColorA
+	}
+	if colorIsUnset(opts.GradColorB) {
+		opts.GradColorB = defaultGradColorB
+	}
+	if colorIsUnset(opts.LabelColor) {
+		opts.LabelColor = defaultLabelColor
+	}
+
+	if opts.ID != "" {
+		a.id = opts.ID
+	} else {
+		a.id = fmt.Sprintf("%d", nextID())
+	}
+	a.startTime = time.Now()
+	a.cyclingCharWidth = opts.Size
+	a.labelColor = opts.LabelColor
+
+	// Check cache first
+	cacheKey := settingsHash(opts)
+	cached, exists := animCacheMap.Get(cacheKey)
+
+	if exists {
+		// Use cached values
+		a.width = cached.width
+		a.labelWidth = cached.labelWidth
+		a.label = csync.NewSliceFrom(cached.label)
+		a.ellipsisFrames = csync.NewSliceFrom(cached.ellipsisFrames)
+		a.initialFrames = cached.initialFrames
+		a.cyclingFrames = cached.cyclingFrames
+	} else {
+		// Generate new values and cache them
+		a.labelWidth = lipgloss.Width(opts.Label)
+
+		// Total width of anim, in cells.
+		a.width = opts.Size
+		if opts.Label != "" {
+			a.width += labelGapWidth + lipgloss.Width(opts.Label)
+		}
+
+		// Render the label
+		a.renderLabel(opts.Label)
+
+		// Pre-generate gradient.
+		var ramp []color.Color
+		numFrames := prerenderedFrames
+		if opts.CycleColors {
+			ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
+			numFrames = a.width * 2
+		} else {
+			ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
+		}
+
+		// Pre-render initial characters.
+		a.initialFrames = make([][]string, numFrames)
+		offset := 0
+		for i := range a.initialFrames {
+			a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
+			for j := range a.initialFrames[i] {
+				if j+offset >= len(ramp) {
+					continue // skip if we run out of colors
+				}
+
+				var c color.Color
+				if j <= a.cyclingCharWidth {
+					c = ramp[j+offset]
+				} else {
+					c = opts.LabelColor
+				}
+
+				// Also prerender the initial character with Lip Gloss to avoid
+				// processing in the render loop.
+				a.initialFrames[i][j] = lipgloss.NewStyle().
+					Foreground(c).
+					Render(string(initialChar))
+			}
+			if opts.CycleColors {
+				offset++
+			}
+		}
+
+		// Prerender scrambled rune frames for the animation.
+		a.cyclingFrames = make([][]string, numFrames)
+		offset = 0
+		for i := range a.cyclingFrames {
+			a.cyclingFrames[i] = make([]string, a.width)
+			for j := range a.cyclingFrames[i] {
+				if j+offset >= len(ramp) {
+					continue // skip if we run out of colors
+				}
+
+				// Also prerender the color with Lip Gloss here to avoid processing
+				// in the render loop.
+				r := availableRunes[rand.IntN(len(availableRunes))]
+				a.cyclingFrames[i][j] = lipgloss.NewStyle().
+					Foreground(ramp[j+offset]).
+					Render(string(r))
+			}
+			if opts.CycleColors {
+				offset++
+			}
+		}
+
+		// Cache the results
+		labelSlice := make([]string, a.label.Len())
+		for i, v := range a.label.Seq2() {
+			labelSlice[i] = v
+		}
+		ellipsisSlice := make([]string, a.ellipsisFrames.Len())
+		for i, v := range a.ellipsisFrames.Seq2() {
+			ellipsisSlice[i] = v
+		}
+		cached = &animCache{
+			initialFrames:  a.initialFrames,
+			cyclingFrames:  a.cyclingFrames,
+			width:          a.width,
+			labelWidth:     a.labelWidth,
+			label:          labelSlice,
+			ellipsisFrames: ellipsisSlice,
+		}
+		animCacheMap.Set(cacheKey, cached)
+	}
+
+	// Random assign a birth to each character for a stagged entrance effect.
+	a.birthOffsets = make([]time.Duration, a.width)
+	for i := range a.birthOffsets {
+		a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond
+	}
+
+	return a
+}
+
+// SetLabel updates the label text and re-renders it.
+func (a *Anim) SetLabel(newLabel string) {
+	a.labelWidth = lipgloss.Width(newLabel)
+
+	// Update total width
+	a.width = a.cyclingCharWidth
+	if newLabel != "" {
+		a.width += labelGapWidth + a.labelWidth
+	}
+
+	// Re-render the label
+	a.renderLabel(newLabel)
+}
+
+// renderLabel renders the label with the current label color.
+func (a *Anim) renderLabel(label string) {
+	if a.labelWidth > 0 {
+		// Pre-render the label.
+		labelRunes := []rune(label)
+		a.label = csync.NewSlice[string]()
+		for i := range labelRunes {
+			rendered := lipgloss.NewStyle().
+				Foreground(a.labelColor).
+				Render(string(labelRunes[i]))
+			a.label.Append(rendered)
+		}
+
+		// Pre-render the ellipsis frames which come after the label.
+		a.ellipsisFrames = csync.NewSlice[string]()
+		for _, frame := range ellipsisFrames {
+			rendered := lipgloss.NewStyle().
+				Foreground(a.labelColor).
+				Render(frame)
+			a.ellipsisFrames.Append(rendered)
+		}
+	} else {
+		a.label = csync.NewSlice[string]()
+		a.ellipsisFrames = csync.NewSlice[string]()
+	}
+}
+
+// Width returns the total width of the animation.
+func (a *Anim) Width() (w int) {
+	w = a.width
+	if a.labelWidth > 0 {
+		w += labelGapWidth + a.labelWidth
+
+		var widestEllipsisFrame int
+		for _, f := range ellipsisFrames {
+			fw := lipgloss.Width(f)
+			if fw > widestEllipsisFrame {
+				widestEllipsisFrame = fw
+			}
+		}
+		w += widestEllipsisFrame
+	}
+	return w
+}
+
+// Start starts the animation.
+func (a *Anim) Start() tea.Cmd {
+	return a.Step()
+}
+
+// Animate advances the animation to the next step.
+func (a *Anim) Animate(msg StepMsg) tea.Cmd {
+	if msg.ID != a.id {
+		return nil
+	}
+
+	step := a.step.Add(1)
+	if int(step) >= len(a.cyclingFrames) {
+		a.step.Store(0)
+	}
+
+	if a.initialized.Load() && a.labelWidth > 0 {
+		// Manage the ellipsis animation.
+		ellipsisStep := a.ellipsisStep.Add(1)
+		if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
+			a.ellipsisStep.Store(0)
+		}
+	} else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset {
+		a.initialized.Store(true)
+	}
+	return a.Step()
+}
+
+// Render renders the current state of the animation.
+func (a *Anim) Render() string {
+	var b strings.Builder
+	step := int(a.step.Load())
+	for i := range a.width {
+		switch {
+		case !a.initialized.Load() && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
+			// Birth offset not reached: render initial character.
+			b.WriteString(a.initialFrames[step][i])
+		case i < a.cyclingCharWidth:
+			// Render a cycling character.
+			b.WriteString(a.cyclingFrames[step][i])
+		case i == a.cyclingCharWidth:
+			// Render label gap.
+			b.WriteString(labelGap)
+		case i > a.cyclingCharWidth:
+			// Label.
+			if labelChar, ok := a.label.Get(i - a.cyclingCharWidth - labelGapWidth); ok {
+				b.WriteString(labelChar)
+			}
+		}
+	}
+	// Render animated ellipsis at the end of the label if all characters
+	// have been initialized.
+	if a.initialized.Load() && a.labelWidth > 0 {
+		ellipsisStep := int(a.ellipsisStep.Load())
+		if ellipsisFrame, ok := a.ellipsisFrames.Get(ellipsisStep / ellipsisAnimSpeed); ok {
+			b.WriteString(ellipsisFrame)
+		}
+	}
+
+	return b.String()
+}
+
+// Step is a command that triggers the next step in the animation.
+func (a *Anim) Step() tea.Cmd {
+	return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
+		return StepMsg{ID: a.id}
+	})
+}
+
+// makeGradientRamp() returns a slice of colors blended between the given keys.
+// Blending is done as Hcl to stay in gamut.
+func makeGradientRamp(size int, stops ...color.Color) []color.Color {
+	if len(stops) < 2 {
+		return nil
+	}
+
+	points := make([]colorful.Color, len(stops))
+	for i, k := range stops {
+		points[i], _ = colorful.MakeColor(k)
+	}
+
+	numSegments := len(stops) - 1
+	if numSegments == 0 {
+		return nil
+	}
+	blended := make([]color.Color, 0, size)
+
+	// Calculate how many colors each segment should have.
+	segmentSizes := make([]int, numSegments)
+	baseSize := size / numSegments
+	remainder := size % numSegments
+
+	// Distribute the remainder across segments.
+	for i := range numSegments {
+		segmentSizes[i] = baseSize
+		if i < remainder {
+			segmentSizes[i]++
+		}
+	}
+
+	// Generate colors for each segment.
+	for i := range numSegments {
+		c1 := points[i]
+		c2 := points[i+1]
+		segmentSize := segmentSizes[i]
+
+		for j := range segmentSize {
+			if segmentSize == 0 {
+				continue
+			}
+			t := float64(j) / float64(segmentSize)
+			c := c1.BlendHcl(c2, t)
+			blended = append(blended, c)
+		}
+	}
+
+	return blended
+}
+
+func colorIsUnset(c color.Color) bool {
+	if c == nil {
+		return true
+	}
+	_, _, _, a := c.RGBA()
+	return a == 0
+}

+ 135 - 0
internal/ui/attachments/attachments.go

@@ -0,0 +1,135 @@
+package attachments
+
+import (
+	"fmt"
+	"math"
+	"path/filepath"
+	"slices"
+	"strings"
+
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/x/ansi"
+)
+
+const maxFilename = 15
+
+type Keymap struct {
+	DeleteMode,
+	DeleteAll,
+	Escape key.Binding
+}
+
+func New(renderer *Renderer, keyMap Keymap) *Attachments {
+	return &Attachments{
+		keyMap:   keyMap,
+		renderer: renderer,
+	}
+}
+
+type Attachments struct {
+	renderer *Renderer
+	keyMap   Keymap
+	list     []message.Attachment
+	deleting bool
+}
+
+func (m *Attachments) List() []message.Attachment { return m.list }
+func (m *Attachments) Reset()                     { m.list = nil }
+
+func (m *Attachments) Update(msg tea.Msg) bool {
+	switch msg := msg.(type) {
+	case message.Attachment:
+		m.list = append(m.list, msg)
+		return true
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, m.keyMap.DeleteMode):
+			if len(m.list) > 0 {
+				m.deleting = true
+			}
+			return true
+		case m.deleting && key.Matches(msg, m.keyMap.Escape):
+			m.deleting = false
+			return true
+		case m.deleting && key.Matches(msg, m.keyMap.DeleteAll):
+			m.deleting = false
+			m.list = nil
+			return true
+		case m.deleting:
+			// Handle digit keys for individual attachment deletion.
+			r := msg.Code
+			if r >= '0' && r <= '9' {
+				num := int(r - '0')
+				if num < len(m.list) {
+					m.list = slices.Delete(m.list, num, num+1)
+				}
+				m.deleting = false
+			}
+			return true
+		}
+	}
+	return false
+}
+
+func (m *Attachments) Render(width int) string {
+	return m.renderer.Render(m.list, m.deleting, width)
+}
+
+func NewRenderer(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) *Renderer {
+	return &Renderer{
+		normalStyle:   normalStyle,
+		textStyle:     textStyle,
+		imageStyle:    imageStyle,
+		deletingStyle: deletingStyle,
+	}
+}
+
+type Renderer struct {
+	normalStyle, textStyle, imageStyle, deletingStyle lipgloss.Style
+}
+
+func (r *Renderer) Render(attachments []message.Attachment, deleting bool, width int) string {
+	var chips []string
+
+	maxItemWidth := lipgloss.Width(r.imageStyle.String() + r.normalStyle.Render(strings.Repeat("x", maxFilename)))
+	fits := int(math.Floor(float64(width)/float64(maxItemWidth))) - 1
+
+	for i, att := range attachments {
+		filename := filepath.Base(att.FileName)
+		// Truncate if needed.
+		if ansi.StringWidth(filename) > maxFilename {
+			filename = ansi.Truncate(filename, maxFilename, "…")
+		}
+
+		if deleting {
+			chips = append(
+				chips,
+				r.deletingStyle.Render(fmt.Sprintf("%d", i)),
+				r.normalStyle.Render(filename),
+			)
+		} else {
+			chips = append(
+				chips,
+				r.icon(att).String(),
+				r.normalStyle.Render(filename),
+			)
+		}
+
+		if i == fits && len(attachments) > i {
+			chips = append(chips, lipgloss.NewStyle().Width(maxItemWidth).Render(fmt.Sprintf("%d more…", len(attachments)-fits)))
+			break
+		}
+	}
+
+	return lipgloss.JoinHorizontal(lipgloss.Left, chips...)
+}
+
+func (r *Renderer) icon(a message.Attachment) lipgloss.Style {
+	if a.IsImage() {
+		return r.imageStyle
+	}
+	return r.textStyle
+}

+ 302 - 0
internal/ui/chat/agent.go

@@ -0,0 +1,302 @@
+package chat
+
+import (
+	"encoding/json"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"charm.land/lipgloss/v2/tree"
+	"github.com/charmbracelet/crush/internal/agent"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/anim"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// -----------------------------------------------------------------------------
+// Agent Tool
+// -----------------------------------------------------------------------------
+
+// NestedToolContainer is an interface for tool items that can contain nested tool calls.
+type NestedToolContainer interface {
+	NestedTools() []ToolMessageItem
+	SetNestedTools(tools []ToolMessageItem)
+	AddNestedTool(tool ToolMessageItem)
+}
+
+// AgentToolMessageItem is a message item that represents an agent tool call.
+type AgentToolMessageItem struct {
+	*baseToolMessageItem
+
+	nestedTools []ToolMessageItem
+}
+
+var (
+	_ ToolMessageItem     = (*AgentToolMessageItem)(nil)
+	_ NestedToolContainer = (*AgentToolMessageItem)(nil)
+)
+
+// NewAgentToolMessageItem creates a new [AgentToolMessageItem].
+func NewAgentToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) *AgentToolMessageItem {
+	t := &AgentToolMessageItem{}
+	t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgentToolRenderContext{agent: t}, canceled)
+	// For the agent tool we keep spinning until the tool call is finished.
+	t.spinningFunc = func(state SpinningState) bool {
+		return !state.HasResult() && !state.IsCanceled()
+	}
+	return t
+}
+
+// Animate progresses the message animation if it should be spinning.
+func (a *AgentToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
+	if a.result != nil || a.Status() == ToolStatusCanceled {
+		return nil
+	}
+	if msg.ID == a.ID() {
+		return a.anim.Animate(msg)
+	}
+	for _, nestedTool := range a.nestedTools {
+		if msg.ID != nestedTool.ID() {
+			continue
+		}
+		if s, ok := nestedTool.(Animatable); ok {
+			return s.Animate(msg)
+		}
+	}
+	return nil
+}
+
+// NestedTools returns the nested tools.
+func (a *AgentToolMessageItem) NestedTools() []ToolMessageItem {
+	return a.nestedTools
+}
+
+// SetNestedTools sets the nested tools.
+func (a *AgentToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
+	a.nestedTools = tools
+	a.clearCache()
+}
+
+// AddNestedTool adds a nested tool.
+func (a *AgentToolMessageItem) AddNestedTool(tool ToolMessageItem) {
+	// Mark nested tools as simple (compact) rendering.
+	if s, ok := tool.(Compactable); ok {
+		s.SetCompact(true)
+	}
+	a.nestedTools = append(a.nestedTools, tool)
+	a.clearCache()
+}
+
+// AgentToolRenderContext renders agent tool messages.
+type AgentToolRenderContext struct {
+	agent *AgentToolMessageItem
+}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.agent.nestedTools) == 0 {
+		return pendingTool(sty, "Agent", opts.Anim)
+	}
+
+	var params agent.AgentParams
+	_ = json.Unmarshal([]byte(opts.ToolCall.Input), &params)
+
+	prompt := params.Prompt
+	prompt = strings.ReplaceAll(prompt, "\n", " ")
+
+	header := toolHeader(sty, opts.Status, "Agent", cappedWidth, opts.Compact)
+	if opts.Compact {
+		return header
+	}
+
+	// Build the task tag and prompt.
+	taskTag := sty.Tool.AgentTaskTag.Render("Task")
+	taskTagWidth := lipgloss.Width(taskTag)
+
+	// Calculate remaining width for prompt.
+	remainingWidth := min(cappedWidth-taskTagWidth-3, maxTextWidth-taskTagWidth-3) // -3 for spacing
+
+	promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt)
+
+	header = lipgloss.JoinVertical(
+		lipgloss.Left,
+		header,
+		"",
+		lipgloss.JoinHorizontal(
+			lipgloss.Left,
+			taskTag,
+			" ",
+			promptText,
+		),
+	)
+
+	// Build tree with nested tool calls.
+	childTools := tree.Root(header)
+
+	for _, nestedTool := range r.agent.nestedTools {
+		childView := nestedTool.Render(remainingWidth)
+		childTools.Child(childView)
+	}
+
+	// Build parts.
+	var parts []string
+	parts = append(parts, childTools.Enumerator(roundedEnumerator(2, taskTagWidth-5)).String())
+
+	// Show animation if still running.
+	if !opts.HasResult() && !opts.IsCanceled() {
+		parts = append(parts, "", opts.Anim.Render())
+	}
+
+	result := lipgloss.JoinVertical(lipgloss.Left, parts...)
+
+	// Add body content when completed.
+	if opts.HasResult() && opts.Result.Content != "" {
+		body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent)
+		return joinToolParts(result, body)
+	}
+
+	return result
+}
+
+// -----------------------------------------------------------------------------
+// Agentic Fetch Tool
+// -----------------------------------------------------------------------------
+
+// AgenticFetchToolMessageItem is a message item that represents an agentic fetch tool call.
+type AgenticFetchToolMessageItem struct {
+	*baseToolMessageItem
+
+	nestedTools []ToolMessageItem
+}
+
+var (
+	_ ToolMessageItem     = (*AgenticFetchToolMessageItem)(nil)
+	_ NestedToolContainer = (*AgenticFetchToolMessageItem)(nil)
+)
+
+// NewAgenticFetchToolMessageItem creates a new [AgenticFetchToolMessageItem].
+func NewAgenticFetchToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) *AgenticFetchToolMessageItem {
+	t := &AgenticFetchToolMessageItem{}
+	t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgenticFetchToolRenderContext{fetch: t}, canceled)
+	// For the agentic fetch tool we keep spinning until the tool call is finished.
+	t.spinningFunc = func(state SpinningState) bool {
+		return !state.HasResult() && !state.IsCanceled()
+	}
+	return t
+}
+
+// NestedTools returns the nested tools.
+func (a *AgenticFetchToolMessageItem) NestedTools() []ToolMessageItem {
+	return a.nestedTools
+}
+
+// SetNestedTools sets the nested tools.
+func (a *AgenticFetchToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
+	a.nestedTools = tools
+	a.clearCache()
+}
+
+// AddNestedTool adds a nested tool.
+func (a *AgenticFetchToolMessageItem) AddNestedTool(tool ToolMessageItem) {
+	// Mark nested tools as simple (compact) rendering.
+	if s, ok := tool.(Compactable); ok {
+		s.SetCompact(true)
+	}
+	a.nestedTools = append(a.nestedTools, tool)
+	a.clearCache()
+}
+
+// AgenticFetchToolRenderContext renders agentic fetch tool messages.
+type AgenticFetchToolRenderContext struct {
+	fetch *AgenticFetchToolMessageItem
+}
+
+// agenticFetchParams matches tools.AgenticFetchParams.
+type agenticFetchParams struct {
+	URL    string `json:"url,omitempty"`
+	Prompt string `json:"prompt"`
+}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.fetch.nestedTools) == 0 {
+		return pendingTool(sty, "Agentic Fetch", opts.Anim)
+	}
+
+	var params agenticFetchParams
+	_ = json.Unmarshal([]byte(opts.ToolCall.Input), &params)
+
+	prompt := params.Prompt
+	prompt = strings.ReplaceAll(prompt, "\n", " ")
+
+	// Build header with optional URL param.
+	toolParams := []string{}
+	if params.URL != "" {
+		toolParams = append(toolParams, params.URL)
+	}
+
+	header := toolHeader(sty, opts.Status, "Agentic Fetch", cappedWidth, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	// Build the prompt tag.
+	promptTag := sty.Tool.AgenticFetchPromptTag.Render("Prompt")
+	promptTagWidth := lipgloss.Width(promptTag)
+
+	// Calculate remaining width for prompt text.
+	remainingWidth := min(cappedWidth-promptTagWidth-3, maxTextWidth-promptTagWidth-3) // -3 for spacing
+
+	promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt)
+
+	header = lipgloss.JoinVertical(
+		lipgloss.Left,
+		header,
+		"",
+		lipgloss.JoinHorizontal(
+			lipgloss.Left,
+			promptTag,
+			" ",
+			promptText,
+		),
+	)
+
+	// Build tree with nested tool calls.
+	childTools := tree.Root(header)
+
+	for _, nestedTool := range r.fetch.nestedTools {
+		childView := nestedTool.Render(remainingWidth)
+		childTools.Child(childView)
+	}
+
+	// Build parts.
+	var parts []string
+	parts = append(parts, childTools.Enumerator(roundedEnumerator(2, promptTagWidth-5)).String())
+
+	// Show animation if still running.
+	if !opts.HasResult() && !opts.IsCanceled() {
+		parts = append(parts, "", opts.Anim.Render())
+	}
+
+	result := lipgloss.JoinVertical(lipgloss.Left, parts...)
+
+	// Add body content when completed.
+	if opts.HasResult() && opts.Result.Content != "" {
+		body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent)
+		return joinToolParts(result, body)
+	}
+
+	return result
+}

+ 257 - 0
internal/ui/chat/assistant.go

@@ -0,0 +1,257 @@
+package chat
+
+import (
+	"fmt"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/anim"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// assistantMessageTruncateFormat is the text shown when an assistant message is
+// truncated.
+const assistantMessageTruncateFormat = "… (%d lines hidden) [click or space to expand]"
+
+// maxCollapsedThinkingHeight defines the maximum height of the thinking
+const maxCollapsedThinkingHeight = 10
+
+// AssistantMessageItem represents an assistant message in the chat UI.
+//
+// This item includes thinking, and the content but does not include the tool calls.
+type AssistantMessageItem struct {
+	*highlightableMessageItem
+	*cachedMessageItem
+	*focusableMessageItem
+
+	message           *message.Message
+	sty               *styles.Styles
+	anim              *anim.Anim
+	thinkingExpanded  bool
+	thinkingBoxHeight int // Tracks the rendered thinking box height for click detection.
+}
+
+// NewAssistantMessageItem creates a new AssistantMessageItem.
+func NewAssistantMessageItem(sty *styles.Styles, message *message.Message) MessageItem {
+	a := &AssistantMessageItem{
+		highlightableMessageItem: defaultHighlighter(sty),
+		cachedMessageItem:        &cachedMessageItem{},
+		focusableMessageItem:     &focusableMessageItem{},
+		message:                  message,
+		sty:                      sty,
+	}
+
+	a.anim = anim.New(anim.Settings{
+		ID:          a.ID(),
+		Size:        15,
+		GradColorA:  sty.Primary,
+		GradColorB:  sty.Secondary,
+		LabelColor:  sty.FgBase,
+		CycleColors: true,
+	})
+	return a
+}
+
+// StartAnimation starts the assistant message animation if it should be spinning.
+func (a *AssistantMessageItem) StartAnimation() tea.Cmd {
+	if !a.isSpinning() {
+		return nil
+	}
+	return a.anim.Start()
+}
+
+// Animate progresses the assistant message animation if it should be spinning.
+func (a *AssistantMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
+	if !a.isSpinning() {
+		return nil
+	}
+	return a.anim.Animate(msg)
+}
+
+// ID implements MessageItem.
+func (a *AssistantMessageItem) ID() string {
+	return a.message.ID
+}
+
+// RawRender implements [MessageItem].
+func (a *AssistantMessageItem) RawRender(width int) string {
+	cappedWidth := cappedMessageWidth(width)
+
+	var spinner string
+	if a.isSpinning() {
+		spinner = a.renderSpinning()
+	}
+
+	content, height, ok := a.getCachedRender(cappedWidth)
+	if !ok {
+		content = a.renderMessageContent(cappedWidth)
+		height = lipgloss.Height(content)
+		// cache the rendered content
+		a.setCachedRender(content, cappedWidth, height)
+	}
+
+	highlightedContent := a.renderHighlighted(content, cappedWidth, height)
+	if spinner != "" {
+		if highlightedContent != "" {
+			highlightedContent += "\n\n"
+		}
+		return highlightedContent + spinner
+	}
+
+	return highlightedContent
+}
+
+// Render implements MessageItem.
+func (a *AssistantMessageItem) Render(width int) string {
+	style := a.sty.Chat.Message.AssistantBlurred
+	if a.focused {
+		style = a.sty.Chat.Message.AssistantFocused
+	}
+	return style.Render(a.RawRender(width))
+}
+
+// renderMessageContent renders the message content including thinking, main content, and finish reason.
+func (a *AssistantMessageItem) renderMessageContent(width int) string {
+	var messageParts []string
+	thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking)
+	content := strings.TrimSpace(a.message.Content().Text)
+	// if the massage has reasoning content add that first
+	if thinking != "" {
+		messageParts = append(messageParts, a.renderThinking(a.message.ReasoningContent().Thinking, width))
+	}
+
+	// then add the main content
+	if content != "" {
+		// add a spacer between thinking and content
+		if thinking != "" {
+			messageParts = append(messageParts, "")
+		}
+		messageParts = append(messageParts, a.renderMarkdown(content, width))
+	}
+
+	// finally add any finish reason info
+	if a.message.IsFinished() {
+		switch a.message.FinishReason() {
+		case message.FinishReasonCanceled:
+			messageParts = append(messageParts, a.sty.Base.Italic(true).Render("Canceled"))
+		case message.FinishReasonError:
+			messageParts = append(messageParts, a.renderError(width))
+		}
+	}
+
+	return strings.Join(messageParts, "\n")
+}
+
+// renderThinking renders the thinking/reasoning content with footer.
+func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
+	renderer := common.PlainMarkdownRenderer(a.sty, width)
+	rendered, err := renderer.Render(thinking)
+	if err != nil {
+		rendered = thinking
+	}
+	rendered = strings.TrimSpace(rendered)
+
+	lines := strings.Split(rendered, "\n")
+	totalLines := len(lines)
+
+	isTruncated := totalLines > maxCollapsedThinkingHeight
+	if !a.thinkingExpanded && isTruncated {
+		lines = lines[totalLines-maxCollapsedThinkingHeight:]
+		hint := a.sty.Chat.Message.ThinkingTruncationHint.Render(
+			fmt.Sprintf(assistantMessageTruncateFormat, totalLines-maxCollapsedThinkingHeight),
+		)
+		lines = append([]string{hint, ""}, lines...)
+	}
+
+	thinkingStyle := a.sty.Chat.Message.ThinkingBox.Width(width)
+	result := thinkingStyle.Render(strings.Join(lines, "\n"))
+	a.thinkingBoxHeight = lipgloss.Height(result)
+
+	var footer string
+	// if thinking is done add the thought for footer
+	if !a.message.IsThinking() || len(a.message.ToolCalls()) > 0 {
+		duration := a.message.ThinkingDuration()
+		if duration.String() != "0s" {
+			footer = a.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") +
+				a.sty.Chat.Message.ThinkingFooterDuration.Render(duration.String())
+		}
+	}
+
+	if footer != "" {
+		result += "\n\n" + footer
+	}
+
+	return result
+}
+
+// renderMarkdown renders content as markdown.
+func (a *AssistantMessageItem) renderMarkdown(content string, width int) string {
+	renderer := common.MarkdownRenderer(a.sty, width)
+	result, err := renderer.Render(content)
+	if err != nil {
+		return content
+	}
+	return strings.TrimSuffix(result, "\n")
+}
+
+func (a *AssistantMessageItem) renderSpinning() string {
+	if a.message.IsThinking() {
+		a.anim.SetLabel("Thinking")
+	} else if a.message.IsSummaryMessage {
+		a.anim.SetLabel("Summarizing")
+	}
+	return a.anim.Render()
+}
+
+// renderError renders an error message.
+func (a *AssistantMessageItem) renderError(width int) string {
+	finishPart := a.message.FinishPart()
+	errTag := a.sty.Chat.Message.ErrorTag.Render("ERROR")
+	truncated := ansi.Truncate(finishPart.Message, width-2-lipgloss.Width(errTag), "...")
+	title := fmt.Sprintf("%s %s", errTag, a.sty.Chat.Message.ErrorTitle.Render(truncated))
+	details := a.sty.Chat.Message.ErrorDetails.Width(width - 2).Render(finishPart.Details)
+	return fmt.Sprintf("%s\n\n%s", title, details)
+}
+
+// isSpinning returns true if the assistant message is still generating.
+func (a *AssistantMessageItem) isSpinning() bool {
+	isThinking := a.message.IsThinking()
+	isFinished := a.message.IsFinished()
+	hasContent := strings.TrimSpace(a.message.Content().Text) != ""
+	hasToolCalls := len(a.message.ToolCalls()) > 0
+	return (isThinking || !isFinished) && !hasContent && !hasToolCalls
+}
+
+// SetMessage is used to update the underlying message.
+func (a *AssistantMessageItem) SetMessage(message *message.Message) tea.Cmd {
+	wasSpinning := a.isSpinning()
+	a.message = message
+	a.clearCache()
+	if !wasSpinning && a.isSpinning() {
+		return a.StartAnimation()
+	}
+	return nil
+}
+
+// ToggleExpanded toggles the expanded state of the thinking box.
+func (a *AssistantMessageItem) ToggleExpanded() {
+	a.thinkingExpanded = !a.thinkingExpanded
+	a.clearCache()
+}
+
+// HandleMouseClick implements MouseClickable.
+func (a *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
+	if btn != ansi.MouseLeft {
+		return false
+	}
+	// check if the click is within the thinking box
+	if a.thinkingBoxHeight > 0 && y < a.thinkingBoxHeight {
+		a.ToggleExpanded()
+		return true
+	}
+	return false
+}

+ 248 - 0
internal/ui/chat/bash.go

@@ -0,0 +1,248 @@
+package chat
+
+import (
+	"cmp"
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// -----------------------------------------------------------------------------
+// Bash Tool
+// -----------------------------------------------------------------------------
+
+// BashToolMessageItem is a message item that represents a bash tool call.
+type BashToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*BashToolMessageItem)(nil)
+
+// NewBashToolMessageItem creates a new [BashToolMessageItem].
+func NewBashToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &BashToolRenderContext{}, canceled)
+}
+
+// BashToolRenderContext renders bash tool messages.
+type BashToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Bash", opts.Anim)
+	}
+
+	var params tools.BashParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		params.Command = "failed to parse command"
+	}
+
+	// Check if this is a background job.
+	var meta tools.BashResponseMetadata
+	if opts.HasResult() {
+		_ = json.Unmarshal([]byte(opts.Result.Metadata), &meta)
+	}
+
+	if meta.Background {
+		description := cmp.Or(meta.Description, params.Command)
+		content := "Command: " + params.Command + "\n" + opts.Result.Content
+		return renderJobTool(sty, opts, cappedWidth, "Start", meta.ShellID, description, content)
+	}
+
+	// Regular bash command.
+	cmd := strings.ReplaceAll(params.Command, "\n", " ")
+	cmd = strings.ReplaceAll(cmd, "\t", "    ")
+	toolParams := []string{cmd}
+	if params.RunInBackground {
+		toolParams = append(toolParams, "background", "true")
+	}
+
+	header := toolHeader(sty, opts.Status, "Bash", cappedWidth, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if !opts.HasResult() {
+		return header
+	}
+
+	output := meta.Output
+	if output == "" && opts.Result.Content != tools.BashNoOutput {
+		output = opts.Result.Content
+	}
+	if output == "" {
+		return header
+	}
+
+	bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+	body := sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.ExpandedContent))
+	return joinToolParts(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// Job Output Tool
+// -----------------------------------------------------------------------------
+
+// JobOutputToolMessageItem is a message item for job_output tool calls.
+type JobOutputToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*JobOutputToolMessageItem)(nil)
+
+// NewJobOutputToolMessageItem creates a new [JobOutputToolMessageItem].
+func NewJobOutputToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &JobOutputToolRenderContext{}, canceled)
+}
+
+// JobOutputToolRenderContext renders job_output tool messages.
+type JobOutputToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Job", opts.Anim)
+	}
+
+	var params tools.JobOutputParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	var description string
+	if opts.HasResult() && opts.Result.Metadata != "" {
+		var meta tools.JobOutputResponseMetadata
+		if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
+			description = cmp.Or(meta.Description, meta.Command)
+		}
+	}
+
+	content := ""
+	if opts.HasResult() {
+		content = opts.Result.Content
+	}
+	return renderJobTool(sty, opts, cappedWidth, "Output", params.ShellID, description, content)
+}
+
+// -----------------------------------------------------------------------------
+// Job Kill Tool
+// -----------------------------------------------------------------------------
+
+// JobKillToolMessageItem is a message item for job_kill tool calls.
+type JobKillToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*JobKillToolMessageItem)(nil)
+
+// NewJobKillToolMessageItem creates a new [JobKillToolMessageItem].
+func NewJobKillToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &JobKillToolRenderContext{}, canceled)
+}
+
+// JobKillToolRenderContext renders job_kill tool messages.
+type JobKillToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Job", opts.Anim)
+	}
+
+	var params tools.JobKillParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	var description string
+	if opts.HasResult() && opts.Result.Metadata != "" {
+		var meta tools.JobKillResponseMetadata
+		if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
+			description = cmp.Or(meta.Description, meta.Command)
+		}
+	}
+
+	content := ""
+	if opts.HasResult() {
+		content = opts.Result.Content
+	}
+	return renderJobTool(sty, opts, cappedWidth, "Kill", params.ShellID, description, content)
+}
+
+// renderJobTool renders a job-related tool with the common pattern:
+// header → nested check → early state → body.
+func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action, shellID, description, content string) string {
+	header := jobHeader(sty, opts.Status, action, shellID, description, width)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if content == "" {
+		return header
+	}
+
+	bodyWidth := width - toolBodyLeftPaddingTotal
+	body := sty.Tool.Body.Render(toolOutputPlainContent(sty, content, bodyWidth, opts.ExpandedContent))
+	return joinToolParts(header, body)
+}
+
+// jobHeader builds a header for job-related tools.
+// Format: "● Job (Action) PID shellID description..."
+func jobHeader(sty *styles.Styles, status ToolStatus, action, shellID, description string, width int) string {
+	icon := toolIcon(sty, status)
+	jobPart := sty.Tool.JobToolName.Render("Job")
+	actionPart := sty.Tool.JobAction.Render("(" + action + ")")
+	pidPart := sty.Tool.JobPID.Render("PID " + shellID)
+
+	prefix := fmt.Sprintf("%s %s %s %s", icon, jobPart, actionPart, pidPart)
+
+	if description == "" {
+		return prefix
+	}
+
+	prefixWidth := lipgloss.Width(prefix)
+	availableWidth := width - prefixWidth - 1
+	if availableWidth < 10 {
+		return prefix
+	}
+
+	truncatedDesc := ansi.Truncate(description, availableWidth, "…")
+	return prefix + " " + sty.Tool.JobDescription.Render(truncatedDesc)
+}
+
+// joinToolParts joins header and body with a blank line separator.
+func joinToolParts(header, body string) string {
+	return strings.Join([]string{header, "", body}, "\n")
+}

+ 68 - 0
internal/ui/chat/diagnostics.go

@@ -0,0 +1,68 @@
+package chat
+
+import (
+	"encoding/json"
+
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// -----------------------------------------------------------------------------
+// Diagnostics Tool
+// -----------------------------------------------------------------------------
+
+// DiagnosticsToolMessageItem is a message item that represents a diagnostics tool call.
+type DiagnosticsToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*DiagnosticsToolMessageItem)(nil)
+
+// NewDiagnosticsToolMessageItem creates a new [DiagnosticsToolMessageItem].
+func NewDiagnosticsToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &DiagnosticsToolRenderContext{}, canceled)
+}
+
+// DiagnosticsToolRenderContext renders diagnostics tool messages.
+type DiagnosticsToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Diagnostics", opts.Anim)
+	}
+
+	var params tools.DiagnosticsParams
+	_ = json.Unmarshal([]byte(opts.ToolCall.Input), &params)
+
+	// Show "project" if no file path, otherwise show the file path.
+	mainParam := "project"
+	if params.FilePath != "" {
+		mainParam = fsext.PrettyPath(params.FilePath)
+	}
+
+	header := toolHeader(sty, opts.Status, "Diagnostics", cappedWidth, opts.Compact, mainParam)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if opts.HasEmptyResult() {
+		return header
+	}
+
+	bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+	body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+	return joinToolParts(header, body)
+}

+ 192 - 0
internal/ui/chat/fetch.go

@@ -0,0 +1,192 @@
+package chat
+
+import (
+	"encoding/json"
+
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// -----------------------------------------------------------------------------
+// Fetch Tool
+// -----------------------------------------------------------------------------
+
+// FetchToolMessageItem is a message item that represents a fetch tool call.
+type FetchToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*FetchToolMessageItem)(nil)
+
+// NewFetchToolMessageItem creates a new [FetchToolMessageItem].
+func NewFetchToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &FetchToolRenderContext{}, canceled)
+}
+
+// FetchToolRenderContext renders fetch tool messages.
+type FetchToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Fetch", opts.Anim)
+	}
+
+	var params tools.FetchParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	toolParams := []string{params.URL}
+	if params.Format != "" {
+		toolParams = append(toolParams, "format", params.Format)
+	}
+	if params.Timeout != 0 {
+		toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout))
+	}
+
+	header := toolHeader(sty, opts.Status, "Fetch", cappedWidth, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if opts.HasEmptyResult() {
+		return header
+	}
+
+	// Determine file extension for syntax highlighting based on format.
+	file := getFileExtensionForFormat(params.Format)
+	body := toolOutputCodeContent(sty, file, opts.Result.Content, 0, cappedWidth, opts.ExpandedContent)
+	return joinToolParts(header, body)
+}
+
+// getFileExtensionForFormat returns a filename with appropriate extension for syntax highlighting.
+func getFileExtensionForFormat(format string) string {
+	switch format {
+	case "text":
+		return "fetch.txt"
+	case "html":
+		return "fetch.html"
+	default:
+		return "fetch.md"
+	}
+}
+
+// -----------------------------------------------------------------------------
+// WebFetch Tool
+// -----------------------------------------------------------------------------
+
+// WebFetchToolMessageItem is a message item that represents a web_fetch tool call.
+type WebFetchToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*WebFetchToolMessageItem)(nil)
+
+// NewWebFetchToolMessageItem creates a new [WebFetchToolMessageItem].
+func NewWebFetchToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &WebFetchToolRenderContext{}, canceled)
+}
+
+// WebFetchToolRenderContext renders web_fetch tool messages.
+type WebFetchToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (w *WebFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Fetch", opts.Anim)
+	}
+
+	var params tools.WebFetchParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	toolParams := []string{params.URL}
+	header := toolHeader(sty, opts.Status, "Fetch", cappedWidth, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if opts.HasEmptyResult() {
+		return header
+	}
+
+	body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth, opts.ExpandedContent)
+	return joinToolParts(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// WebSearch Tool
+// -----------------------------------------------------------------------------
+
+// WebSearchToolMessageItem is a message item that represents a web_search tool call.
+type WebSearchToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*WebSearchToolMessageItem)(nil)
+
+// NewWebSearchToolMessageItem creates a new [WebSearchToolMessageItem].
+func NewWebSearchToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &WebSearchToolRenderContext{}, canceled)
+}
+
+// WebSearchToolRenderContext renders web_search tool messages.
+type WebSearchToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (w *WebSearchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Search", opts.Anim)
+	}
+
+	var params tools.WebSearchParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	toolParams := []string{params.Query}
+	header := toolHeader(sty, opts.Status, "Search", cappedWidth, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if opts.HasEmptyResult() {
+		return header
+	}
+
+	body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth, opts.ExpandedContent)
+	return joinToolParts(header, body)
+}

+ 340 - 0
internal/ui/chat/file.go

@@ -0,0 +1,340 @@
+package chat
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// -----------------------------------------------------------------------------
+// View Tool
+// -----------------------------------------------------------------------------
+
+// ViewToolMessageItem is a message item that represents a view tool call.
+type ViewToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*ViewToolMessageItem)(nil)
+
+// NewViewToolMessageItem creates a new [ViewToolMessageItem].
+func NewViewToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &ViewToolRenderContext{}, canceled)
+}
+
+// ViewToolRenderContext renders view tool messages.
+type ViewToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "View", opts.Anim)
+	}
+
+	var params tools.ViewParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	file := fsext.PrettyPath(params.FilePath)
+	toolParams := []string{file}
+	if params.Limit != 0 {
+		toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
+	}
+	if params.Offset != 0 {
+		toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
+	}
+
+	header := toolHeader(sty, opts.Status, "View", cappedWidth, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if !opts.HasResult() {
+		return header
+	}
+
+	// Handle image content.
+	if opts.Result.Data != "" && strings.HasPrefix(opts.Result.MIMEType, "image/") {
+		body := toolOutputImageContent(sty, opts.Result.Data, opts.Result.MIMEType)
+		return joinToolParts(header, body)
+	}
+
+	// Try to get content from metadata first (contains actual file content).
+	var meta tools.ViewResponseMetadata
+	content := opts.Result.Content
+	if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil && meta.Content != "" {
+		content = meta.Content
+	}
+
+	if content == "" {
+		return header
+	}
+
+	// Render code content with syntax highlighting.
+	body := toolOutputCodeContent(sty, params.FilePath, content, params.Offset, cappedWidth, opts.ExpandedContent)
+	return joinToolParts(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// Write Tool
+// -----------------------------------------------------------------------------
+
+// WriteToolMessageItem is a message item that represents a write tool call.
+type WriteToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*WriteToolMessageItem)(nil)
+
+// NewWriteToolMessageItem creates a new [WriteToolMessageItem].
+func NewWriteToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &WriteToolRenderContext{}, canceled)
+}
+
+// WriteToolRenderContext renders write tool messages.
+type WriteToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Write", opts.Anim)
+	}
+
+	var params tools.WriteParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	file := fsext.PrettyPath(params.FilePath)
+	header := toolHeader(sty, opts.Status, "Write", cappedWidth, opts.Compact, file)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if params.Content == "" {
+		return header
+	}
+
+	// Render code content with syntax highlighting.
+	body := toolOutputCodeContent(sty, params.FilePath, params.Content, 0, cappedWidth, opts.ExpandedContent)
+	return joinToolParts(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// Edit Tool
+// -----------------------------------------------------------------------------
+
+// EditToolMessageItem is a message item that represents an edit tool call.
+type EditToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*EditToolMessageItem)(nil)
+
+// NewEditToolMessageItem creates a new [EditToolMessageItem].
+func NewEditToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &EditToolRenderContext{}, canceled)
+}
+
+// EditToolRenderContext renders edit tool messages.
+type EditToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	// Edit tool uses full width for diffs.
+	if opts.IsPending() {
+		return pendingTool(sty, "Edit", opts.Anim)
+	}
+
+	var params tools.EditParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width)
+	}
+
+	file := fsext.PrettyPath(params.FilePath)
+	header := toolHeader(sty, opts.Status, "Edit", width, opts.Compact, file)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if !opts.HasResult() {
+		return header
+	}
+
+	// Get diff content from metadata.
+	var meta tools.EditResponseMetadata
+	if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err != nil {
+		bodyWidth := width - toolBodyLeftPaddingTotal
+		body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+		return joinToolParts(header, body)
+	}
+
+	// Render diff.
+	body := toolOutputDiffContent(sty, file, meta.OldContent, meta.NewContent, width, opts.ExpandedContent)
+	return joinToolParts(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// MultiEdit Tool
+// -----------------------------------------------------------------------------
+
+// MultiEditToolMessageItem is a message item that represents a multi-edit tool call.
+type MultiEditToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*MultiEditToolMessageItem)(nil)
+
+// NewMultiEditToolMessageItem creates a new [MultiEditToolMessageItem].
+func NewMultiEditToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &MultiEditToolRenderContext{}, canceled)
+}
+
+// MultiEditToolRenderContext renders multi-edit tool messages.
+type MultiEditToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	// MultiEdit tool uses full width for diffs.
+	if opts.IsPending() {
+		return pendingTool(sty, "Multi-Edit", opts.Anim)
+	}
+
+	var params tools.MultiEditParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width)
+	}
+
+	file := fsext.PrettyPath(params.FilePath)
+	toolParams := []string{file}
+	if len(params.Edits) > 0 {
+		toolParams = append(toolParams, "edits", fmt.Sprintf("%d", len(params.Edits)))
+	}
+
+	header := toolHeader(sty, opts.Status, "Multi-Edit", width, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if !opts.HasResult() {
+		return header
+	}
+
+	// Get diff content from metadata.
+	var meta tools.MultiEditResponseMetadata
+	if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err != nil {
+		bodyWidth := width - toolBodyLeftPaddingTotal
+		body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+		return joinToolParts(header, body)
+	}
+
+	// Render diff with optional failed edits note.
+	body := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.ExpandedContent)
+	return joinToolParts(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// Download Tool
+// -----------------------------------------------------------------------------
+
+// DownloadToolMessageItem is a message item that represents a download tool call.
+type DownloadToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*DownloadToolMessageItem)(nil)
+
+// NewDownloadToolMessageItem creates a new [DownloadToolMessageItem].
+func NewDownloadToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &DownloadToolRenderContext{}, canceled)
+}
+
+// DownloadToolRenderContext renders download tool messages.
+type DownloadToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Download", opts.Anim)
+	}
+
+	var params tools.DownloadParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	toolParams := []string{params.URL}
+	if params.FilePath != "" {
+		toolParams = append(toolParams, "file_path", fsext.PrettyPath(params.FilePath))
+	}
+	if params.Timeout != 0 {
+		toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout))
+	}
+
+	header := toolHeader(sty, opts.Status, "Download", cappedWidth, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if opts.HasEmptyResult() {
+		return header
+	}
+
+	bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+	body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+	return joinToolParts(header, body)
+}

+ 121 - 0
internal/ui/chat/mcp.go

@@ -0,0 +1,121 @@
+package chat
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/stringext"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// MCPToolMessageItem is a message item that represents a bash tool call.
+type MCPToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*MCPToolMessageItem)(nil)
+
+// NewMCPToolMessageItem creates a new [MCPToolMessageItem].
+func NewMCPToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &MCPToolRenderContext{}, canceled)
+}
+
+// MCPToolRenderContext renders bash tool messages.
+type MCPToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (b *MCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	toolNameParts := strings.SplitN(opts.ToolCall.Name, "_", 3)
+	if len(toolNameParts) != 3 {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid tool name"}, cappedWidth)
+	}
+	mcpName := prettyName(toolNameParts[1])
+	toolName := prettyName(toolNameParts[2])
+
+	mcpName = sty.Tool.MCPName.Render(mcpName)
+	toolName = sty.Tool.MCPToolName.Render(toolName)
+
+	name := fmt.Sprintf("%s %s %s", mcpName, sty.Tool.MCPArrow.String(), toolName)
+
+	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
+	// see if the result is json
+	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)
+}
+
+func prettyName(name string) string {
+	name = strings.ReplaceAll(name, "_", " ")
+	name = strings.ReplaceAll(name, "-", " ")
+	return stringext.Capitalize(name)
+}
+
+// looksLikeMarkdown checks if content appears to be markdown by looking for
+// common markdown patterns.
+func looksLikeMarkdown(content string) bool {
+	patterns := []string{
+		"# ",  // headers
+		"## ", // headers
+		"**",  // bold
+		"```", // code fence
+		"- ",  // unordered list
+		"1. ", // ordered list
+		"> ",  // blockquote
+		"---", // horizontal rule
+		"***", // horizontal rule
+	}
+	for _, p := range patterns {
+		if strings.Contains(content, p) {
+			return true
+		}
+	}
+	return false
+}

+ 312 - 0
internal/ui/chat/messages.go

@@ -0,0 +1,312 @@
+package chat
+
+import (
+	"fmt"
+	"image"
+	"strings"
+	"time"
+
+	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/message"
+	"github.com/charmbracelet/crush/internal/ui/anim"
+	"github.com/charmbracelet/crush/internal/ui/attachments"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/list"
+	"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
+
+// maxTextWidth is the maximum width text messages can be
+const maxTextWidth = 120
+
+// Identifiable is an interface for items that can provide a unique identifier.
+type Identifiable interface {
+	ID() string
+}
+
+// Animatable is an interface for items that support animation.
+type Animatable interface {
+	StartAnimation() tea.Cmd
+	Animate(msg anim.StepMsg) tea.Cmd
+}
+
+// Expandable is an interface for items that can be expanded or collapsed.
+type Expandable interface {
+	ToggleExpanded()
+}
+
+// MessageItem represents a [message.Message] item that can be displayed in the
+// UI and be part of a [list.List] identifiable by a unique ID.
+type MessageItem interface {
+	list.Item
+	list.RawRenderable
+	Identifiable
+}
+
+// HighlightableMessageItem is a message item that supports highlighting.
+type HighlightableMessageItem interface {
+	MessageItem
+	list.Highlightable
+}
+
+// FocusableMessageItem is a message item that supports focus.
+type FocusableMessageItem interface {
+	MessageItem
+	list.Focusable
+}
+
+// SendMsg represents a message to send a chat message.
+type SendMsg struct {
+	Text        string
+	Attachments []message.Attachment
+}
+
+type highlightableMessageItem struct {
+	startLine   int
+	startCol    int
+	endLine     int
+	endCol      int
+	highlighter list.Highlighter
+}
+
+var _ list.Highlightable = (*highlightableMessageItem)(nil)
+
+// isHighlighted returns true if the item has a highlight range set.
+func (h *highlightableMessageItem) isHighlighted() bool {
+	return h.startLine != -1 || h.endLine != -1
+}
+
+// renderHighlighted highlights the content if necessary.
+func (h *highlightableMessageItem) renderHighlighted(content string, width, height int) string {
+	if !h.isHighlighted() {
+		return content
+	}
+	area := image.Rect(0, 0, width, height)
+	return list.Highlight(content, area, h.startLine, h.startCol, h.endLine, h.endCol, h.highlighter)
+}
+
+// SetHighlight implements list.Highlightable.
+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
+	h.startLine = startLine
+	h.startCol = max(0, startCol-offset)
+	h.endLine = endLine
+	if endCol >= 0 {
+		h.endCol = max(0, endCol-offset)
+	} else {
+		h.endCol = endCol
+	}
+}
+
+// Highlight implements list.Highlightable.
+func (h *highlightableMessageItem) Highlight() (startLine int, startCol int, endLine int, endCol int) {
+	return h.startLine, h.startCol, h.endLine, h.endCol
+}
+
+func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem {
+	return &highlightableMessageItem{
+		startLine:   -1,
+		startCol:    -1,
+		endLine:     -1,
+		endCol:      -1,
+		highlighter: list.ToHighlighter(sty.TextSelection),
+	}
+}
+
+// cachedMessageItem caches rendered message content to avoid re-rendering.
+//
+// This should be used by any message that can store a cached version of its render. e.x user,assistant... and so on
+//
+// THOUGHT(kujtim): we should consider if its efficient to store the render for different widths
+// the issue with that could be memory usage
+type cachedMessageItem struct {
+	// rendered is the cached rendered string
+	rendered string
+	// width and height are the dimensions of the cached render
+	width  int
+	height int
+}
+
+// getCachedRender returns the cached render if it exists for the given width.
+func (c *cachedMessageItem) getCachedRender(width int) (string, int, bool) {
+	if c.width == width && c.rendered != "" {
+		return c.rendered, c.height, true
+	}
+	return "", 0, false
+}
+
+// setCachedRender sets the cached render.
+func (c *cachedMessageItem) setCachedRender(rendered string, width, height int) {
+	c.rendered = rendered
+	c.width = width
+	c.height = height
+}
+
+// clearCache clears the cached render.
+func (c *cachedMessageItem) clearCache() {
+	c.rendered = ""
+	c.width = 0
+	c.height = 0
+}
+
+// focusableMessageItem is a base struct for message items that can be focused.
+type focusableMessageItem struct {
+	focused bool
+}
+
+// SetFocused implements MessageItem.
+func (f *focusableMessageItem) SetFocused(focused bool) {
+	f.focused = focused
+}
+
+// AssistantInfoID returns a stable ID for assistant info items.
+func AssistantInfoID(messageID string) string {
+	return fmt.Sprintf("%s:assistant-info", messageID)
+}
+
+// AssistantInfoItem renders model info and response time after assistant completes.
+type AssistantInfoItem struct {
+	*cachedMessageItem
+
+	id                  string
+	message             *message.Message
+	sty                 *styles.Styles
+	lastUserMessageTime time.Time
+}
+
+// NewAssistantInfoItem creates a new AssistantInfoItem.
+func NewAssistantInfoItem(sty *styles.Styles, message *message.Message, lastUserMessageTime time.Time) MessageItem {
+	return &AssistantInfoItem{
+		cachedMessageItem:   &cachedMessageItem{},
+		id:                  AssistantInfoID(message.ID),
+		message:             message,
+		sty:                 sty,
+		lastUserMessageTime: lastUserMessageTime,
+	}
+}
+
+// ID implements MessageItem.
+func (a *AssistantInfoItem) ID() string {
+	return a.id
+}
+
+// RawRender implements MessageItem.
+func (a *AssistantInfoItem) RawRender(width int) string {
+	innerWidth := max(0, width-messageLeftPaddingTotal)
+	content, _, ok := a.getCachedRender(innerWidth)
+	if !ok {
+		content = a.renderContent(innerWidth)
+		height := lipgloss.Height(content)
+		a.setCachedRender(content, innerWidth, height)
+	}
+	return content
+}
+
+// Render implements MessageItem.
+func (a *AssistantInfoItem) Render(width int) string {
+	return a.sty.Chat.Message.SectionHeader.Render(a.RawRender(width))
+}
+
+func (a *AssistantInfoItem) renderContent(width int) string {
+	finishData := a.message.FinishPart()
+	if finishData == nil {
+		return ""
+	}
+	finishTime := time.Unix(finishData.Time, 0)
+	duration := finishTime.Sub(a.lastUserMessageTime)
+	infoMsg := a.sty.Chat.Message.AssistantInfoDuration.Render(duration.String())
+	icon := a.sty.Chat.Message.AssistantInfoIcon.Render(styles.ModelIcon)
+	model := config.Get().GetModel(a.message.Provider, a.message.Model)
+	if model == nil {
+		model = &catwalk.Model{Name: "Unknown Model"}
+	}
+	modelFormatted := a.sty.Chat.Message.AssistantInfoModel.Render(model.Name)
+	providerName := a.message.Provider
+	if providerConfig, ok := config.Get().Providers.Get(a.message.Provider); ok {
+		providerName = providerConfig.Name
+	}
+	provider := a.sty.Chat.Message.AssistantInfoProvider.Render(fmt.Sprintf("via %s", providerName))
+	assistant := fmt.Sprintf("%s %s %s %s", icon, modelFormatted, provider, infoMsg)
+	return common.Section(a.sty, assistant, width)
+}
+
+// cappedMessageWidth returns the maximum width for message content for readability.
+func cappedMessageWidth(availableWidth int) int {
+	return min(availableWidth-messageLeftPaddingTotal, maxTextWidth)
+}
+
+// ExtractMessageItems extracts [MessageItem]s from a [message.Message]. It
+// returns all parts of the message as [MessageItem]s.
+//
+// For assistant messages with tool calls, pass a toolResults map to link results.
+// Use BuildToolResultMap to create this map from all messages in a session.
+func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
+	switch msg.Role {
+	case message.User:
+		r := attachments.NewRenderer(
+			sty.Attachments.Normal,
+			sty.Attachments.Deleting,
+			sty.Attachments.Image,
+			sty.Attachments.Text,
+		)
+		return []MessageItem{NewUserMessageItem(sty, msg, r)}
+	case message.Assistant:
+		var items []MessageItem
+		if ShouldRenderAssistantMessage(msg) {
+			items = append(items, NewAssistantMessageItem(sty, msg))
+		}
+		for _, tc := range msg.ToolCalls() {
+			var result *message.ToolResult
+			if tr, ok := toolResults[tc.ID]; ok {
+				result = &tr
+			}
+			items = append(items, NewToolMessageItem(
+				sty,
+				msg.ID,
+				tc,
+				result,
+				msg.FinishReason() == message.FinishReasonCanceled,
+			))
+		}
+		return items
+	}
+	return []MessageItem{}
+}
+
+// ShouldRenderAssistantMessage determines if an assistant message should be rendered
+//
+// In some cases the assistant message only has tools so we do not want to render an
+// empty message.
+func ShouldRenderAssistantMessage(msg *message.Message) bool {
+	content := strings.TrimSpace(msg.Content().Text)
+	thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
+	isError := msg.FinishReason() == message.FinishReasonError
+	isCancelled := msg.FinishReason() == message.FinishReasonCanceled
+	hasToolCalls := len(msg.ToolCalls()) > 0
+	return !hasToolCalls || content != "" || thinking != "" || msg.IsThinking() || isError || isCancelled
+}
+
+// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages.
+// Tool result messages (role == message.Tool) contain the results that should be linked
+// to tool calls in assistant messages.
+func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResult {
+	resultMap := make(map[string]message.ToolResult)
+	for _, msg := range messages {
+		if msg.Role == message.Tool {
+			for _, result := range msg.ToolResults() {
+				if result.ToolCallID != "" {
+					resultMap[result.ToolCallID] = result
+				}
+			}
+		}
+	}
+	return resultMap
+}

+ 63 - 0
internal/ui/chat/references.go

@@ -0,0 +1,63 @@
+package chat
+
+import (
+	"encoding/json"
+
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// ReferencesToolMessageItem is a message item that represents a references tool call.
+type ReferencesToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*ReferencesToolMessageItem)(nil)
+
+// NewReferencesToolMessageItem creates a new [ReferencesToolMessageItem].
+func NewReferencesToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &ReferencesToolRenderContext{}, canceled)
+}
+
+// ReferencesToolRenderContext renders references tool messages.
+type ReferencesToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (r *ReferencesToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Find References", opts.Anim)
+	}
+
+	var params tools.ReferencesParams
+	_ = json.Unmarshal([]byte(opts.ToolCall.Input), &params)
+
+	toolParams := []string{params.Symbol}
+	if params.Path != "" {
+		toolParams = append(toolParams, "path", fsext.PrettyPath(params.Path))
+	}
+
+	header := toolHeader(sty, opts.Status, "Find References", cappedWidth, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if opts.HasEmptyResult() {
+		return header
+	}
+
+	bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+	body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+	return joinToolParts(header, body)
+}

+ 256 - 0
internal/ui/chat/search.go

@@ -0,0 +1,256 @@
+package chat
+
+import (
+	"encoding/json"
+
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// -----------------------------------------------------------------------------
+// Glob Tool
+// -----------------------------------------------------------------------------
+
+// GlobToolMessageItem is a message item that represents a glob tool call.
+type GlobToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*GlobToolMessageItem)(nil)
+
+// NewGlobToolMessageItem creates a new [GlobToolMessageItem].
+func NewGlobToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &GlobToolRenderContext{}, canceled)
+}
+
+// GlobToolRenderContext renders glob tool messages.
+type GlobToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Glob", opts.Anim)
+	}
+
+	var params tools.GlobParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	toolParams := []string{params.Pattern}
+	if params.Path != "" {
+		toolParams = append(toolParams, "path", params.Path)
+	}
+
+	header := toolHeader(sty, opts.Status, "Glob", 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
+	body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+	return joinToolParts(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// Grep Tool
+// -----------------------------------------------------------------------------
+
+// GrepToolMessageItem is a message item that represents a grep tool call.
+type GrepToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*GrepToolMessageItem)(nil)
+
+// NewGrepToolMessageItem creates a new [GrepToolMessageItem].
+func NewGrepToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &GrepToolRenderContext{}, canceled)
+}
+
+// GrepToolRenderContext renders grep tool messages.
+type GrepToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Grep", opts.Anim)
+	}
+
+	var params tools.GrepParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	toolParams := []string{params.Pattern}
+	if params.Path != "" {
+		toolParams = append(toolParams, "path", params.Path)
+	}
+	if params.Include != "" {
+		toolParams = append(toolParams, "include", params.Include)
+	}
+	if params.LiteralText {
+		toolParams = append(toolParams, "literal", "true")
+	}
+
+	header := toolHeader(sty, opts.Status, "Grep", cappedWidth, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if opts.HasEmptyResult() {
+		return header
+	}
+
+	bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+	body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+	return joinToolParts(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// LS Tool
+// -----------------------------------------------------------------------------
+
+// LSToolMessageItem is a message item that represents an ls tool call.
+type LSToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*LSToolMessageItem)(nil)
+
+// NewLSToolMessageItem creates a new [LSToolMessageItem].
+func NewLSToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &LSToolRenderContext{}, canceled)
+}
+
+// LSToolRenderContext renders ls tool messages.
+type LSToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "List", opts.Anim)
+	}
+
+	var params tools.LSParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	path := params.Path
+	if path == "" {
+		path = "."
+	}
+	path = fsext.PrettyPath(path)
+
+	header := toolHeader(sty, opts.Status, "List", cappedWidth, opts.Compact, path)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if opts.HasEmptyResult() {
+		return header
+	}
+
+	bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+	body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+	return joinToolParts(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// Sourcegraph Tool
+// -----------------------------------------------------------------------------
+
+// SourcegraphToolMessageItem is a message item that represents a sourcegraph tool call.
+type SourcegraphToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*SourcegraphToolMessageItem)(nil)
+
+// NewSourcegraphToolMessageItem creates a new [SourcegraphToolMessageItem].
+func NewSourcegraphToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &SourcegraphToolRenderContext{}, canceled)
+}
+
+// SourcegraphToolRenderContext renders sourcegraph tool messages.
+type SourcegraphToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Sourcegraph", opts.Anim)
+	}
+
+	var params tools.SourcegraphParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	toolParams := []string{params.Query}
+	if params.Count != 0 {
+		toolParams = append(toolParams, "count", formatNonZero(params.Count))
+	}
+	if params.ContextWindow != 0 {
+		toolParams = append(toolParams, "context", formatNonZero(params.ContextWindow))
+	}
+
+	header := toolHeader(sty, opts.Status, "Sourcegraph", cappedWidth, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if opts.HasEmptyResult() {
+		return header
+	}
+
+	bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+	body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+	return joinToolParts(header, body)
+}

+ 192 - 0
internal/ui/chat/todos.go

@@ -0,0 +1,192 @@
+package chat
+
+import (
+	"encoding/json"
+	"fmt"
+	"slices"
+	"strings"
+
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// -----------------------------------------------------------------------------
+// Todos Tool
+// -----------------------------------------------------------------------------
+
+// TodosToolMessageItem is a message item that represents a todos tool call.
+type TodosToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*TodosToolMessageItem)(nil)
+
+// NewTodosToolMessageItem creates a new [TodosToolMessageItem].
+func NewTodosToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &TodosToolRenderContext{}, canceled)
+}
+
+// TodosToolRenderContext renders todos tool messages.
+type TodosToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "To-Do", opts.Anim)
+	}
+
+	var params tools.TodosParams
+	var meta tools.TodosResponseMetadata
+	var headerText string
+	var body string
+
+	// Parse params for pending state (before result is available).
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err == nil {
+		completedCount := 0
+		inProgressTask := ""
+		for _, todo := range params.Todos {
+			if todo.Status == "completed" {
+				completedCount++
+			}
+			if todo.Status == "in_progress" {
+				if todo.ActiveForm != "" {
+					inProgressTask = todo.ActiveForm
+				} else {
+					inProgressTask = todo.Content
+				}
+			}
+		}
+
+		// Default display from params (used when pending or no metadata).
+		ratio := sty.Tool.TodoRatio.Render(fmt.Sprintf("%d/%d", completedCount, len(params.Todos)))
+		headerText = ratio
+		if inProgressTask != "" {
+			headerText = fmt.Sprintf("%s · %s", ratio, inProgressTask)
+		}
+
+		// If we have metadata, use it for richer display.
+		if opts.HasResult() && opts.Result.Metadata != "" {
+			if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
+				if meta.IsNew {
+					if meta.JustStarted != "" {
+						headerText = fmt.Sprintf("created %d todos, starting first", meta.Total)
+					} else {
+						headerText = fmt.Sprintf("created %d todos", meta.Total)
+					}
+					body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
+				} else {
+					// Build header based on what changed.
+					hasCompleted := len(meta.JustCompleted) > 0
+					hasStarted := meta.JustStarted != ""
+					allCompleted := meta.Completed == meta.Total
+
+					ratio := sty.Tool.TodoRatio.Render(fmt.Sprintf("%d/%d", meta.Completed, meta.Total))
+					if hasCompleted && hasStarted {
+						text := sty.Subtle.Render(fmt.Sprintf(" · completed %d, starting next", len(meta.JustCompleted)))
+						headerText = fmt.Sprintf("%s%s", ratio, text)
+					} else if hasCompleted {
+						text := sty.Subtle.Render(fmt.Sprintf(" · completed %d", len(meta.JustCompleted)))
+						if allCompleted {
+							text = sty.Subtle.Render(" · completed all")
+						}
+						headerText = fmt.Sprintf("%s%s", ratio, text)
+					} else if hasStarted {
+						headerText = fmt.Sprintf("%s%s", ratio, sty.Subtle.Render(" · starting task"))
+					} else {
+						headerText = ratio
+					}
+
+					// Build body with details.
+					if allCompleted {
+						// Show all todos when all are completed, like when created.
+						body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
+					} else if meta.JustStarted != "" {
+						body = sty.Tool.TodoInProgressIcon.Render(styles.ArrowRightIcon+" ") +
+							sty.Base.Render(meta.JustStarted)
+					}
+				}
+			}
+		}
+	}
+
+	toolParams := []string{headerText}
+	header := toolHeader(sty, opts.Status, "To-Do", cappedWidth, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if body == "" {
+		return header
+	}
+
+	return joinToolParts(header, sty.Tool.Body.Render(body))
+}
+
+// FormatTodosList formats a list of todos for display.
+func FormatTodosList(sty *styles.Styles, todos []session.Todo, inProgressIcon string, width int) string {
+	if len(todos) == 0 {
+		return ""
+	}
+
+	sorted := make([]session.Todo, len(todos))
+	copy(sorted, todos)
+	sortTodos(sorted)
+
+	var lines []string
+	for _, todo := range sorted {
+		var prefix string
+		textStyle := sty.Base
+
+		switch todo.Status {
+		case session.TodoStatusCompleted:
+			prefix = sty.Tool.TodoCompletedIcon.Render(styles.TodoCompletedIcon) + " "
+		case session.TodoStatusInProgress:
+			prefix = sty.Tool.TodoInProgressIcon.Render(inProgressIcon + " ")
+		default:
+			prefix = sty.Tool.TodoPendingIcon.Render(styles.TodoPendingIcon) + " "
+		}
+
+		text := todo.Content
+		if todo.Status == session.TodoStatusInProgress && todo.ActiveForm != "" {
+			text = todo.ActiveForm
+		}
+		line := prefix + textStyle.Render(text)
+		line = ansi.Truncate(line, width, "…")
+
+		lines = append(lines, line)
+	}
+
+	return strings.Join(lines, "\n")
+}
+
+// sortTodos sorts todos by status: completed, in_progress, pending.
+func sortTodos(todos []session.Todo) {
+	slices.SortStableFunc(todos, func(a, b session.Todo) int {
+		return statusOrder(a.Status) - statusOrder(b.Status)
+	})
+}
+
+// statusOrder returns the sort order for a todo status.
+func statusOrder(s session.TodoStatus) int {
+	switch s {
+	case session.TodoStatusCompleted:
+		return 0
+	case session.TodoStatusInProgress:
+		return 1
+	default:
+		return 2
+	}
+}

+ 807 - 0
internal/ui/chat/tools.go

@@ -0,0 +1,807 @@
+package chat
+
+import (
+	"fmt"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"charm.land/lipgloss/v2/tree"
+	"github.com/charmbracelet/crush/internal/agent"
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/anim"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// responseContextHeight limits the number of lines displayed in tool output.
+const responseContextHeight = 10
+
+// toolBodyLeftPaddingTotal represents the padding that should be applied to each tool body
+const toolBodyLeftPaddingTotal = 2
+
+// ToolStatus represents the current state of a tool call.
+type ToolStatus int
+
+const (
+	ToolStatusAwaitingPermission ToolStatus = iota
+	ToolStatusRunning
+	ToolStatusSuccess
+	ToolStatusError
+	ToolStatusCanceled
+)
+
+// ToolMessageItem represents a tool call message in the chat UI.
+type ToolMessageItem interface {
+	MessageItem
+
+	ToolCall() message.ToolCall
+	SetToolCall(tc message.ToolCall)
+	SetResult(res *message.ToolResult)
+	MessageID() string
+	SetMessageID(id string)
+	SetStatus(status ToolStatus)
+	Status() ToolStatus
+}
+
+// Compactable is an interface for tool items that can render in a compacted mode.
+// When compact mode is enabled, tools render as a compact single-line header.
+type Compactable interface {
+	SetCompact(compact bool)
+}
+
+// SpinningState contains the state passed to SpinningFunc for custom spinning logic.
+type SpinningState struct {
+	ToolCall message.ToolCall
+	Result   *message.ToolResult
+	Status   ToolStatus
+}
+
+// IsCanceled returns true if the tool status is canceled.
+func (s *SpinningState) IsCanceled() bool {
+	return s.Status == ToolStatusCanceled
+}
+
+// HasResult returns true if the result is not nil.
+func (s *SpinningState) HasResult() bool {
+	return s.Result != nil
+}
+
+// SpinningFunc is a function type for custom spinning logic.
+// Returns true if the tool should show the spinning animation.
+type SpinningFunc func(state SpinningState) bool
+
+// DefaultToolRenderContext implements the default [ToolRenderer] interface.
+type DefaultToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (d *DefaultToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	return "TODO: Implement Tool Renderer For: " + opts.ToolCall.Name
+}
+
+// ToolRenderOpts contains the data needed to render a tool call.
+type ToolRenderOpts struct {
+	ToolCall        message.ToolCall
+	Result          *message.ToolResult
+	Anim            *anim.Anim
+	ExpandedContent bool
+	Compact         bool
+	IsSpinning      bool
+	Status          ToolStatus
+}
+
+// IsPending returns true if the tool call is still pending (not finished and
+// not canceled).
+func (o *ToolRenderOpts) IsPending() bool {
+	return !o.ToolCall.Finished && !o.IsCanceled()
+}
+
+// IsCanceled returns true if the tool status is canceled.
+func (o *ToolRenderOpts) IsCanceled() bool {
+	return o.Status == ToolStatusCanceled
+}
+
+// HasResult returns true if the result is not nil.
+func (o *ToolRenderOpts) HasResult() bool {
+	return o.Result != nil
+}
+
+// HasEmptyResult returns true if the result is nil or has empty content.
+func (o *ToolRenderOpts) HasEmptyResult() bool {
+	return o.Result == nil || o.Result.Content == ""
+}
+
+// ToolRenderer represents an interface for rendering tool calls.
+type ToolRenderer interface {
+	RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string
+}
+
+// ToolRendererFunc is a function type that implements the [ToolRenderer] interface.
+type ToolRendererFunc func(sty *styles.Styles, width int, opts *ToolRenderOpts) string
+
+// RenderTool implements the ToolRenderer interface.
+func (f ToolRendererFunc) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	return f(sty, width, opts)
+}
+
+// baseToolMessageItem represents a tool call message that can be displayed in the UI.
+type baseToolMessageItem struct {
+	*highlightableMessageItem
+	*cachedMessageItem
+	*focusableMessageItem
+
+	toolRenderer ToolRenderer
+	toolCall     message.ToolCall
+	result       *message.ToolResult
+	messageID    string
+	status       ToolStatus
+	// we use this so we can efficiently cache
+	// tools that have a capped width (e.x bash.. and others)
+	hasCappedWidth bool
+	// isCompact indicates this tool should render in compact mode.
+	isCompact bool
+	// spinningFunc allows tools to override the default spinning logic.
+	// If nil, uses the default: !toolCall.Finished && !canceled.
+	spinningFunc SpinningFunc
+
+	sty             *styles.Styles
+	anim            *anim.Anim
+	expandedContent bool
+}
+
+// newBaseToolMessageItem is the internal constructor for base tool message items.
+func newBaseToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	toolRenderer ToolRenderer,
+	canceled bool,
+) *baseToolMessageItem {
+	// we only do full width for diffs (as far as I know)
+	hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName
+
+	status := ToolStatusRunning
+	if canceled {
+		status = ToolStatusCanceled
+	}
+
+	t := &baseToolMessageItem{
+		highlightableMessageItem: defaultHighlighter(sty),
+		cachedMessageItem:        &cachedMessageItem{},
+		focusableMessageItem:     &focusableMessageItem{},
+		sty:                      sty,
+		toolRenderer:             toolRenderer,
+		toolCall:                 toolCall,
+		result:                   result,
+		status:                   status,
+		hasCappedWidth:           hasCappedWidth,
+	}
+	t.anim = anim.New(anim.Settings{
+		ID:          toolCall.ID,
+		Size:        15,
+		GradColorA:  sty.Primary,
+		GradColorB:  sty.Secondary,
+		LabelColor:  sty.FgBase,
+		CycleColors: true,
+	})
+
+	return t
+}
+
+// NewToolMessageItem creates a new [ToolMessageItem] based on the tool call name.
+//
+// It returns a specific tool message item type if implemented, otherwise it
+// returns a generic tool message item. The messageID is the ID of the assistant
+// message containing this tool call.
+func NewToolMessageItem(
+	sty *styles.Styles,
+	messageID string,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	var item ToolMessageItem
+	switch toolCall.Name {
+	case tools.BashToolName:
+		item = NewBashToolMessageItem(sty, toolCall, result, canceled)
+	case tools.JobOutputToolName:
+		item = NewJobOutputToolMessageItem(sty, toolCall, result, canceled)
+	case tools.JobKillToolName:
+		item = NewJobKillToolMessageItem(sty, toolCall, result, canceled)
+	case tools.ViewToolName:
+		item = NewViewToolMessageItem(sty, toolCall, result, canceled)
+	case tools.WriteToolName:
+		item = NewWriteToolMessageItem(sty, toolCall, result, canceled)
+	case tools.EditToolName:
+		item = NewEditToolMessageItem(sty, toolCall, result, canceled)
+	case tools.MultiEditToolName:
+		item = NewMultiEditToolMessageItem(sty, toolCall, result, canceled)
+	case tools.GlobToolName:
+		item = NewGlobToolMessageItem(sty, toolCall, result, canceled)
+	case tools.GrepToolName:
+		item = NewGrepToolMessageItem(sty, toolCall, result, canceled)
+	case tools.LSToolName:
+		item = NewLSToolMessageItem(sty, toolCall, result, canceled)
+	case tools.DownloadToolName:
+		item = NewDownloadToolMessageItem(sty, toolCall, result, canceled)
+	case tools.FetchToolName:
+		item = NewFetchToolMessageItem(sty, toolCall, result, canceled)
+	case tools.SourcegraphToolName:
+		item = NewSourcegraphToolMessageItem(sty, toolCall, result, canceled)
+	case tools.DiagnosticsToolName:
+		item = NewDiagnosticsToolMessageItem(sty, toolCall, result, canceled)
+	case agent.AgentToolName:
+		item = NewAgentToolMessageItem(sty, toolCall, result, canceled)
+	case tools.AgenticFetchToolName:
+		item = NewAgenticFetchToolMessageItem(sty, toolCall, result, canceled)
+	case tools.WebFetchToolName:
+		item = NewWebFetchToolMessageItem(sty, toolCall, result, canceled)
+	case tools.WebSearchToolName:
+		item = NewWebSearchToolMessageItem(sty, toolCall, result, canceled)
+	case tools.TodosToolName:
+		item = NewTodosToolMessageItem(sty, toolCall, result, canceled)
+	case tools.ReferencesToolName:
+		item = NewReferencesToolMessageItem(sty, toolCall, result, canceled)
+	default:
+		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.SetMessageID(messageID)
+	return item
+}
+
+// SetCompact implements the Compactable interface.
+func (t *baseToolMessageItem) SetCompact(compact bool) {
+	t.isCompact = compact
+	t.clearCache()
+}
+
+// ID returns the unique identifier for this tool message item.
+func (t *baseToolMessageItem) ID() string {
+	return t.toolCall.ID
+}
+
+// StartAnimation starts the assistant message animation if it should be spinning.
+func (t *baseToolMessageItem) StartAnimation() tea.Cmd {
+	if !t.isSpinning() {
+		return nil
+	}
+	return t.anim.Start()
+}
+
+// Animate progresses the assistant message animation if it should be spinning.
+func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
+	if !t.isSpinning() {
+		return nil
+	}
+	return t.anim.Animate(msg)
+}
+
+// RawRender implements [MessageItem].
+func (t *baseToolMessageItem) RawRender(width int) string {
+	toolItemWidth := width - messageLeftPaddingTotal
+	if t.hasCappedWidth {
+		toolItemWidth = cappedMessageWidth(width)
+	}
+
+	content, height, ok := t.getCachedRender(toolItemWidth)
+	// if we are spinning or there is no cache rerender
+	if !ok || t.isSpinning() {
+		content = t.toolRenderer.RenderTool(t.sty, toolItemWidth, &ToolRenderOpts{
+			ToolCall:        t.toolCall,
+			Result:          t.result,
+			Anim:            t.anim,
+			ExpandedContent: t.expandedContent,
+			Compact:         t.isCompact,
+			IsSpinning:      t.isSpinning(),
+			Status:          t.computeStatus(),
+		})
+		height = lipgloss.Height(content)
+		// cache the rendered content
+		t.setCachedRender(content, toolItemWidth, height)
+	}
+
+	return t.renderHighlighted(content, toolItemWidth, height)
+}
+
+// Render renders the tool message item at the given width.
+func (t *baseToolMessageItem) Render(width int) string {
+	style := t.sty.Chat.Message.ToolCallBlurred
+	if t.focused {
+		style = t.sty.Chat.Message.ToolCallFocused
+	}
+
+	if t.isCompact {
+		style = t.sty.Chat.Message.ToolCallCompact
+	}
+
+	return style.Render(t.RawRender(width))
+}
+
+// ToolCall returns the tool call associated with this message item.
+func (t *baseToolMessageItem) ToolCall() message.ToolCall {
+	return t.toolCall
+}
+
+// SetToolCall sets the tool call associated with this message item.
+func (t *baseToolMessageItem) SetToolCall(tc message.ToolCall) {
+	t.toolCall = tc
+	t.clearCache()
+}
+
+// SetResult sets the tool result associated with this message item.
+func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
+	t.result = res
+	t.clearCache()
+}
+
+// MessageID returns the ID of the message containing this tool call.
+func (t *baseToolMessageItem) MessageID() string {
+	return t.messageID
+}
+
+// SetMessageID sets the ID of the message containing this tool call.
+func (t *baseToolMessageItem) SetMessageID(id string) {
+	t.messageID = id
+}
+
+// SetStatus sets the tool status.
+func (t *baseToolMessageItem) SetStatus(status ToolStatus) {
+	t.status = status
+	t.clearCache()
+}
+
+// Status returns the current tool status.
+func (t *baseToolMessageItem) Status() ToolStatus {
+	return t.status
+}
+
+// computeStatus computes the effective status considering the result.
+func (t *baseToolMessageItem) computeStatus() ToolStatus {
+	if t.result != nil {
+		if t.result.IsError {
+			return ToolStatusError
+		}
+		return ToolStatusSuccess
+	}
+	return t.status
+}
+
+// isSpinning returns true if the tool should show animation.
+func (t *baseToolMessageItem) isSpinning() bool {
+	if t.spinningFunc != nil {
+		return t.spinningFunc(SpinningState{
+			ToolCall: t.toolCall,
+			Result:   t.result,
+			Status:   t.status,
+		})
+	}
+	return !t.toolCall.Finished && t.status != ToolStatusCanceled
+}
+
+// SetSpinningFunc sets a custom function to determine if the tool should spin.
+func (t *baseToolMessageItem) SetSpinningFunc(fn SpinningFunc) {
+	t.spinningFunc = fn
+}
+
+// ToggleExpanded toggles the expanded state of the thinking box.
+func (t *baseToolMessageItem) ToggleExpanded() {
+	t.expandedContent = !t.expandedContent
+	t.clearCache()
+}
+
+// HandleMouseClick implements MouseClickable.
+func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
+	if btn != ansi.MouseLeft {
+		return false
+	}
+	t.ToggleExpanded()
+	return true
+}
+
+// pendingTool renders a tool that is still in progress with an animation.
+func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string {
+	icon := sty.Tool.IconPending.Render()
+	toolName := sty.Tool.NameNormal.Render(name)
+
+	var animView string
+	if anim != nil {
+		animView = anim.Render()
+	}
+
+	return fmt.Sprintf("%s %s %s", icon, toolName, animView)
+}
+
+// toolEarlyStateContent handles error/cancelled/pending states before content rendering.
+// Returns the rendered output and true if early state was handled.
+func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int) (string, bool) {
+	var msg string
+	switch opts.Status {
+	case ToolStatusError:
+		msg = toolErrorContent(sty, opts.Result, width)
+	case ToolStatusCanceled:
+		msg = sty.Tool.StateCancelled.Render("Canceled.")
+	case ToolStatusAwaitingPermission:
+		msg = sty.Tool.StateWaiting.Render("Requesting permission...")
+	case ToolStatusRunning:
+		msg = sty.Tool.StateWaiting.Render("Waiting for tool response...")
+	default:
+		return "", false
+	}
+	return msg, true
+}
+
+// toolErrorContent formats an error message with ERROR tag.
+func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string {
+	if result == nil {
+		return ""
+	}
+	errContent := strings.ReplaceAll(result.Content, "\n", " ")
+	errTag := sty.Tool.ErrorTag.Render("ERROR")
+	tagWidth := lipgloss.Width(errTag)
+	errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")
+	return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent))
+}
+
+// toolIcon returns the status icon for a tool call.
+// toolIcon returns the status icon for a tool call based on its status.
+func toolIcon(sty *styles.Styles, status ToolStatus) string {
+	switch status {
+	case ToolStatusSuccess:
+		return sty.Tool.IconSuccess.String()
+	case ToolStatusError:
+		return sty.Tool.IconError.String()
+	case ToolStatusCanceled:
+		return sty.Tool.IconCancelled.String()
+	default:
+		return sty.Tool.IconPending.String()
+	}
+}
+
+// toolParamList formats parameters as "main (key=value, ...)" with truncation.
+// toolParamList formats tool parameters as "main (key=value, ...)" with truncation.
+func toolParamList(sty *styles.Styles, params []string, width int) string {
+	// minSpaceForMainParam is the min space required for the main param
+	// if this is less that the value set we will only show the main param nothing else
+	const minSpaceForMainParam = 30
+	if len(params) == 0 {
+		return ""
+	}
+
+	mainParam := params[0]
+
+	// Build key=value pairs from remaining params (consecutive key, value pairs).
+	var kvPairs []string
+	for i := 1; i+1 < len(params); i += 2 {
+		if params[i+1] != "" {
+			kvPairs = append(kvPairs, fmt.Sprintf("%s=%s", params[i], params[i+1]))
+		}
+	}
+
+	// Try to include key=value pairs if there's enough space.
+	output := mainParam
+	if len(kvPairs) > 0 {
+		partsStr := strings.Join(kvPairs, ", ")
+		if remaining := width - lipgloss.Width(partsStr) - 3; remaining >= minSpaceForMainParam {
+			output = fmt.Sprintf("%s (%s)", mainParam, partsStr)
+		}
+	}
+
+	if width >= 0 {
+		output = ansi.Truncate(output, width, "…")
+	}
+	return sty.Tool.ParamMain.Render(output)
+}
+
+// toolHeader builds the tool header line: "● ToolName params..."
+func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, nested bool, params ...string) string {
+	icon := toolIcon(sty, status)
+	nameStyle := sty.Tool.NameNormal
+	if nested {
+		nameStyle = sty.Tool.NameNested
+	}
+	toolName := nameStyle.Render(name)
+	prefix := fmt.Sprintf("%s %s ", icon, toolName)
+	prefixWidth := lipgloss.Width(prefix)
+	remainingWidth := width - prefixWidth
+	paramsStr := toolParamList(sty, params, remainingWidth)
+	return prefix + paramsStr
+}
+
+// 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)
+	lines := strings.Split(content, "\n")
+
+	maxLines := responseContextHeight
+	if expanded {
+		maxLines = len(lines) // Show all
+	}
+
+	var out []string
+	for i, ln := range lines {
+		if i >= maxLines {
+			break
+		}
+		ln = " " + ln
+		if lipgloss.Width(ln) > width {
+			ln = ansi.Truncate(ln, width, "…")
+		}
+		out = append(out, sty.Tool.ContentLine.Width(width).Render(ln))
+	}
+
+	wasTruncated := len(lines) > responseContextHeight
+
+	if !expanded && wasTruncated {
+		out = append(out, sty.Tool.ContentTruncation.
+			Width(width).
+			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-responseContextHeight)))
+	}
+
+	return strings.Join(out, "\n")
+}
+
+// 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", "    ")
+
+	lines := strings.Split(content, "\n")
+	maxLines := responseContextHeight
+	if expanded {
+		maxLines = len(lines)
+	}
+
+	// Truncate if needed.
+	displayLines := lines
+	if len(lines) > maxLines {
+		displayLines = lines[:maxLines]
+	}
+
+	bg := sty.Tool.ContentCodeBg
+	highlighted, _ := common.SyntaxHighlight(sty, strings.Join(displayLines, "\n"), path, bg)
+	highlightedLines := strings.Split(highlighted, "\n")
+
+	// Calculate line number width.
+	maxLineNumber := len(displayLines) + offset
+	maxDigits := getDigits(maxLineNumber)
+	numFmt := fmt.Sprintf("%%%dd", maxDigits)
+
+	bodyWidth := width - toolBodyLeftPaddingTotal
+	codeWidth := bodyWidth - maxDigits - 4 // -4 for line number padding
+
+	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, "…")
+		}
+
+		codeLine := sty.Tool.ContentCodeLine.
+			Width(codeWidth).
+			PaddingLeft(2).
+			Render(ln)
+
+		out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine))
+	}
+
+	// Add truncation message if needed.
+	if len(lines) > maxLines && !expanded {
+		out = append(out, sty.Tool.ContentCodeTruncation.
+			Width(bodyWidth).
+			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
+		)
+	}
+
+	return sty.Tool.Body.Render(strings.Join(out, "\n"))
+}
+
+// toolOutputImageContent renders image data with size info.
+func toolOutputImageContent(sty *styles.Styles, data, mediaType string) string {
+	dataSize := len(data) * 3 / 4
+	sizeStr := formatSize(dataSize)
+
+	loaded := sty.Base.Foreground(sty.Green).Render("Loaded")
+	arrow := sty.Base.Foreground(sty.GreenDark).Render("→")
+	typeStyled := sty.Base.Render(mediaType)
+	sizeStyled := sty.Subtle.Render(sizeStr)
+
+	return sty.Tool.Body.Render(fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled))
+}
+
+// getDigits returns the number of digits in a number.
+func getDigits(n int) int {
+	if n == 0 {
+		return 1
+	}
+	if n < 0 {
+		n = -n
+	}
+	digits := 0
+	for n > 0 {
+		n /= 10
+		digits++
+	}
+	return digits
+}
+
+// formatSize formats byte size into human readable format.
+func formatSize(bytes int) string {
+	const (
+		kb = 1024
+		mb = kb * 1024
+	)
+	switch {
+	case bytes >= mb:
+		return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
+	case bytes >= kb:
+		return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
+	default:
+		return fmt.Sprintf("%d B", bytes)
+	}
+}
+
+// toolOutputDiffContent renders a diff between old and new content.
+func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent string, width int, expanded bool) string {
+	bodyWidth := width - toolBodyLeftPaddingTotal
+
+	formatter := common.DiffFormatter(sty).
+		Before(file, oldContent).
+		After(file, newContent).
+		Width(bodyWidth)
+
+	// Use split view for wide terminals.
+	if width > maxTextWidth {
+		formatter = formatter.Split()
+	}
+
+	formatted := formatter.String()
+	lines := strings.Split(formatted, "\n")
+
+	// Truncate if needed.
+	maxLines := responseContextHeight
+	if expanded {
+		maxLines = len(lines)
+	}
+
+	if len(lines) > maxLines && !expanded {
+		truncMsg := sty.Tool.DiffTruncation.
+			Width(bodyWidth).
+			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
+		formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n")
+	}
+
+	return sty.Tool.Body.Render(formatted)
+}
+
+// formatTimeout converts timeout seconds to a duration string (e.g., "30s").
+// Returns empty string if timeout is 0.
+func formatTimeout(timeout int) string {
+	if timeout == 0 {
+		return ""
+	}
+	return fmt.Sprintf("%ds", timeout)
+}
+
+// formatNonZero returns string representation of non-zero integers, empty string for zero.
+func formatNonZero(value int) string {
+	if value == 0 {
+		return ""
+	}
+	return fmt.Sprintf("%d", value)
+}
+
+// toolOutputMultiEditDiffContent renders a diff with optional failed edits note.
+func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string {
+	bodyWidth := width - toolBodyLeftPaddingTotal
+
+	formatter := common.DiffFormatter(sty).
+		Before(file, meta.OldContent).
+		After(file, meta.NewContent).
+		Width(bodyWidth)
+
+	// Use split view for wide terminals.
+	if width > maxTextWidth {
+		formatter = formatter.Split()
+	}
+
+	formatted := formatter.String()
+	lines := strings.Split(formatted, "\n")
+
+	// Truncate if needed.
+	maxLines := responseContextHeight
+	if expanded {
+		maxLines = len(lines)
+	}
+
+	if len(lines) > maxLines && !expanded {
+		truncMsg := sty.Tool.DiffTruncation.
+			Width(bodyWidth).
+			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
+		formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n")
+	}
+
+	// Add failed edits note if any exist.
+	if len(meta.EditsFailed) > 0 {
+		noteTag := sty.Tool.NoteTag.Render("Note")
+		noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, totalEdits)
+		note := fmt.Sprintf("%s %s", noteTag, sty.Tool.NoteMessage.Render(noteMsg))
+		formatted = formatted + "\n\n" + note
+	}
+
+	return sty.Tool.Body.Render(formatted)
+}
+
+// roundedEnumerator creates a tree enumerator with rounded corners.
+func roundedEnumerator(lPadding, width int) tree.Enumerator {
+	if width == 0 {
+		width = 2
+	}
+	if lPadding == 0 {
+		lPadding = 1
+	}
+	return func(children tree.Children, index int) string {
+		line := strings.Repeat("─", width)
+		padding := strings.Repeat(" ", lPadding)
+		if children.Length()-1 == index {
+			return padding + "╰" + line
+		}
+		return padding + "├" + line
+	}
+}
+
+// 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)
+
+	// Cap width for readability.
+	if width > maxTextWidth {
+		width = maxTextWidth
+	}
+
+	renderer := common.PlainMarkdownRenderer(sty, width)
+	rendered, err := renderer.Render(content)
+	if err != nil {
+		return toolOutputPlainContent(sty, content, width, expanded)
+	}
+
+	lines := strings.Split(rendered, "\n")
+	maxLines := responseContextHeight
+	if expanded {
+		maxLines = len(lines)
+	}
+
+	var out []string
+	for i, ln := range lines {
+		if i >= maxLines {
+			break
+		}
+		out = append(out, ln)
+	}
+
+	if len(lines) > maxLines && !expanded {
+		out = append(out, sty.Tool.ContentTruncation.
+			Width(width).
+			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
+		)
+	}
+
+	return sty.Tool.Body.Render(strings.Join(out, "\n"))
+}

+ 94 - 0
internal/ui/chat/user.go

@@ -0,0 +1,94 @@
+package chat
+
+import (
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/attachments"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// UserMessageItem represents a user message in the chat UI.
+type UserMessageItem struct {
+	*highlightableMessageItem
+	*cachedMessageItem
+	*focusableMessageItem
+
+	attachments *attachments.Renderer
+	message     *message.Message
+	sty         *styles.Styles
+}
+
+// NewUserMessageItem creates a new UserMessageItem.
+func NewUserMessageItem(sty *styles.Styles, message *message.Message, attachments *attachments.Renderer) MessageItem {
+	return &UserMessageItem{
+		highlightableMessageItem: defaultHighlighter(sty),
+		cachedMessageItem:        &cachedMessageItem{},
+		focusableMessageItem:     &focusableMessageItem{},
+		attachments:              attachments,
+		message:                  message,
+		sty:                      sty,
+	}
+}
+
+// RawRender implements [MessageItem].
+func (m *UserMessageItem) RawRender(width int) string {
+	cappedWidth := cappedMessageWidth(width)
+
+	content, height, ok := m.getCachedRender(cappedWidth)
+	// cache hit
+	if ok {
+		return m.renderHighlighted(content, cappedWidth, height)
+	}
+
+	renderer := common.MarkdownRenderer(m.sty, cappedWidth)
+
+	msgContent := strings.TrimSpace(m.message.Content().Text)
+	result, err := renderer.Render(msgContent)
+	if err != nil {
+		content = msgContent
+	} else {
+		content = strings.TrimSuffix(result, "\n")
+	}
+
+	if len(m.message.BinaryContent()) > 0 {
+		attachmentsStr := m.renderAttachments(cappedWidth)
+		if content == "" {
+			content = attachmentsStr
+		} else {
+			content = strings.Join([]string{content, "", attachmentsStr}, "\n")
+		}
+	}
+
+	height = lipgloss.Height(content)
+	m.setCachedRender(content, cappedWidth, height)
+	return m.renderHighlighted(content, cappedWidth, height)
+}
+
+// Render implements MessageItem.
+func (m *UserMessageItem) Render(width int) string {
+	style := m.sty.Chat.Message.UserBlurred
+	if m.focused {
+		style = m.sty.Chat.Message.UserFocused
+	}
+	return style.Render(m.RawRender(width))
+}
+
+// ID implements MessageItem.
+func (m *UserMessageItem) ID() string {
+	return m.message.ID
+}
+
+// renderAttachments renders attachments.
+func (m *UserMessageItem) renderAttachments(width int) string {
+	var attachments []message.Attachment
+	for _, at := range m.message.BinaryContent() {
+		attachments = append(attachments, message.Attachment{
+			FileName: at.Path,
+			MimeType: at.MIMEType,
+		})
+	}
+	return m.attachments.Render(attachments, false, width)
+}

+ 69 - 0
internal/ui/common/button.go

@@ -0,0 +1,69 @@
+package common
+
+import (
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// ButtonOpts defines the configuration for a single button
+type ButtonOpts struct {
+	// Text is the button label
+	Text string
+	// UnderlineIndex is the 0-based index of the character to underline (-1 for none)
+	UnderlineIndex int
+	// Selected indicates whether this button is currently selected
+	Selected bool
+	// Padding inner horizontal padding defaults to 2 if this is 0
+	Padding int
+}
+
+// Button creates a button with an underlined character and selection state
+func Button(t *styles.Styles, opts ButtonOpts) string {
+	// Select style based on selection state
+	style := t.ButtonBlur
+	if opts.Selected {
+		style = t.ButtonFocus
+	}
+
+	text := opts.Text
+	if opts.Padding == 0 {
+		opts.Padding = 2
+	}
+
+	// the index is out of bound
+	if opts.UnderlineIndex > -1 && opts.UnderlineIndex > len(text)-1 {
+		opts.UnderlineIndex = -1
+	}
+
+	text = style.Padding(0, opts.Padding).Render(text)
+
+	if opts.UnderlineIndex != -1 {
+		text = lipgloss.StyleRanges(text, lipgloss.NewRange(opts.Padding+opts.UnderlineIndex, opts.Padding+opts.UnderlineIndex+1, style.Underline(true)))
+	}
+
+	return text
+}
+
+// ButtonGroup creates a row of selectable buttons
+// Spacing is the separator between buttons
+// Use "  " or similar for horizontal layout
+// Use "\n"  for vertical layout
+// Defaults to "  " (horizontal)
+func ButtonGroup(t *styles.Styles, buttons []ButtonOpts, spacing string) string {
+	if len(buttons) == 0 {
+		return ""
+	}
+
+	if spacing == "" {
+		spacing = "  "
+	}
+
+	parts := make([]string, len(buttons))
+	for i, button := range buttons {
+		parts[i] = Button(t, button)
+	}
+
+	return strings.Join(parts, spacing)
+}

+ 65 - 0
internal/ui/common/common.go

@@ -0,0 +1,65 @@
+package common
+
+import (
+	"fmt"
+	"image"
+	"os"
+
+	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+// MaxAttachmentSize defines the maximum allowed size for file attachments (5 MB).
+const MaxAttachmentSize = int64(5 * 1024 * 1024)
+
+// AllowedImageTypes defines the permitted image file types.
+var AllowedImageTypes = []string{".jpg", ".jpeg", ".png"}
+
+// Common defines common UI options and configurations.
+type Common struct {
+	App    *app.App
+	Styles *styles.Styles
+}
+
+// Config returns the configuration associated with this [Common] instance.
+func (c *Common) Config() *config.Config {
+	return c.App.Config()
+}
+
+// DefaultCommon returns the default common UI configurations.
+func DefaultCommon(app *app.App) *Common {
+	s := styles.DefaultStyles()
+	return &Common{
+		App:    app,
+		Styles: &s,
+	}
+}
+
+// CenterRect returns a new [Rectangle] centered within the given area with the
+// specified width and height.
+func CenterRect(area uv.Rectangle, width, height int) uv.Rectangle {
+	centerX := area.Min.X + area.Dx()/2
+	centerY := area.Min.Y + area.Dy()/2
+	minX := centerX - width/2
+	minY := centerY - height/2
+	maxX := minX + width
+	maxY := minY + height
+	return image.Rect(minX, minY, maxX, maxY)
+}
+
+// IsFileTooBig checks if the file at the given path exceeds the specified size
+// limit.
+func IsFileTooBig(filePath string, sizeLimit int64) (bool, error) {
+	fileInfo, err := os.Stat(filePath)
+	if err != nil {
+		return false, fmt.Errorf("error getting file info: %w", err)
+	}
+
+	if fileInfo.Size() > sizeLimit {
+		return true, nil
+	}
+
+	return false, nil
+}

+ 16 - 0
internal/ui/common/diff.go

@@ -0,0 +1,16 @@
+package common
+
+import (
+	"github.com/alecthomas/chroma/v2"
+	"github.com/charmbracelet/crush/internal/tui/exp/diffview"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// DiffFormatter returns a diff formatter with the given styles that can be
+// used to format diff outputs.
+func DiffFormatter(s *styles.Styles) *diffview.DiffView {
+	formatDiff := diffview.New()
+	style := chroma.MustNewStyle("crush", s.ChromaTheme())
+	diff := formatDiff.ChromaStyle(style).Style(s.Diff).TabWidth(4)
+	return diff
+}

+ 190 - 0
internal/ui/common/elements.go

@@ -0,0 +1,190 @@
+package common
+
+import (
+	"cmp"
+	"fmt"
+	"image/color"
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/home"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// PrettyPath formats a file path with home directory shortening and applies
+// muted styling.
+func PrettyPath(t *styles.Styles, path string, width int) string {
+	formatted := home.Short(path)
+	return t.Muted.Width(width).Render(formatted)
+}
+
+// ModelContextInfo contains token usage and cost information for a model.
+type ModelContextInfo struct {
+	ContextUsed  int64
+	ModelContext int64
+	Cost         float64
+}
+
+// ModelInfo renders model information including name, provider, reasoning
+// settings, and optional context usage/cost.
+func ModelInfo(t *styles.Styles, modelName, providerName, reasoningInfo string, context *ModelContextInfo, width int) string {
+	modelIcon := t.Subtle.Render(styles.ModelIcon)
+	modelName = t.Base.Render(modelName)
+
+	// Build first line with model name and optionally provider on the same line
+	var firstLine string
+	if providerName != "" {
+		providerInfo := t.Muted.Render(fmt.Sprintf("via %s", providerName))
+		modelWithProvider := fmt.Sprintf("%s %s %s", modelIcon, modelName, providerInfo)
+
+		// Check if it fits on one line
+		if lipgloss.Width(modelWithProvider) <= width {
+			firstLine = modelWithProvider
+		} else {
+			// If it doesn't fit, put provider on next line
+			firstLine = fmt.Sprintf("%s %s", modelIcon, modelName)
+		}
+	} else {
+		firstLine = fmt.Sprintf("%s %s", modelIcon, modelName)
+	}
+
+	parts := []string{firstLine}
+
+	// If provider didn't fit on first line, add it as second line
+	if providerName != "" && !strings.Contains(firstLine, "via") {
+		providerInfo := fmt.Sprintf("via %s", providerName)
+		parts = append(parts, t.Muted.PaddingLeft(2).Render(providerInfo))
+	}
+
+	if reasoningInfo != "" {
+		parts = append(parts, t.Subtle.PaddingLeft(2).Render(reasoningInfo))
+	}
+
+	if context != nil {
+		formattedInfo := formatTokensAndCost(t, context.ContextUsed, context.ModelContext, context.Cost)
+		parts = append(parts, lipgloss.NewStyle().PaddingLeft(2).Render(formattedInfo))
+	}
+
+	return lipgloss.NewStyle().Width(width).Render(
+		lipgloss.JoinVertical(lipgloss.Left, parts...),
+	)
+}
+
+// formatTokensAndCost formats token usage and cost with appropriate units
+// (K/M) and percentage of context window.
+func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost float64) string {
+	var formattedTokens string
+	switch {
+	case tokens >= 1_000_000:
+		formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
+	case tokens >= 1_000:
+		formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
+	default:
+		formattedTokens = fmt.Sprintf("%d", tokens)
+	}
+
+	if strings.HasSuffix(formattedTokens, ".0K") {
+		formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
+	}
+	if strings.HasSuffix(formattedTokens, ".0M") {
+		formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
+	}
+
+	percentage := (float64(tokens) / float64(contextWindow)) * 100
+
+	formattedCost := t.Muted.Render(fmt.Sprintf("$%.2f", cost))
+
+	formattedTokens = t.Subtle.Render(fmt.Sprintf("(%s)", formattedTokens))
+	formattedPercentage := t.Muted.Render(fmt.Sprintf("%d%%", int(percentage)))
+	formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
+	if percentage > 80 {
+		formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
+	}
+
+	return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
+}
+
+// StatusOpts defines options for rendering a status line with icon, title,
+// description, and optional extra content.
+type StatusOpts struct {
+	Icon             string // if empty no icon will be shown
+	Title            string
+	TitleColor       color.Color
+	Description      string
+	DescriptionColor color.Color
+	ExtraContent     string // additional content to append after the description
+}
+
+// Status renders a status line with icon, title, description, and extra
+// content. The description is truncated if it exceeds the available width.
+func Status(t *styles.Styles, opts StatusOpts, width int) string {
+	icon := opts.Icon
+	title := opts.Title
+	description := opts.Description
+
+	titleColor := cmp.Or(opts.TitleColor, t.Muted.GetForeground())
+	descriptionColor := cmp.Or(opts.DescriptionColor, t.Subtle.GetForeground())
+
+	title = t.Base.Foreground(titleColor).Render(title)
+
+	if description != "" {
+		extraContentWidth := lipgloss.Width(opts.ExtraContent)
+		if extraContentWidth > 0 {
+			extraContentWidth += 1
+		}
+		description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContentWidth, "…")
+		description = t.Base.Foreground(descriptionColor).Render(description)
+	}
+
+	content := []string{}
+	if icon != "" {
+		content = append(content, icon)
+	}
+	content = append(content, title)
+	if description != "" {
+		content = append(content, description)
+	}
+	if opts.ExtraContent != "" {
+		content = append(content, opts.ExtraContent)
+	}
+
+	return strings.Join(content, " ")
+}
+
+// Section renders a section header with a title and a horizontal line filling
+// the remaining width.
+func Section(t *styles.Styles, text string, width int, info ...string) string {
+	char := styles.SectionSeparator
+	length := lipgloss.Width(text) + 1
+	remainingWidth := width - length
+
+	var infoText string
+	if len(info) > 0 {
+		infoText = strings.Join(info, " ")
+		if len(infoText) > 0 {
+			infoText = " " + infoText
+			remainingWidth -= lipgloss.Width(infoText)
+		}
+	}
+
+	text = t.Section.Title.Render(text)
+	if remainingWidth > 0 {
+		text = text + " " + t.Section.Line.Render(strings.Repeat(char, remainingWidth)) + infoText
+	}
+	return text
+}
+
+// DialogTitle renders a dialog title with a decorative line filling the
+// remaining width.
+func DialogTitle(t *styles.Styles, title string, width int) string {
+	char := "╱"
+	length := lipgloss.Width(title) + 1
+	remainingWidth := width - length
+	if remainingWidth > 0 {
+		lines := strings.Repeat(char, remainingWidth)
+		lines = styles.ApplyForegroundGrad(t, lines, t.Primary, t.Secondary)
+		title = title + " " + lines
+	}
+	return title
+}

+ 57 - 0
internal/ui/common/highlight.go

@@ -0,0 +1,57 @@
+package common
+
+import (
+	"bytes"
+	"image/color"
+
+	"github.com/alecthomas/chroma/v2"
+	"github.com/alecthomas/chroma/v2/formatters"
+	"github.com/alecthomas/chroma/v2/lexers"
+	chromastyles "github.com/alecthomas/chroma/v2/styles"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// SyntaxHighlight applies syntax highlighting to the given source code based
+// on the file name and background color. It returns the highlighted code as a
+// string.
+func SyntaxHighlight(st *styles.Styles, source, fileName string, bg color.Color) (string, error) {
+	// Determine the language lexer to use
+	l := lexers.Match(fileName)
+	if l == nil {
+		l = lexers.Analyse(source)
+	}
+	if l == nil {
+		l = lexers.Fallback
+	}
+	l = chroma.Coalesce(l)
+
+	// Get the formatter
+	f := formatters.Get("terminal16m")
+	if f == nil {
+		f = formatters.Fallback
+	}
+
+	style := chroma.MustNewStyle("crush", st.ChromaTheme())
+
+	// Modify the style to use the provided background
+	s, err := style.Builder().Transform(
+		func(t chroma.StyleEntry) chroma.StyleEntry {
+			r, g, b, _ := bg.RGBA()
+			t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
+			return t
+		},
+	).Build()
+	if err != nil {
+		s = chromastyles.Fallback
+	}
+
+	// Tokenize and format
+	it, err := l.Tokenise(nil, source)
+	if err != nil {
+		return "", err
+	}
+
+	var buf bytes.Buffer
+	err = f.Format(&buf, s, it)
+	return buf.String(), err
+}

+ 11 - 0
internal/ui/common/interface.go

@@ -0,0 +1,11 @@
+package common
+
+import (
+	tea "charm.land/bubbletea/v2"
+)
+
+// Model represents a common interface for UI components.
+type Model[T any] interface {
+	Update(msg tea.Msg) (T, tea.Cmd)
+	View() string
+}

+ 26 - 0
internal/ui/common/markdown.go

@@ -0,0 +1,26 @@
+package common
+
+import (
+	"charm.land/glamour/v2"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// MarkdownRenderer returns a glamour [glamour.TermRenderer] configured with
+// the given styles and width.
+func MarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer {
+	r, _ := glamour.NewTermRenderer(
+		glamour.WithStyles(sty.Markdown),
+		glamour.WithWordWrap(width),
+	)
+	return r
+}
+
+// PlainMarkdownRenderer returns a glamour [glamour.TermRenderer] with no colors
+// (plain text with structure) and the given width.
+func PlainMarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer {
+	r, _ := glamour.NewTermRenderer(
+		glamour.WithStyles(sty.PlainMarkdown),
+		glamour.WithWordWrap(width),
+	)
+	return r
+}

+ 46 - 0
internal/ui/common/scrollbar.go

@@ -0,0 +1,46 @@
+package common
+
+import (
+	"strings"
+
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// Scrollbar renders a vertical scrollbar based on content and viewport size.
+// Returns an empty string if content fits within viewport (no scrolling needed).
+func Scrollbar(s *styles.Styles, height, contentSize, viewportSize, offset int) string {
+	if height <= 0 || contentSize <= viewportSize {
+		return ""
+	}
+
+	// Calculate thumb size (minimum 1 character).
+	thumbSize := max(1, height*viewportSize/contentSize)
+
+	// Calculate thumb position.
+	maxOffset := contentSize - viewportSize
+	if maxOffset <= 0 {
+		return ""
+	}
+
+	// Calculate where the thumb starts.
+	trackSpace := height - thumbSize
+	thumbPos := 0
+	if trackSpace > 0 && maxOffset > 0 {
+		thumbPos = min(trackSpace, offset*trackSpace/maxOffset)
+	}
+
+	// Build the scrollbar.
+	var sb strings.Builder
+	for i := range height {
+		if i > 0 {
+			sb.WriteString("\n")
+		}
+		if i >= thumbPos && i < thumbPos+thumbSize {
+			sb.WriteString(s.Dialog.ScrollbarThumb.Render(styles.ScrollbarThumb))
+		} else {
+			sb.WriteString(s.Dialog.ScrollbarTrack.Render(styles.ScrollbarTrack))
+		}
+	}
+
+	return sb.String()
+}

+ 279 - 0
internal/ui/completions/completions.go

@@ -0,0 +1,279 @@
+package completions
+
+import (
+	"slices"
+	"strings"
+
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/ui/list"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/charmbracelet/x/exp/ordered"
+)
+
+const (
+	minHeight = 1
+	maxHeight = 10
+	minWidth  = 10
+	maxWidth  = 100
+)
+
+// SelectionMsg is sent when a completion is selected.
+type SelectionMsg struct {
+	Value  any
+	Insert bool // If true, insert without closing.
+}
+
+// ClosedMsg is sent when the completions are closed.
+type ClosedMsg struct{}
+
+// FilesLoadedMsg is sent when files have been loaded for completions.
+type FilesLoadedMsg struct {
+	Files []string
+}
+
+// Completions represents the completions popup component.
+type Completions struct {
+	// Popup dimensions
+	width  int
+	height int
+
+	// State
+	open  bool
+	query string
+
+	// Key bindings
+	keyMap KeyMap
+
+	// List component
+	list *list.FilterableList
+
+	// Styling
+	normalStyle  lipgloss.Style
+	focusedStyle lipgloss.Style
+	matchStyle   lipgloss.Style
+}
+
+// New creates a new completions component.
+func New(normalStyle, focusedStyle, matchStyle lipgloss.Style) *Completions {
+	l := list.NewFilterableList()
+	l.SetGap(0)
+	l.SetReverse(true)
+
+	return &Completions{
+		keyMap:       DefaultKeyMap(),
+		list:         l,
+		normalStyle:  normalStyle,
+		focusedStyle: focusedStyle,
+		matchStyle:   matchStyle,
+	}
+}
+
+// IsOpen returns whether the completions popup is open.
+func (c *Completions) IsOpen() bool {
+	return c.open
+}
+
+// Query returns the current filter query.
+func (c *Completions) Query() string {
+	return c.query
+}
+
+// Size returns the visible size of the popup.
+func (c *Completions) Size() (width, height int) {
+	visible := len(c.list.FilteredItems())
+	return c.width, min(visible, c.height)
+}
+
+// KeyMap returns the key bindings.
+func (c *Completions) KeyMap() KeyMap {
+	return c.keyMap
+}
+
+// OpenWithFiles opens the completions with file items from the filesystem.
+func (c *Completions) OpenWithFiles(depth, limit int) tea.Cmd {
+	return func() tea.Msg {
+		files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
+		slices.Sort(files)
+		return FilesLoadedMsg{Files: files}
+	}
+}
+
+// SetFiles sets the file items on the completions popup.
+func (c *Completions) SetFiles(files []string) {
+	items := make([]list.FilterableItem, 0, len(files))
+	for _, file := range files {
+		file = strings.TrimPrefix(file, "./")
+		item := NewCompletionItem(
+			file,
+			FileCompletionValue{Path: file},
+			c.normalStyle,
+			c.focusedStyle,
+			c.matchStyle,
+		)
+		items = append(items, item)
+	}
+
+	c.open = true
+	c.query = ""
+	c.list.SetItems(items...)
+	c.list.SetFilter("") // Clear any previous filter.
+	c.list.Focus()
+
+	c.width = maxWidth
+	c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
+	c.list.SetSize(c.width, c.height)
+	c.list.SelectFirst()
+	c.list.ScrollToSelected()
+
+	// recalculate width by using just the visible items
+	start, end := c.list.VisibleItemIndices()
+	width := 0
+	if end != 0 {
+		for _, file := range files[start : end+1] {
+			width = max(width, ansi.StringWidth(file))
+		}
+	}
+	c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth))
+	c.list.SetSize(c.width, c.height)
+}
+
+// Close closes the completions popup.
+func (c *Completions) Close() {
+	c.open = false
+}
+
+// Filter filters the completions with the given query.
+func (c *Completions) Filter(query string) {
+	if !c.open {
+		return
+	}
+
+	if query == c.query {
+		return
+	}
+
+	c.query = query
+	c.list.SetFilter(query)
+
+	// recalculate width by using just the visible items
+	items := c.list.FilteredItems()
+	start, end := c.list.VisibleItemIndices()
+	width := 0
+	if end != 0 {
+		for _, item := range items[start : end+1] {
+			width = max(width, ansi.StringWidth(item.(interface{ Text() string }).Text()))
+		}
+	}
+	c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth))
+	c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
+	c.list.SetSize(c.width, c.height)
+	c.list.SelectFirst()
+	c.list.ScrollToSelected()
+}
+
+// HasItems returns whether there are visible items.
+func (c *Completions) HasItems() bool {
+	return len(c.list.FilteredItems()) > 0
+}
+
+// Update handles key events for the completions.
+func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Msg, bool) {
+	if !c.open {
+		return nil, false
+	}
+
+	switch {
+	case key.Matches(msg, c.keyMap.Up):
+		c.selectPrev()
+		return nil, true
+
+	case key.Matches(msg, c.keyMap.Down):
+		c.selectNext()
+		return nil, true
+
+	case key.Matches(msg, c.keyMap.UpInsert):
+		c.selectPrev()
+		return c.selectCurrent(true), true
+
+	case key.Matches(msg, c.keyMap.DownInsert):
+		c.selectNext()
+		return c.selectCurrent(true), true
+
+	case key.Matches(msg, c.keyMap.Select):
+		return c.selectCurrent(false), true
+
+	case key.Matches(msg, c.keyMap.Cancel):
+		c.Close()
+		return ClosedMsg{}, true
+	}
+
+	return nil, false
+}
+
+// selectPrev selects the previous item with circular navigation.
+func (c *Completions) selectPrev() {
+	items := c.list.FilteredItems()
+	if len(items) == 0 {
+		return
+	}
+	if !c.list.SelectPrev() {
+		c.list.WrapToEnd()
+	}
+	c.list.ScrollToSelected()
+}
+
+// selectNext selects the next item with circular navigation.
+func (c *Completions) selectNext() {
+	items := c.list.FilteredItems()
+	if len(items) == 0 {
+		return
+	}
+	if !c.list.SelectNext() {
+		c.list.WrapToStart()
+	}
+	c.list.ScrollToSelected()
+}
+
+// selectCurrent returns a command with the currently selected item.
+func (c *Completions) selectCurrent(insert bool) tea.Msg {
+	items := c.list.FilteredItems()
+	if len(items) == 0 {
+		return nil
+	}
+
+	selected := c.list.Selected()
+	if selected < 0 || selected >= len(items) {
+		return nil
+	}
+
+	item, ok := items[selected].(*CompletionItem)
+	if !ok {
+		return nil
+	}
+
+	if !insert {
+		c.open = false
+	}
+
+	return SelectionMsg{
+		Value:  item.Value(),
+		Insert: insert,
+	}
+}
+
+// Render renders the completions popup.
+func (c *Completions) Render() string {
+	if !c.open {
+		return ""
+	}
+
+	items := c.list.FilteredItems()
+	if len(items) == 0 {
+		return ""
+	}
+
+	return c.list.Render()
+}

+ 185 - 0
internal/ui/completions/item.go

@@ -0,0 +1,185 @@
+package completions
+
+import (
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/ui/list"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/rivo/uniseg"
+	"github.com/sahilm/fuzzy"
+)
+
+// FileCompletionValue represents a file path completion value.
+type FileCompletionValue struct {
+	Path string
+}
+
+// CompletionItem represents an item in the completions list.
+type CompletionItem struct {
+	text    string
+	value   any
+	match   fuzzy.Match
+	focused bool
+	cache   map[int]string
+
+	// Styles
+	normalStyle  lipgloss.Style
+	focusedStyle lipgloss.Style
+	matchStyle   lipgloss.Style
+}
+
+// NewCompletionItem creates a new completion item.
+func NewCompletionItem(text string, value any, normalStyle, focusedStyle, matchStyle lipgloss.Style) *CompletionItem {
+	return &CompletionItem{
+		text:         text,
+		value:        value,
+		normalStyle:  normalStyle,
+		focusedStyle: focusedStyle,
+		matchStyle:   matchStyle,
+	}
+}
+
+// Text returns the display text of the item.
+func (c *CompletionItem) Text() string {
+	return c.text
+}
+
+// Value returns the value of the item.
+func (c *CompletionItem) Value() any {
+	return c.value
+}
+
+// Filter implements [list.FilterableItem].
+func (c *CompletionItem) Filter() string {
+	return c.text
+}
+
+// SetMatch implements [list.MatchSettable].
+func (c *CompletionItem) SetMatch(m fuzzy.Match) {
+	c.cache = nil
+	c.match = m
+}
+
+// SetFocused implements [list.Focusable].
+func (c *CompletionItem) SetFocused(focused bool) {
+	if c.focused != focused {
+		c.cache = nil
+	}
+	c.focused = focused
+}
+
+// Render implements [list.Item].
+func (c *CompletionItem) Render(width int) string {
+	return renderItem(
+		c.normalStyle,
+		c.focusedStyle,
+		c.matchStyle,
+		c.text,
+		c.focused,
+		width,
+		c.cache,
+		&c.match,
+	)
+}
+
+func renderItem(
+	normalStyle, focusedStyle, matchStyle lipgloss.Style,
+	text string,
+	focused bool,
+	width int,
+	cache map[int]string,
+	match *fuzzy.Match,
+) string {
+	if cache == nil {
+		cache = make(map[int]string)
+	}
+
+	cached, ok := cache[width]
+	if ok {
+		return cached
+	}
+
+	innerWidth := width - 2 // Account for padding
+	// Truncate if needed.
+	if ansi.StringWidth(text) > innerWidth {
+		text = ansi.Truncate(text, innerWidth, "…")
+	}
+
+	// Select base style.
+	style := normalStyle
+	matchStyle = matchStyle.Background(style.GetBackground())
+	if focused {
+		style = focusedStyle
+		matchStyle = matchStyle.Background(style.GetBackground())
+	}
+
+	// Render full-width text with background.
+	content := style.Padding(0, 1).Width(width).Render(text)
+
+	// Apply match highlighting using StyleRanges.
+	if len(match.MatchedIndexes) > 0 {
+		var ranges []lipgloss.Range
+		for _, rng := range matchedRanges(match.MatchedIndexes) {
+			start, stop := bytePosToVisibleCharPos(text, rng)
+			// Offset by 1 for the padding space.
+			ranges = append(ranges, lipgloss.NewRange(start+1, stop+2, matchStyle))
+		}
+		content = lipgloss.StyleRanges(content, ranges...)
+	}
+
+	cache[width] = content
+	return content
+}
+
+// matchedRanges converts a list of match indexes into contiguous ranges.
+func matchedRanges(in []int) [][2]int {
+	if len(in) == 0 {
+		return [][2]int{}
+	}
+	current := [2]int{in[0], in[0]}
+	if len(in) == 1 {
+		return [][2]int{current}
+	}
+	var out [][2]int
+	for i := 1; i < len(in); i++ {
+		if in[i] == current[1]+1 {
+			current[1] = in[i]
+		} else {
+			out = append(out, current)
+			current = [2]int{in[i], in[i]}
+		}
+	}
+	out = append(out, current)
+	return out
+}
+
+// bytePosToVisibleCharPos converts byte positions to visible character positions.
+func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
+	bytePos, byteStart, byteStop := 0, rng[0], rng[1]
+	pos, start, stop := 0, 0, 0
+	gr := uniseg.NewGraphemes(str)
+	for byteStart > bytePos {
+		if !gr.Next() {
+			break
+		}
+		bytePos += len(gr.Str())
+		pos += max(1, gr.Width())
+	}
+	start = pos
+	for byteStop > bytePos {
+		if !gr.Next() {
+			break
+		}
+		bytePos += len(gr.Str())
+		pos += max(1, gr.Width())
+	}
+	stop = pos
+	return start, stop
+}
+
+// Ensure CompletionItem implements the required interfaces.
+var (
+	_ list.Item           = (*CompletionItem)(nil)
+	_ list.FilterableItem = (*CompletionItem)(nil)
+	_ list.MatchSettable  = (*CompletionItem)(nil)
+	_ list.Focusable      = (*CompletionItem)(nil)
+)

+ 74 - 0
internal/ui/completions/keys.go

@@ -0,0 +1,74 @@
+package completions
+
+import (
+	"charm.land/bubbles/v2/key"
+)
+
+// KeyMap defines the key bindings for the completions component.
+type KeyMap struct {
+	Down,
+	Up,
+	Select,
+	Cancel key.Binding
+	DownInsert,
+	UpInsert key.Binding
+}
+
+// DefaultKeyMap returns the default key bindings for completions.
+func DefaultKeyMap() KeyMap {
+	return KeyMap{
+		Down: key.NewBinding(
+			key.WithKeys("down"),
+			key.WithHelp("down", "move down"),
+		),
+		Up: key.NewBinding(
+			key.WithKeys("up"),
+			key.WithHelp("up", "move up"),
+		),
+		Select: key.NewBinding(
+			key.WithKeys("enter", "tab", "ctrl+y"),
+			key.WithHelp("enter", "select"),
+		),
+		Cancel: key.NewBinding(
+			key.WithKeys("esc", "alt+esc"),
+			key.WithHelp("esc", "cancel"),
+		),
+		DownInsert: key.NewBinding(
+			key.WithKeys("ctrl+n"),
+			key.WithHelp("ctrl+n", "insert next"),
+		),
+		UpInsert: key.NewBinding(
+			key.WithKeys("ctrl+p"),
+			key.WithHelp("ctrl+p", "insert previous"),
+		),
+	}
+}
+
+// KeyBindings returns all key bindings as a slice.
+func (k KeyMap) KeyBindings() []key.Binding {
+	return []key.Binding{
+		k.Down,
+		k.Up,
+		k.Select,
+		k.Cancel,
+	}
+}
+
+// FullHelp returns the full help for the key bindings.
+func (k KeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := k.KeyBindings()
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp returns the short help for the key bindings.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		k.Up,
+		k.Down,
+	}
+}

+ 165 - 0
internal/ui/dialog/actions.go

@@ -0,0 +1,165 @@
+package dialog
+
+import (
+	"fmt"
+	"net/http"
+	"os"
+	"path/filepath"
+
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/commands"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/oauth"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/uiutil"
+)
+
+// ActionClose is a message to close the current dialog.
+type ActionClose struct{}
+
+// ActionQuit is a message to quit the application.
+type ActionQuit = tea.QuitMsg
+
+// ActionOpenDialog is a message to open a dialog.
+type ActionOpenDialog struct {
+	DialogID string
+}
+
+// ActionSelectSession is a message indicating a session has been selected.
+type ActionSelectSession struct {
+	Session session.Session
+}
+
+// ActionSelectModel is a message indicating a model has been selected.
+type ActionSelectModel struct {
+	Provider  catwalk.Provider
+	Model     config.SelectedModel
+	ModelType config.SelectedModelType
+}
+
+// Messages for commands
+type (
+	ActionNewSession        struct{}
+	ActionToggleHelp        struct{}
+	ActionToggleCompactMode struct{}
+	ActionToggleThinking    struct{}
+	ActionExternalEditor    struct{}
+	ActionToggleYoloMode    struct{}
+	// ActionInitializeProject is a message to initialize a project.
+	ActionInitializeProject struct{}
+	ActionSummarize         struct {
+		SessionID string
+	}
+	// ActionSelectReasoningEffort is a message indicating a reasoning effort has been selected.
+	ActionSelectReasoningEffort struct {
+		Effort string
+	}
+	ActionPermissionResponse struct {
+		Permission permission.PermissionRequest
+		Action     PermissionAction
+	}
+	// ActionRunCustomCommand is a message to run a custom command.
+	ActionRunCustomCommand struct {
+		Content   string
+		Arguments []commands.Argument
+		Args      map[string]string // Actual argument values
+	}
+	// ActionRunMCPPrompt is a message to run a custom command.
+	ActionRunMCPPrompt struct {
+		Title       string
+		Description string
+		PromptID    string
+		ClientID    string
+		Arguments   []commands.Argument
+		Args        map[string]string // Actual argument values
+	}
+)
+
+// Messages for API key input dialog.
+type (
+	ActionChangeAPIKeyState struct {
+		State APIKeyInputState
+	}
+)
+
+// Messages for OAuth2 device flow dialog.
+type (
+	// ActionInitiateOAuth is sent when the device auth is initiated
+	// successfully.
+	ActionInitiateOAuth struct {
+		DeviceCode      string
+		UserCode        string
+		ExpiresIn       int
+		VerificationURL string
+		Interval        int
+	}
+
+	// ActionCompleteOAuth is sent when the device flow completes successfully.
+	ActionCompleteOAuth struct {
+		Token *oauth.Token
+	}
+
+	// ActionOAuthErrored is sent when the device flow encounters an error.
+	ActionOAuthErrored struct {
+		Error error
+	}
+)
+
+// ActionCmd represents an action that carries a [tea.Cmd] to be passed to the
+// Bubble Tea program loop.
+type ActionCmd struct {
+	Cmd tea.Cmd
+}
+
+// ActionFilePickerSelected is a message indicating a file has been selected in
+// the file picker dialog.
+type ActionFilePickerSelected struct {
+	Path string
+}
+
+// Cmd returns a command that reads the file at path and sends a
+// [message.Attachement] to the program.
+func (a ActionFilePickerSelected) Cmd() tea.Cmd {
+	path := a.Path
+	if path == "" {
+		return nil
+	}
+	return func() tea.Msg {
+		isFileLarge, err := common.IsFileTooBig(path, common.MaxAttachmentSize)
+		if err != nil {
+			return uiutil.InfoMsg{
+				Type: uiutil.InfoTypeError,
+				Msg:  fmt.Sprintf("unable to read the image: %v", err),
+			}
+		}
+		if isFileLarge {
+			return uiutil.InfoMsg{
+				Type: uiutil.InfoTypeError,
+				Msg:  "file too large, max 5MB",
+			}
+		}
+
+		content, err := os.ReadFile(path)
+		if err != nil {
+			return uiutil.InfoMsg{
+				Type: uiutil.InfoTypeError,
+				Msg:  fmt.Sprintf("unable to read the image: %v", err),
+			}
+		}
+
+		mimeBufferSize := min(512, len(content))
+		mimeType := http.DetectContentType(content[:mimeBufferSize])
+		fileName := filepath.Base(path)
+
+		return message.Attachment{
+			FilePath: path,
+			FileName: fileName,
+			MimeType: mimeType,
+			Content:  content,
+		}
+	}
+}

+ 302 - 0
internal/ui/dialog/api_key_input.go

@@ -0,0 +1,302 @@
+package dialog
+
+import (
+	"fmt"
+	"strings"
+	"time"
+
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"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/config"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/crush/internal/uiutil"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/x/exp/charmtone"
+)
+
+type APIKeyInputState int
+
+const (
+	APIKeyInputStateInitial APIKeyInputState = iota
+	APIKeyInputStateVerifying
+	APIKeyInputStateVerified
+	APIKeyInputStateError
+)
+
+// APIKeyInputID is the identifier for the model selection dialog.
+const APIKeyInputID = "api_key_input"
+
+// APIKeyInput represents a model selection dialog.
+type APIKeyInput struct {
+	com *common.Common
+
+	provider  catwalk.Provider
+	model     config.SelectedModel
+	modelType config.SelectedModelType
+
+	width int
+	state APIKeyInputState
+
+	keyMap struct {
+		Submit key.Binding
+		Close  key.Binding
+	}
+	input   textinput.Model
+	spinner spinner.Model
+	help    help.Model
+}
+
+var _ Dialog = (*APIKeyInput)(nil)
+
+// NewAPIKeyInput creates a new Models dialog.
+func NewAPIKeyInput(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*APIKeyInput, tea.Cmd) {
+	t := com.Styles
+
+	m := APIKeyInput{}
+	m.com = com
+	m.provider = provider
+	m.model = model
+	m.modelType = modelType
+	m.width = 60
+
+	innerWidth := m.width - t.Dialog.View.GetHorizontalFrameSize() - 2
+
+	m.input = textinput.New()
+	m.input.SetVirtualCursor(false)
+	m.input.Placeholder = "Enter you API key..."
+	m.input.SetStyles(com.Styles.TextInput)
+	m.input.Focus()
+	m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
+
+	m.spinner = spinner.New(
+		spinner.WithSpinner(spinner.Dot),
+		spinner.WithStyle(t.Base.Foreground(t.Green)),
+	)
+
+	m.help = help.New()
+	m.help.Styles = t.DialogHelpStyles()
+
+	m.keyMap.Submit = key.NewBinding(
+		key.WithKeys("enter", "ctrl+y"),
+		key.WithHelp("enter", "submit"),
+	)
+	m.keyMap.Close = CloseKey
+
+	return &m, nil
+}
+
+// ID implements Dialog.
+func (m *APIKeyInput) ID() string {
+	return APIKeyInputID
+}
+
+// HandleMsg implements [Dialog].
+func (m *APIKeyInput) HandleMsg(msg tea.Msg) Action {
+	switch msg := msg.(type) {
+	case ActionChangeAPIKeyState:
+		m.state = msg.State
+		switch m.state {
+		case APIKeyInputStateVerifying:
+			cmd := tea.Batch(m.spinner.Tick, m.verifyAPIKey)
+			return ActionCmd{cmd}
+		}
+	case spinner.TickMsg:
+		switch m.state {
+		case APIKeyInputStateVerifying:
+			var cmd tea.Cmd
+			m.spinner, cmd = m.spinner.Update(msg)
+			if cmd != nil {
+				return ActionCmd{cmd}
+			}
+		}
+	case tea.KeyPressMsg:
+		switch {
+		case m.state == APIKeyInputStateVerifying:
+			// do nothing
+		case key.Matches(msg, m.keyMap.Close):
+			switch m.state {
+			case APIKeyInputStateVerified:
+				return m.saveKeyAndContinue()
+			default:
+				return ActionClose{}
+			}
+		case key.Matches(msg, m.keyMap.Submit):
+			switch m.state {
+			case APIKeyInputStateInitial, APIKeyInputStateError:
+				return ActionChangeAPIKeyState{State: APIKeyInputStateVerifying}
+			case APIKeyInputStateVerified:
+				return m.saveKeyAndContinue()
+			}
+		default:
+			var cmd tea.Cmd
+			m.input, cmd = m.input.Update(msg)
+			if cmd != nil {
+				return ActionCmd{cmd}
+			}
+		}
+	case tea.PasteMsg:
+		var cmd tea.Cmd
+		m.input, cmd = m.input.Update(msg)
+		if cmd != nil {
+			return ActionCmd{cmd}
+		}
+	}
+	return nil
+}
+
+// Draw implements [Dialog].
+func (m *APIKeyInput) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	t := m.com.Styles
+
+	textStyle := t.Dialog.SecondaryText
+	helpStyle := t.Dialog.HelpView
+	dialogStyle := t.Dialog.View.Width(m.width)
+	inputStyle := t.Dialog.InputPrompt
+	helpStyle = helpStyle.Width(m.width - dialogStyle.GetHorizontalFrameSize())
+
+	m.input.Prompt = m.spinner.View()
+
+	content := strings.Join([]string{
+		m.headerView(),
+		inputStyle.Render(m.inputView()),
+		textStyle.Render("This will be written in your global configuration:"),
+		textStyle.Render(config.GlobalConfigData()),
+		"",
+		helpStyle.Render(m.help.View(m)),
+	}, "\n")
+
+	view := dialogStyle.Render(content)
+
+	cur := m.Cursor()
+	DrawCenterCursor(scr, area, view, cur)
+	return cur
+}
+
+func (m *APIKeyInput) headerView() string {
+	t := m.com.Styles
+	titleStyle := t.Dialog.Title
+	dialogStyle := t.Dialog.View.Width(m.width)
+
+	headerOffset := titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
+	return common.DialogTitle(t, titleStyle.Render(m.dialogTitle()), m.width-headerOffset)
+}
+
+func (m *APIKeyInput) dialogTitle() string {
+	t := m.com.Styles
+	textStyle := t.Dialog.TitleText
+	errorStyle := t.Dialog.TitleError
+	accentStyle := t.Dialog.TitleAccent
+
+	switch m.state {
+	case APIKeyInputStateInitial:
+		return textStyle.Render("Enter your ") + accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + textStyle.Render(".")
+	case APIKeyInputStateVerifying:
+		return textStyle.Render("Verifying your ") + accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + textStyle.Render("...")
+	case APIKeyInputStateVerified:
+		return accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + textStyle.Render(" validated.")
+	case APIKeyInputStateError:
+		return errorStyle.Render("Invalid ") + accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + errorStyle.Render(". Try again?")
+	}
+	return ""
+}
+
+func (m *APIKeyInput) inputView() string {
+	t := m.com.Styles
+
+	switch m.state {
+	case APIKeyInputStateInitial:
+		m.input.Prompt = "> "
+		m.input.SetStyles(t.TextInput)
+		m.input.Focus()
+	case APIKeyInputStateVerifying:
+		ts := t.TextInput
+		ts.Blurred.Prompt = ts.Focused.Prompt
+
+		m.input.Prompt = m.spinner.View()
+		m.input.SetStyles(ts)
+		m.input.Blur()
+	case APIKeyInputStateVerified:
+		ts := t.TextInput
+		ts.Blurred.Prompt = ts.Focused.Prompt
+
+		m.input.Prompt = styles.CheckIcon + " "
+		m.input.SetStyles(ts)
+		m.input.Blur()
+	case APIKeyInputStateError:
+		ts := t.TextInput
+		ts.Focused.Prompt = ts.Focused.Prompt.Foreground(charmtone.Cherry)
+
+		m.input.Prompt = styles.ErrorIcon + " "
+		m.input.SetStyles(ts)
+		m.input.Focus()
+	}
+	return m.input.View()
+}
+
+// Cursor returns the cursor position relative to the dialog.
+func (m *APIKeyInput) Cursor() *tea.Cursor {
+	return InputCursor(m.com.Styles, m.input.Cursor())
+}
+
+// FullHelp returns the full help view.
+func (m *APIKeyInput) FullHelp() [][]key.Binding {
+	return [][]key.Binding{
+		{
+			m.keyMap.Submit,
+			m.keyMap.Close,
+		},
+	}
+}
+
+// ShortHelp returns the full help view.
+func (m *APIKeyInput) ShortHelp() []key.Binding {
+	return []key.Binding{
+		m.keyMap.Submit,
+		m.keyMap.Close,
+	}
+}
+
+func (m *APIKeyInput) verifyAPIKey() tea.Msg {
+	start := time.Now()
+
+	providerConfig := config.ProviderConfig{
+		ID:      string(m.provider.ID),
+		Name:    m.provider.Name,
+		APIKey:  m.input.Value(),
+		Type:    m.provider.Type,
+		BaseURL: m.provider.APIEndpoint,
+	}
+	err := providerConfig.TestConnection(config.Get().Resolver())
+
+	// intentionally wait for at least 750ms to make sure the user sees the spinner
+	elapsed := time.Since(start)
+	minimum := 750 * time.Millisecond
+	if elapsed < minimum {
+		time.Sleep(minimum - elapsed)
+	}
+
+	if err == nil {
+		return ActionChangeAPIKeyState{APIKeyInputStateVerified}
+	}
+	return ActionChangeAPIKeyState{APIKeyInputStateError}
+}
+
+func (m *APIKeyInput) saveKeyAndContinue() Action {
+	cfg := m.com.Config()
+
+	err := cfg.SetProviderAPIKey(string(m.provider.ID), m.input.Value())
+	if err != nil {
+		return ActionCmd{uiutil.ReportError(fmt.Errorf("failed to save API key: %w", err))}
+	}
+
+	return ActionSelectModel{
+		Provider:  m.provider,
+		Model:     m.model,
+		ModelType: m.modelType,
+	}
+}

+ 399 - 0
internal/ui/dialog/arguments.go

@@ -0,0 +1,399 @@
+package dialog
+
+import (
+	"strings"
+
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/spinner"
+	"charm.land/bubbles/v2/textinput"
+	"charm.land/bubbles/v2/viewport"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"golang.org/x/text/cases"
+	"golang.org/x/text/language"
+
+	"github.com/charmbracelet/crush/internal/commands"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/uiutil"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+// ArgumentsID is the identifier for the arguments dialog.
+const ArgumentsID = "arguments"
+
+// Dialog sizing for arguments.
+const (
+	maxInputWidth        = 120
+	minInputWidth        = 30
+	maxViewportHeight    = 20
+	argumentsFieldHeight = 3 // label + input + spacing per field
+)
+
+// Arguments represents a dialog for collecting command arguments.
+type Arguments struct {
+	com       *common.Common
+	title     string
+	arguments []commands.Argument
+	inputs    []textinput.Model
+	focused   int
+	spinner   spinner.Model
+	loading   bool
+
+	description  string
+	resultAction Action
+
+	help   help.Model
+	keyMap struct {
+		Confirm,
+		Next,
+		Previous,
+		ScrollUp,
+		ScrollDown,
+		Close key.Binding
+	}
+
+	viewport viewport.Model
+}
+
+var _ Dialog = (*Arguments)(nil)
+
+// NewArguments creates a new arguments dialog.
+func NewArguments(com *common.Common, title, description string, arguments []commands.Argument, resultAction Action) *Arguments {
+	a := &Arguments{
+		com:          com,
+		title:        title,
+		description:  description,
+		arguments:    arguments,
+		resultAction: resultAction,
+	}
+
+	a.help = help.New()
+	a.help.Styles = com.Styles.DialogHelpStyles()
+
+	a.keyMap.Confirm = key.NewBinding(
+		key.WithKeys("enter"),
+		key.WithHelp("enter", "confirm"),
+	)
+	a.keyMap.Next = key.NewBinding(
+		key.WithKeys("down", "tab"),
+		key.WithHelp("↓/tab", "next"),
+	)
+	a.keyMap.Previous = key.NewBinding(
+		key.WithKeys("up", "shift+tab"),
+		key.WithHelp("↑/shift+tab", "previous"),
+	)
+	a.keyMap.Close = CloseKey
+
+	// Create input fields for each argument.
+	a.inputs = make([]textinput.Model, len(arguments))
+	for i, arg := range arguments {
+		input := textinput.New()
+		input.SetVirtualCursor(false)
+		input.SetStyles(com.Styles.TextInput)
+		input.Prompt = "> "
+		// Use description as placeholder if available, otherwise title
+		if arg.Description != "" {
+			input.Placeholder = arg.Description
+		} else {
+			input.Placeholder = arg.Title
+		}
+
+		if i == 0 {
+			input.Focus()
+		} else {
+			input.Blur()
+		}
+
+		a.inputs[i] = input
+	}
+	s := spinner.New()
+	s.Spinner = spinner.Dot
+	s.Style = com.Styles.Dialog.Spinner
+	a.spinner = s
+
+	return a
+}
+
+// ID implements Dialog.
+func (a *Arguments) ID() string {
+	return ArgumentsID
+}
+
+// focusInput changes focus to a new input by index with wrap-around.
+func (a *Arguments) focusInput(newIndex int) {
+	a.inputs[a.focused].Blur()
+
+	// Wrap around: Go's modulo can return negative, so add len first.
+	n := len(a.inputs)
+	a.focused = ((newIndex % n) + n) % n
+
+	a.inputs[a.focused].Focus()
+
+	// Ensure the newly focused field is visible in the viewport
+	a.ensureFieldVisible(a.focused)
+}
+
+// isFieldVisible checks if a field at the given index is visible in the viewport.
+func (a *Arguments) isFieldVisible(fieldIndex int) bool {
+	fieldStart := fieldIndex * argumentsFieldHeight
+	fieldEnd := fieldStart + argumentsFieldHeight - 1
+	viewportTop := a.viewport.YOffset()
+	viewportBottom := viewportTop + a.viewport.Height() - 1
+
+	return fieldStart >= viewportTop && fieldEnd <= viewportBottom
+}
+
+// ensureFieldVisible scrolls the viewport to make the field visible.
+func (a *Arguments) ensureFieldVisible(fieldIndex int) {
+	if a.isFieldVisible(fieldIndex) {
+		return
+	}
+
+	fieldStart := fieldIndex * argumentsFieldHeight
+	fieldEnd := fieldStart + argumentsFieldHeight - 1
+	viewportTop := a.viewport.YOffset()
+	viewportHeight := a.viewport.Height()
+
+	// If field is above viewport, scroll up to show it at top
+	if fieldStart < viewportTop {
+		a.viewport.SetYOffset(fieldStart)
+		return
+	}
+
+	// If field is below viewport, scroll down to show it at bottom
+	if fieldEnd > viewportTop+viewportHeight-1 {
+		a.viewport.SetYOffset(fieldEnd - viewportHeight + 1)
+	}
+}
+
+// findVisibleFieldByOffset returns the field index closest to the given viewport offset.
+func (a *Arguments) findVisibleFieldByOffset(fromTop bool) int {
+	offset := a.viewport.YOffset()
+	if !fromTop {
+		offset += a.viewport.Height() - 1
+	}
+
+	fieldIndex := offset / argumentsFieldHeight
+	if fieldIndex >= len(a.inputs) {
+		return len(a.inputs) - 1
+	}
+	return fieldIndex
+}
+
+// HandleMsg implements Dialog.
+func (a *Arguments) HandleMsg(msg tea.Msg) Action {
+	switch msg := msg.(type) {
+	case spinner.TickMsg:
+		if a.loading {
+			var cmd tea.Cmd
+			a.spinner, cmd = a.spinner.Update(msg)
+			return ActionCmd{Cmd: cmd}
+		}
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, a.keyMap.Close):
+			return ActionClose{}
+		case key.Matches(msg, a.keyMap.Confirm):
+			// If we're on the last input or there's only one input, submit.
+			if a.focused == len(a.inputs)-1 || len(a.inputs) == 1 {
+				args := make(map[string]string)
+				var warning tea.Cmd
+				for i, arg := range a.arguments {
+					args[arg.ID] = a.inputs[i].Value()
+					if arg.Required && strings.TrimSpace(a.inputs[i].Value()) == "" {
+						warning = uiutil.ReportWarn("Required argument '" + arg.Title + "' is missing.")
+						break
+					}
+				}
+				if warning != nil {
+					return ActionCmd{Cmd: warning}
+				}
+
+				switch action := a.resultAction.(type) {
+				case ActionRunCustomCommand:
+					action.Args = args
+					return action
+				case ActionRunMCPPrompt:
+					action.Args = args
+					return action
+				}
+			}
+			a.focusInput(a.focused + 1)
+		case key.Matches(msg, a.keyMap.Next):
+			a.focusInput(a.focused + 1)
+		case key.Matches(msg, a.keyMap.Previous):
+			a.focusInput(a.focused - 1)
+		default:
+			var cmd tea.Cmd
+			a.inputs[a.focused], cmd = a.inputs[a.focused].Update(msg)
+			return ActionCmd{Cmd: cmd}
+		}
+	case tea.MouseWheelMsg:
+		a.viewport, _ = a.viewport.Update(msg)
+		// If focused field scrolled out of view, focus the visible field
+		if !a.isFieldVisible(a.focused) {
+			a.focusInput(a.findVisibleFieldByOffset(msg.Button == tea.MouseWheelDown))
+		}
+	case tea.PasteMsg:
+		var cmd tea.Cmd
+		a.inputs[a.focused], cmd = a.inputs[a.focused].Update(msg)
+		return ActionCmd{Cmd: cmd}
+	}
+	return nil
+}
+
+// Cursor returns the cursor position relative to the dialog.
+// we pass the description height to offset the cursor correctly.
+func (a *Arguments) Cursor(descriptionHeight int) *tea.Cursor {
+	cursor := InputCursor(a.com.Styles, a.inputs[a.focused].Cursor())
+	if cursor == nil {
+		return nil
+	}
+	cursor.Y += descriptionHeight + a.focused*argumentsFieldHeight - a.viewport.YOffset() + 1
+	return cursor
+}
+
+// Draw implements Dialog.
+func (a *Arguments) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	s := a.com.Styles
+
+	dialogContentStyle := s.Dialog.Arguments.Content
+	possibleWidth := area.Dx() - s.Dialog.View.GetHorizontalFrameSize() - dialogContentStyle.GetHorizontalFrameSize()
+	// Build fields with label and input.
+	caser := cases.Title(language.English)
+
+	var fields []string
+	for i, arg := range a.arguments {
+		isFocused := i == a.focused
+
+		// Try to pretty up the title for the label.
+		title := strings.ReplaceAll(arg.Title, "_", " ")
+		title = strings.ReplaceAll(title, "-", " ")
+		titleParts := strings.Fields(title)
+		for i, part := range titleParts {
+			titleParts[i] = caser.String(strings.ToLower(part))
+		}
+		labelText := strings.Join(titleParts, " ")
+
+		markRequiredStyle := s.Dialog.Arguments.InputRequiredMarkBlurred
+
+		labelStyle := s.Dialog.Arguments.InputLabelBlurred
+		if isFocused {
+			labelStyle = s.Dialog.Arguments.InputLabelFocused
+			markRequiredStyle = s.Dialog.Arguments.InputRequiredMarkFocused
+		}
+		if arg.Required {
+			labelText += markRequiredStyle.String()
+		}
+		label := labelStyle.Render(labelText)
+
+		labelWidth := lipgloss.Width(labelText)
+		placeholderWidth := lipgloss.Width(a.inputs[i].Placeholder)
+
+		inputWidth := max(placeholderWidth, labelWidth, minInputWidth)
+		inputWidth = min(inputWidth, min(possibleWidth, maxInputWidth))
+		a.inputs[i].SetWidth(inputWidth)
+
+		inputLine := a.inputs[i].View()
+
+		field := lipgloss.JoinVertical(lipgloss.Left, label, inputLine, "")
+		fields = append(fields, field)
+	}
+
+	renderedFields := lipgloss.JoinVertical(lipgloss.Left, fields...)
+
+	// Anchor width to the longest field, capped at maxInputWidth.
+	const scrollbarWidth = 1
+	width := lipgloss.Width(renderedFields)
+	height := lipgloss.Height(renderedFields)
+
+	// Use standard header
+	titleStyle := s.Dialog.Title
+
+	titleText := a.title
+	if titleText == "" {
+		titleText = "Arguments"
+	}
+
+	header := common.DialogTitle(s, titleText, width)
+
+	// Add description if available.
+	var description string
+	if a.description != "" {
+		descStyle := s.Dialog.Arguments.Description.Width(width)
+		description = descStyle.Render(a.description)
+	}
+
+	helpView := s.Dialog.HelpView.Width(width).Render(a.help.View(a))
+	if a.loading {
+		helpView = s.Dialog.HelpView.Width(width).Render(a.spinner.View() + " Generating Prompt...")
+	}
+
+	availableHeight := area.Dy() - s.Dialog.View.GetVerticalFrameSize() - dialogContentStyle.GetVerticalFrameSize() - lipgloss.Height(header) - lipgloss.Height(description) - lipgloss.Height(helpView) - 2 // extra spacing
+	viewportHeight := min(height, maxViewportHeight, availableHeight)
+
+	a.viewport.SetWidth(width) // -1 for scrollbar
+	a.viewport.SetHeight(viewportHeight)
+	a.viewport.SetContent(renderedFields)
+
+	scrollbar := common.Scrollbar(s, viewportHeight, a.viewport.TotalLineCount(), viewportHeight, a.viewport.YOffset())
+	content := a.viewport.View()
+	if scrollbar != "" {
+		content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar)
+	}
+	contentParts := []string{}
+	if description != "" {
+		contentParts = append(contentParts, description)
+	}
+	contentParts = append(contentParts, content)
+
+	view := lipgloss.JoinVertical(
+		lipgloss.Left,
+		titleStyle.Render(header),
+		dialogContentStyle.Render(lipgloss.JoinVertical(lipgloss.Left, contentParts...)),
+		helpView,
+	)
+
+	dialog := s.Dialog.View.Render(view)
+
+	descriptionHeight := 0
+	if a.description != "" {
+		descriptionHeight = lipgloss.Height(description)
+	}
+	cur := a.Cursor(descriptionHeight)
+
+	DrawCenterCursor(scr, area, dialog, cur)
+	return cur
+}
+
+// StartLoading implements [LoadingDialog].
+func (a *Arguments) StartLoading() tea.Cmd {
+	if a.loading {
+		return nil
+	}
+	a.loading = true
+	return a.spinner.Tick
+}
+
+// StopLoading implements [LoadingDialog].
+func (a *Arguments) StopLoading() {
+	a.loading = false
+}
+
+// ShortHelp implements help.KeyMap.
+func (a *Arguments) ShortHelp() []key.Binding {
+	return []key.Binding{
+		a.keyMap.Confirm,
+		a.keyMap.Next,
+		a.keyMap.Close,
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (a *Arguments) FullHelp() [][]key.Binding {
+	return [][]key.Binding{
+		{a.keyMap.Confirm, a.keyMap.Next, a.keyMap.Previous},
+		{a.keyMap.Close},
+	}
+}

+ 481 - 0
internal/ui/dialog/commands.go

@@ -0,0 +1,481 @@
+package dialog
+
+import (
+	"os"
+	"strings"
+
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"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"
+	"github.com/charmbracelet/crush/internal/ui/list"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+// CommandsID is the identifier for the commands dialog.
+const CommandsID = "commands"
+
+// CommandType represents the type of commands being displayed.
+type CommandType uint
+
+// String returns the string representation of the CommandType.
+func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] }
+
+const (
+	sidebarCompactModeBreakpoint  = 120
+	defaultCommandsDialogMaxWidth = 70
+)
+
+const (
+	SystemCommands CommandType = iota
+	UserCommands
+	MCPPrompts
+)
+
+// Commands represents a dialog that shows available commands.
+type Commands struct {
+	com    *common.Common
+	keyMap struct {
+		Select,
+		UpDown,
+		Next,
+		Previous,
+		Tab,
+		ShiftTab,
+		Close key.Binding
+	}
+
+	sessionID string // can be empty for non-session-specific commands
+	selected  CommandType
+
+	spinner spinner.Model
+	loading bool
+
+	help  help.Model
+	input textinput.Model
+	list  *list.FilterableList
+
+	windowWidth int
+
+	customCommands []commands.CustomCommand
+	mcpPrompts     []commands.MCPPrompt
+}
+
+var _ Dialog = (*Commands)(nil)
+
+// NewCommands creates a new commands dialog.
+func NewCommands(com *common.Common, sessionID string, customCommands []commands.CustomCommand, mcpPrompts []commands.MCPPrompt) (*Commands, error) {
+	c := &Commands{
+		com:            com,
+		selected:       SystemCommands,
+		sessionID:      sessionID,
+		customCommands: customCommands,
+		mcpPrompts:     mcpPrompts,
+	}
+
+	help := help.New()
+	help.Styles = com.Styles.DialogHelpStyles()
+
+	c.help = help
+
+	c.list = list.NewFilterableList()
+	c.list.Focus()
+	c.list.SetSelected(0)
+
+	c.input = textinput.New()
+	c.input.SetVirtualCursor(false)
+	c.input.Placeholder = "Type to filter"
+	c.input.SetStyles(com.Styles.TextInput)
+	c.input.Focus()
+
+	c.keyMap.Select = key.NewBinding(
+		key.WithKeys("enter", "ctrl+y"),
+		key.WithHelp("enter", "confirm"),
+	)
+	c.keyMap.UpDown = key.NewBinding(
+		key.WithKeys("up", "down"),
+		key.WithHelp("↑/↓", "choose"),
+	)
+	c.keyMap.Next = key.NewBinding(
+		key.WithKeys("down"),
+		key.WithHelp("↓", "next item"),
+	)
+	c.keyMap.Previous = key.NewBinding(
+		key.WithKeys("up", "ctrl+p"),
+		key.WithHelp("↑", "previous item"),
+	)
+	c.keyMap.Tab = key.NewBinding(
+		key.WithKeys("tab"),
+		key.WithHelp("tab", "switch selection"),
+	)
+	c.keyMap.ShiftTab = key.NewBinding(
+		key.WithKeys("shift+tab"),
+		key.WithHelp("shift+tab", "switch selection prev"),
+	)
+	closeKey := CloseKey
+	closeKey.SetHelp("esc", "cancel")
+	c.keyMap.Close = closeKey
+
+	// Set initial commands
+	c.setCommandItems(c.selected)
+
+	s := spinner.New()
+	s.Spinner = spinner.Dot
+	s.Style = com.Styles.Dialog.Spinner
+	c.spinner = s
+
+	return c, nil
+}
+
+// ID implements Dialog.
+func (c *Commands) ID() string {
+	return CommandsID
+}
+
+// HandleMsg implements [Dialog].
+func (c *Commands) HandleMsg(msg tea.Msg) Action {
+	switch msg := msg.(type) {
+	case spinner.TickMsg:
+		if c.loading {
+			var cmd tea.Cmd
+			c.spinner, cmd = c.spinner.Update(msg)
+			return ActionCmd{Cmd: cmd}
+		}
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, c.keyMap.Close):
+			return ActionClose{}
+		case key.Matches(msg, c.keyMap.Previous):
+			c.list.Focus()
+			if c.list.IsSelectedFirst() {
+				c.list.SelectLast()
+				c.list.ScrollToBottom()
+				break
+			}
+			c.list.SelectPrev()
+			c.list.ScrollToSelected()
+		case key.Matches(msg, c.keyMap.Next):
+			c.list.Focus()
+			if c.list.IsSelectedLast() {
+				c.list.SelectFirst()
+				c.list.ScrollToTop()
+				break
+			}
+			c.list.SelectNext()
+			c.list.ScrollToSelected()
+		case key.Matches(msg, c.keyMap.Select):
+			if selectedItem := c.list.SelectedItem(); selectedItem != nil {
+				if item, ok := selectedItem.(*CommandItem); ok && item != nil {
+					return item.Action()
+				}
+			}
+		case key.Matches(msg, c.keyMap.Tab):
+			if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 {
+				c.selected = c.nextCommandType()
+				c.setCommandItems(c.selected)
+			}
+		case key.Matches(msg, c.keyMap.ShiftTab):
+			if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 {
+				c.selected = c.previousCommandType()
+				c.setCommandItems(c.selected)
+			}
+		default:
+			var cmd tea.Cmd
+			for _, item := range c.list.FilteredItems() {
+				if item, ok := item.(*CommandItem); ok && item != nil {
+					if msg.String() == item.Shortcut() {
+						return item.Action()
+					}
+				}
+			}
+			c.input, cmd = c.input.Update(msg)
+			value := c.input.Value()
+			c.list.SetFilter(value)
+			c.list.ScrollToTop()
+			c.list.SetSelected(0)
+			return ActionCmd{cmd}
+		}
+	}
+	return nil
+}
+
+// Cursor returns the cursor position relative to the dialog.
+func (c *Commands) Cursor() *tea.Cursor {
+	return InputCursor(c.com.Styles, c.input.Cursor())
+}
+
+// commandsRadioView generates the command type selector radio buttons.
+func commandsRadioView(sty *styles.Styles, selected CommandType, hasUserCmds bool, hasMCPPrompts bool) string {
+	if !hasUserCmds && !hasMCPPrompts {
+		return ""
+	}
+
+	selectedFn := func(t CommandType) string {
+		if t == selected {
+			return sty.RadioOn.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
+		}
+		return sty.RadioOff.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
+	}
+
+	parts := []string{
+		selectedFn(SystemCommands),
+	}
+
+	if hasUserCmds {
+		parts = append(parts, selectedFn(UserCommands))
+	}
+	if hasMCPPrompts {
+		parts = append(parts, selectedFn(MCPPrompts))
+	}
+
+	return strings.Join(parts, " ")
+}
+
+// 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()))
+	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),
+		// we need to reset the command items when width changes
+		c.setCommandItems(c.selected)
+	}
+
+	innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
+	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
+		t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
+		t.Dialog.HelpView.GetVerticalFrameSize() +
+		t.Dialog.View.GetVerticalFrameSize()
+
+	c.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
+
+	c.list.SetSize(innerWidth, height-heightOffset)
+	c.help.SetWidth(innerWidth)
+
+	rc := NewRenderContext(t, width)
+	rc.Title = "Commands"
+	rc.TitleInfo = commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpPrompts) > 0)
+	inputView := t.Dialog.InputPrompt.Render(c.input.View())
+	rc.AddPart(inputView)
+	listView := t.Dialog.List.Height(c.list.Height()).Render(c.list.Render())
+	rc.AddPart(listView)
+	rc.Help = c.help.View(c)
+
+	if c.loading {
+		rc.Help = c.spinner.View() + " Generating Prompt..."
+	}
+
+	view := rc.Render()
+
+	cur := c.Cursor()
+	DrawCenterCursor(scr, area, view, cur)
+	return cur
+}
+
+// ShortHelp implements [help.KeyMap].
+func (c *Commands) ShortHelp() []key.Binding {
+	return []key.Binding{
+		c.keyMap.Tab,
+		c.keyMap.UpDown,
+		c.keyMap.Select,
+		c.keyMap.Close,
+	}
+}
+
+// FullHelp implements [help.KeyMap].
+func (c *Commands) FullHelp() [][]key.Binding {
+	return [][]key.Binding{
+		{c.keyMap.Select, c.keyMap.Next, c.keyMap.Previous, c.keyMap.Tab},
+		{c.keyMap.Close},
+	}
+}
+
+// nextCommandType returns the next command type in the cycle.
+func (c *Commands) nextCommandType() CommandType {
+	switch c.selected {
+	case SystemCommands:
+		if len(c.customCommands) > 0 {
+			return UserCommands
+		}
+		if len(c.mcpPrompts) > 0 {
+			return MCPPrompts
+		}
+		fallthrough
+	case UserCommands:
+		if len(c.mcpPrompts) > 0 {
+			return MCPPrompts
+		}
+		fallthrough
+	case MCPPrompts:
+		return SystemCommands
+	default:
+		return SystemCommands
+	}
+}
+
+// previousCommandType returns the previous command type in the cycle.
+func (c *Commands) previousCommandType() CommandType {
+	switch c.selected {
+	case SystemCommands:
+		if len(c.mcpPrompts) > 0 {
+			return MCPPrompts
+		}
+		if len(c.customCommands) > 0 {
+			return UserCommands
+		}
+		return SystemCommands
+	case UserCommands:
+		return SystemCommands
+	case MCPPrompts:
+		if len(c.customCommands) > 0 {
+			return UserCommands
+		}
+		return SystemCommands
+	default:
+		return SystemCommands
+	}
+}
+
+// setCommandItems sets the command items based on the specified command type.
+func (c *Commands) setCommandItems(commandType CommandType) {
+	c.selected = commandType
+
+	commandItems := []list.FilterableItem{}
+	switch c.selected {
+	case SystemCommands:
+		for _, cmd := range c.defaultCommands() {
+			commandItems = append(commandItems, cmd)
+		}
+	case UserCommands:
+		for _, cmd := range c.customCommands {
+			action := ActionRunCustomCommand{
+				Content:   cmd.Content,
+				Arguments: cmd.Arguments,
+			}
+			commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
+		}
+	case MCPPrompts:
+		for _, cmd := range c.mcpPrompts {
+			action := ActionRunMCPPrompt{
+				Title:       cmd.Title,
+				Description: cmd.Description,
+				PromptID:    cmd.PromptID,
+				ClientID:    cmd.ClientID,
+				Arguments:   cmd.Arguments,
+			}
+			commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.PromptID, "", action))
+		}
+	}
+
+	c.list.SetItems(commandItems...)
+	c.list.SetFilter("")
+	c.list.ScrollToTop()
+	c.list.SetSelected(0)
+	c.input.SetValue("")
+}
+
+// defaultCommands returns the list of default system commands.
+func (c *Commands) defaultCommands() []*CommandItem {
+	commands := []*CommandItem{
+		NewCommandItem(c.com.Styles, "new_session", "New Session", "ctrl+n", ActionNewSession{}),
+		NewCommandItem(c.com.Styles, "switch_session", "Switch Session", "ctrl+s", ActionOpenDialog{SessionsID}),
+		NewCommandItem(c.com.Styles, "switch_model", "Switch Model", "ctrl+l", ActionOpenDialog{ModelsID}),
+	}
+
+	// Only show compact command if there's an active session
+	if c.sessionID != "" {
+		commands = append(commands, NewCommandItem(c.com.Styles, "summarize", "Summarize Session", "", ActionSummarize{SessionID: c.sessionID}))
+	}
+
+	// Add reasoning toggle for models that support it
+	cfg := c.com.Config()
+	if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
+		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
+		model := cfg.GetModelByType(agentCfg.Model)
+		if providerCfg != nil && model != nil && model.CanReason {
+			selectedModel := cfg.Models[agentCfg.Model]
+
+			// Anthropic models: thinking toggle
+			if providerCfg.Type == catwalk.TypeAnthropic || providerCfg.Type == catwalk.Type(hyper.Name) {
+				status := "Enable"
+				if selectedModel.Think {
+					status = "Disable"
+				}
+				commands = append(commands, NewCommandItem(c.com.Styles, "toggle_thinking", status+" Thinking Mode", "", ActionToggleThinking{}))
+			}
+
+			// OpenAI models: reasoning effort dialog
+			if len(model.ReasoningLevels) > 0 {
+				commands = append(commands, NewCommandItem(c.com.Styles, "select_reasoning_effort", "Select Reasoning Effort", "", ActionOpenDialog{
+					DialogID: ReasoningID,
+				}))
+			}
+		}
+	}
+	// Only show toggle compact mode command if window width is larger than compact breakpoint (120)
+	if c.windowWidth > sidebarCompactModeBreakpoint && c.sessionID != "" {
+		commands = append(commands, NewCommandItem(c.com.Styles, "toggle_sidebar", "Toggle Sidebar", "", ActionToggleCompactMode{}))
+	}
+	if c.sessionID != "" {
+		cfg := c.com.Config()
+		agentCfg := cfg.Agents[config.AgentCoder]
+		model := cfg.GetModelByType(agentCfg.Model)
+		if model != nil && model.SupportsImages {
+			commands = append(commands, NewCommandItem(c.com.Styles, "file_picker", "Open File Picker", "ctrl+f", ActionOpenDialog{
+				// TODO: Pass in the file picker dialog id
+			}))
+		}
+	}
+
+	// Add external editor command if $EDITOR is available
+	// TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv
+	if os.Getenv("EDITOR") != "" {
+		commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{}))
+	}
+
+	return append(commands,
+		NewCommandItem(c.com.Styles, "toggle_yolo", "Toggle Yolo Mode", "", ActionToggleYoloMode{}),
+		NewCommandItem(c.com.Styles, "toggle_help", "Toggle Help", "ctrl+g", ActionToggleHelp{}),
+		NewCommandItem(c.com.Styles, "init", "Initialize Project", "", ActionInitializeProject{}),
+		NewCommandItem(c.com.Styles, "quit", "Quit", "ctrl+c", tea.QuitMsg{}),
+	)
+}
+
+// SetCustomCommands sets the custom commands and refreshes the view if user commands are currently displayed.
+func (c *Commands) SetCustomCommands(customCommands []commands.CustomCommand) {
+	c.customCommands = customCommands
+	if c.selected == UserCommands {
+		c.setCommandItems(c.selected)
+	}
+}
+
+// SetMCPPrompts sets the MCP prompts and refreshes the view if MCP prompts are currently displayed.
+func (c *Commands) SetMCPPrompts(mcpPrompts []commands.MCPPrompt) {
+	c.mcpPrompts = mcpPrompts
+	if c.selected == MCPPrompts {
+		c.setCommandItems(c.selected)
+	}
+}
+
+// StartLoading implements [LoadingDialog].
+func (a *Commands) StartLoading() tea.Cmd {
+	if a.loading {
+		return nil
+	}
+	a.loading = true
+	return a.spinner.Tick
+}
+
+// StopLoading implements [LoadingDialog].
+func (a *Commands) StopLoading() {
+	a.loading = false
+}

+ 70 - 0
internal/ui/dialog/commands_item.go

@@ -0,0 +1,70 @@
+package dialog
+
+import (
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/sahilm/fuzzy"
+)
+
+// CommandItem wraps a uicmd.Command to implement the ListItem interface.
+type CommandItem struct {
+	id       string
+	title    string
+	shortcut string
+	action   Action
+	t        *styles.Styles
+	m        fuzzy.Match
+	cache    map[int]string
+	focused  bool
+}
+
+var _ ListItem = &CommandItem{}
+
+// NewCommandItem creates a new CommandItem.
+func NewCommandItem(t *styles.Styles, id, title, shortcut string, action Action) *CommandItem {
+	return &CommandItem{
+		id:       id,
+		t:        t,
+		title:    title,
+		shortcut: shortcut,
+		action:   action,
+	}
+}
+
+// Filter implements ListItem.
+func (c *CommandItem) Filter() string {
+	return c.title
+}
+
+// ID implements ListItem.
+func (c *CommandItem) ID() string {
+	return c.id
+}
+
+// SetFocused implements ListItem.
+func (c *CommandItem) SetFocused(focused bool) {
+	if c.focused != focused {
+		c.cache = nil
+	}
+	c.focused = focused
+}
+
+// SetMatch implements ListItem.
+func (c *CommandItem) SetMatch(m fuzzy.Match) {
+	c.cache = nil
+	c.m = m
+}
+
+// Action returns the action associated with the command item.
+func (c *CommandItem) Action() Action {
+	return c.action
+}
+
+// Shortcut returns the shortcut associated with the command item.
+func (c *CommandItem) Shortcut() string {
+	return c.shortcut
+}
+
+// Render implements ListItem.
+func (c *CommandItem) Render(width int) string {
+	return renderItem(c.t, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
+}

+ 130 - 0
internal/ui/dialog/common.go

@@ -0,0 +1,130 @@
+package dialog
+
+import (
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// InputCursor adjusts the cursor position for an input field within a dialog.
+func InputCursor(t *styles.Styles, cur *tea.Cursor) *tea.Cursor {
+	if cur != nil {
+		titleStyle := t.Dialog.Title
+		dialogStyle := t.Dialog.View
+		inputStyle := t.Dialog.InputPrompt
+		// Adjust cursor position to account for dialog layout
+		cur.X += inputStyle.GetBorderLeftSize() +
+			inputStyle.GetMarginLeft() +
+			inputStyle.GetPaddingLeft() +
+			dialogStyle.GetBorderLeftSize() +
+			dialogStyle.GetPaddingLeft() +
+			dialogStyle.GetMarginLeft()
+		cur.Y += titleStyle.GetVerticalFrameSize() +
+			inputStyle.GetBorderTopSize() +
+			inputStyle.GetMarginTop() +
+			inputStyle.GetPaddingTop() +
+			inputStyle.GetBorderBottomSize() +
+			inputStyle.GetMarginBottom() +
+			inputStyle.GetPaddingBottom() +
+			dialogStyle.GetPaddingTop() +
+			dialogStyle.GetMarginTop() +
+			dialogStyle.GetBorderTopSize()
+	}
+	return cur
+}
+
+// RenderContext is a dialog rendering context that can be used to render
+// common dialog layouts.
+type RenderContext struct {
+	// Styles is the styles to use for rendering.
+	Styles *styles.Styles
+	// Width is the total width of the dialog including any margins, borders,
+	// and paddings.
+	Width int
+	// Gap is the gap between content parts. Zero means no gap.
+	Gap int
+	// Title is the title of the dialog. This will be styled using the default
+	// dialog title style and prepended to the content parts slice.
+	Title string
+	// TitleInfo is additional information to display next to the title. This
+	// part is displayed as is, any styling must be applied before setting this
+	// field.
+	TitleInfo string
+	// Parts are the rendered parts of the dialog.
+	Parts []string
+	// Help is the help view content. This will be appended to the content parts
+	// slice using the default dialog help style.
+	Help string
+}
+
+// NewRenderContext creates a new RenderContext with the provided styles and width.
+func NewRenderContext(t *styles.Styles, width int) *RenderContext {
+	return &RenderContext{
+		Styles: t,
+		Width:  width,
+		Parts:  []string{},
+	}
+}
+
+// AddPart adds a rendered part to the dialog.
+func (rc *RenderContext) AddPart(part string) {
+	if len(part) > 0 {
+		rc.Parts = append(rc.Parts, part)
+	}
+}
+
+// Render renders the dialog using the provided context.
+func (rc *RenderContext) Render() string {
+	titleStyle := rc.Styles.Dialog.Title
+	dialogStyle := rc.Styles.Dialog.View.Width(rc.Width)
+
+	parts := []string{}
+	if len(rc.Title) > 0 {
+		var titleInfoWidth int
+		if len(rc.TitleInfo) > 0 {
+			titleInfoWidth = lipgloss.Width(rc.TitleInfo)
+		}
+		title := common.DialogTitle(rc.Styles, rc.Title,
+			max(0, rc.Width-dialogStyle.GetHorizontalFrameSize()-
+				titleStyle.GetHorizontalFrameSize()-
+				titleInfoWidth))
+		if len(rc.TitleInfo) > 0 {
+			title += rc.TitleInfo
+		}
+		parts = append(parts, titleStyle.Render(title))
+		if rc.Gap > 0 {
+			parts = append(parts, make([]string, rc.Gap)...)
+		}
+	}
+
+	if rc.Gap <= 0 {
+		parts = append(parts, rc.Parts...)
+	} else {
+		for i, p := range rc.Parts {
+			if len(p) > 0 {
+				parts = append(parts, p)
+			}
+			if i < len(rc.Parts)-1 {
+				parts = append(parts, make([]string, rc.Gap)...)
+			}
+		}
+	}
+
+	if len(rc.Help) > 0 {
+		if rc.Gap > 0 {
+			parts = append(parts, make([]string, rc.Gap)...)
+		}
+		helpStyle := rc.Styles.Dialog.HelpView
+		helpStyle = helpStyle.Width(rc.Width - dialogStyle.GetHorizontalFrameSize())
+		helpView := ansi.Truncate(helpStyle.Render(rc.Help), rc.Width, "")
+		parts = append(parts, helpView)
+	}
+
+	content := strings.Join(parts, "\n")
+
+	return dialogStyle.Render(content)
+}

+ 197 - 0
internal/ui/dialog/dialog.go

@@ -0,0 +1,197 @@
+package dialog
+
+import (
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+// Dialog sizing constants.
+const (
+	// defaultDialogMaxWidth is the maximum width for standard dialogs.
+	defaultDialogMaxWidth = 120
+	// defaultDialogHeight is the default height for standard dialogs.
+	defaultDialogHeight = 30
+	// titleContentHeight is the height of the title content line.
+	titleContentHeight = 1
+	// inputContentHeight is the height of the input content line.
+	inputContentHeight = 1
+)
+
+// CloseKey is the default key binding to close dialogs.
+var CloseKey = key.NewBinding(
+	key.WithKeys("esc", "alt+esc"),
+	key.WithHelp("esc", "exit"),
+)
+
+// Action represents an action taken in a dialog after handling a message.
+type Action any
+
+// Dialog is a component that can be displayed on top of the UI.
+type Dialog interface {
+	// ID returns the unique identifier of the dialog.
+	ID() string
+	// HandleMsg processes a message and returns an action. An [Action] can be
+	// anything and the caller is responsible for handling it appropriately.
+	HandleMsg(msg tea.Msg) Action
+	// Draw draws the dialog onto the provided screen within the specified area
+	// and returns the desired cursor position on the screen.
+	Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor
+}
+
+// LoadingDialog is a dialog that can show a loading state.
+type LoadingDialog interface {
+	StartLoading() tea.Cmd
+	StopLoading()
+}
+
+// Overlay manages multiple dialogs as an overlay.
+type Overlay struct {
+	dialogs []Dialog
+}
+
+// NewOverlay creates a new [Overlay] instance.
+func NewOverlay(dialogs ...Dialog) *Overlay {
+	return &Overlay{
+		dialogs: dialogs,
+	}
+}
+
+// HasDialogs checks if there are any active dialogs.
+func (d *Overlay) HasDialogs() bool {
+	return len(d.dialogs) > 0
+}
+
+// ContainsDialog checks if a dialog with the specified ID exists.
+func (d *Overlay) ContainsDialog(dialogID string) bool {
+	for _, dialog := range d.dialogs {
+		if dialog.ID() == dialogID {
+			return true
+		}
+	}
+	return false
+}
+
+// OpenDialog opens a new dialog to the stack.
+func (d *Overlay) OpenDialog(dialog Dialog) {
+	d.dialogs = append(d.dialogs, dialog)
+}
+
+// CloseDialog closes the dialog with the specified ID from the stack.
+func (d *Overlay) CloseDialog(dialogID string) {
+	for i, dialog := range d.dialogs {
+		if dialog.ID() == dialogID {
+			d.removeDialog(i)
+			return
+		}
+	}
+}
+
+// CloseFrontDialog closes the front dialog in the stack.
+func (d *Overlay) CloseFrontDialog() {
+	if len(d.dialogs) == 0 {
+		return
+	}
+	d.removeDialog(len(d.dialogs) - 1)
+}
+
+// Dialog returns the dialog with the specified ID, or nil if not found.
+func (d *Overlay) Dialog(dialogID string) Dialog {
+	for _, dialog := range d.dialogs {
+		if dialog.ID() == dialogID {
+			return dialog
+		}
+	}
+	return nil
+}
+
+// DialogLast returns the front dialog, or nil if there are no dialogs.
+func (d *Overlay) DialogLast() Dialog {
+	if len(d.dialogs) == 0 {
+		return nil
+	}
+	return d.dialogs[len(d.dialogs)-1]
+}
+
+// BringToFront brings the dialog with the specified ID to the front.
+func (d *Overlay) BringToFront(dialogID string) {
+	for i, dialog := range d.dialogs {
+		if dialog.ID() == dialogID {
+			// Move the dialog to the end of the slice
+			d.dialogs = append(d.dialogs[:i], d.dialogs[i+1:]...)
+			d.dialogs = append(d.dialogs, dialog)
+			return
+		}
+	}
+}
+
+// Update handles dialog updates.
+func (d *Overlay) Update(msg tea.Msg) tea.Msg {
+	if len(d.dialogs) == 0 {
+		return nil
+	}
+
+	idx := len(d.dialogs) - 1 // active dialog is the last one
+	dialog := d.dialogs[idx]
+	if dialog == nil {
+		return nil
+	}
+
+	return dialog.HandleMsg(msg)
+}
+
+// StartLoading starts the loading state for the front dialog if it
+// implements [LoadingDialog].
+func (d *Overlay) StartLoading() tea.Cmd {
+	dialog := d.DialogLast()
+	if ld, ok := dialog.(LoadingDialog); ok {
+		return ld.StartLoading()
+	}
+	return nil
+}
+
+// StopLoading stops the loading state for the front dialog if it
+// implements [LoadingDialog].
+func (d *Overlay) StopLoading() {
+	dialog := d.DialogLast()
+	if ld, ok := dialog.(LoadingDialog); ok {
+		ld.StopLoading()
+	}
+}
+
+// DrawCenterCursor draws the given string view centered in the screen area and
+// adjusts the cursor position accordingly.
+func DrawCenterCursor(scr uv.Screen, area uv.Rectangle, view string, cur *tea.Cursor) {
+	width, height := lipgloss.Size(view)
+	center := common.CenterRect(area, width, height)
+	if cur != nil {
+		cur.X += center.Min.X
+		cur.Y += center.Min.Y
+	}
+
+	uv.NewStyledString(view).Draw(scr, center)
+}
+
+// DrawCenter draws the given string view centered in the screen area.
+func DrawCenter(scr uv.Screen, area uv.Rectangle, view string) {
+	DrawCenterCursor(scr, area, view, nil)
+}
+
+// Draw renders the overlay and its dialogs.
+func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	var cur *tea.Cursor
+	for _, dialog := range d.dialogs {
+		cur = dialog.Draw(scr, area)
+	}
+	return cur
+}
+
+// removeDialog removes a dialog from the stack.
+func (d *Overlay) removeDialog(idx int) {
+	if idx < 0 || idx >= len(d.dialogs) {
+		return
+	}
+	d.dialogs = append(d.dialogs[:idx], d.dialogs[idx+1:]...)
+}

+ 304 - 0
internal/ui/dialog/filepicker.go

@@ -0,0 +1,304 @@
+package dialog
+
+import (
+	"fmt"
+	"image"
+	_ "image/jpeg" // register JPEG format
+	_ "image/png"  // register PNG format
+	"os"
+	"strings"
+	"sync"
+
+	"charm.land/bubbles/v2/filepicker"
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/home"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	fimage "github.com/charmbracelet/crush/internal/ui/image"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+// FilePickerID is the identifier for the FilePicker dialog.
+const FilePickerID = "filepicker"
+
+// FilePicker is a dialog that allows users to select files or directories.
+type FilePicker struct {
+	com *common.Common
+
+	imgEnc                      fimage.Encoding
+	imgPrevWidth, imgPrevHeight int
+	cellSize                    fimage.CellSize
+
+	fp              filepicker.Model
+	help            help.Model
+	previewingImage bool // indicates if an image is being previewed
+	isTmux          bool
+
+	km struct {
+		Select,
+		Down,
+		Up,
+		Forward,
+		Backward,
+		Navigate,
+		Close key.Binding
+	}
+}
+
+var _ Dialog = (*FilePicker)(nil)
+
+// NewFilePicker creates a new [FilePicker] dialog.
+func NewFilePicker(com *common.Common) (*FilePicker, tea.Cmd) {
+	f := new(FilePicker)
+	f.com = com
+
+	help := help.New()
+	help.Styles = com.Styles.DialogHelpStyles()
+
+	f.help = help
+
+	f.km.Select = key.NewBinding(
+		key.WithKeys("enter"),
+		key.WithHelp("enter", "accept"),
+	)
+	f.km.Down = key.NewBinding(
+		key.WithKeys("down", "j"),
+		key.WithHelp("down/j", "move down"),
+	)
+	f.km.Up = key.NewBinding(
+		key.WithKeys("up", "k"),
+		key.WithHelp("up/k", "move up"),
+	)
+	f.km.Forward = key.NewBinding(
+		key.WithKeys("right", "l"),
+		key.WithHelp("right/l", "move forward"),
+	)
+	f.km.Backward = key.NewBinding(
+		key.WithKeys("left", "h"),
+		key.WithHelp("left/h", "move backward"),
+	)
+	f.km.Navigate = key.NewBinding(
+		key.WithKeys("right", "l", "left", "h", "up", "k", "down", "j"),
+		key.WithHelp("↑↓←→", "navigate"),
+	)
+	f.km.Close = key.NewBinding(
+		key.WithKeys("esc", "alt+esc"),
+		key.WithHelp("esc", "close/exit"),
+	)
+
+	fp := filepicker.New()
+	fp.AllowedTypes = common.AllowedImageTypes
+	fp.ShowPermissions = false
+	fp.ShowSize = false
+	fp.AutoHeight = false
+	fp.Styles = com.Styles.FilePicker
+	fp.Cursor = ""
+	fp.CurrentDirectory = f.WorkingDir()
+
+	f.fp = fp
+
+	return f, f.fp.Init()
+}
+
+// SetImageCapabilities sets the image capabilities for the [FilePicker].
+func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) {
+	if caps != nil {
+		if caps.SupportsKittyGraphics {
+			f.imgEnc = fimage.EncodingKitty
+		}
+		f.cellSize = caps.CellSize()
+		_, f.isTmux = caps.Env.LookupEnv("TMUX")
+	}
+}
+
+// WorkingDir returns the current working directory of the [FilePicker].
+func (f *FilePicker) WorkingDir() string {
+	wd := f.com.Config().WorkingDir()
+	if len(wd) > 0 {
+		return wd
+	}
+
+	cwd, err := os.Getwd()
+	if err != nil {
+		return home.Dir()
+	}
+
+	return cwd
+}
+
+// ShortHelp returns the short help key bindings for the [FilePicker] dialog.
+func (f *FilePicker) ShortHelp() []key.Binding {
+	return []key.Binding{
+		f.km.Navigate,
+		f.km.Select,
+		f.km.Close,
+	}
+}
+
+// FullHelp returns the full help key bindings for the [FilePicker] dialog.
+func (f *FilePicker) FullHelp() [][]key.Binding {
+	return [][]key.Binding{
+		{
+			f.km.Select,
+			f.km.Down,
+			f.km.Up,
+			f.km.Forward,
+		},
+		{
+			f.km.Backward,
+			f.km.Close,
+		},
+	}
+}
+
+// ID returns the identifier of the [FilePicker] dialog.
+func (f *FilePicker) ID() string {
+	return FilePickerID
+}
+
+// HandleMsg updates the [FilePicker] dialog based on the given message.
+func (f *FilePicker) HandleMsg(msg tea.Msg) Action {
+	var cmds []tea.Cmd
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, f.km.Close):
+			return ActionClose{}
+		}
+	}
+
+	var cmd tea.Cmd
+	f.fp, cmd = f.fp.Update(msg)
+	if selFile := f.fp.HighlightedPath(); selFile != "" {
+		var allowed bool
+		for _, allowedExt := range f.fp.AllowedTypes {
+			if strings.HasSuffix(strings.ToLower(selFile), allowedExt) {
+				allowed = true
+				break
+			}
+		}
+
+		f.previewingImage = allowed
+		if allowed && !fimage.HasTransmitted(selFile, f.imgPrevWidth, f.imgPrevHeight) {
+			f.previewingImage = false
+			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),
+					func() tea.Msg {
+						f.previewingImage = true
+						return nil
+					},
+				))
+			}
+		}
+	}
+	if cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+
+	if didSelect, path := f.fp.DidSelectFile(msg); didSelect {
+		return ActionFilePickerSelected{Path: path}
+	}
+
+	return ActionCmd{tea.Batch(cmds...)}
+}
+
+const (
+	filePickerMinWidth  = 70
+	filePickerMinHeight = 10
+)
+
+// Draw renders the [FilePicker] dialog as a string.
+func (f *FilePicker) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	width := max(0, min(filePickerMinWidth, area.Dx()))
+	height := max(0, min(10, area.Dy()))
+	innerWidth := width - f.com.Styles.Dialog.View.GetHorizontalFrameSize()
+	imgPrevHeight := filePickerMinHeight*2 - f.com.Styles.Dialog.ImagePreview.GetVerticalFrameSize()
+	imgPrevWidth := innerWidth - f.com.Styles.Dialog.ImagePreview.GetHorizontalFrameSize()
+	f.imgPrevWidth = imgPrevWidth
+	f.imgPrevHeight = imgPrevHeight
+	f.fp.SetHeight(height)
+
+	styles := f.com.Styles.FilePicker
+	styles.File = styles.File.Width(innerWidth)
+	styles.Directory = styles.Directory.Width(innerWidth)
+	styles.Selected = styles.Selected.PaddingLeft(1).Width(innerWidth)
+	styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(innerWidth)
+	f.fp.Styles = styles
+
+	t := f.com.Styles
+	rc := NewRenderContext(t, width)
+	rc.Gap = 1
+	rc.Title = "Add Image"
+	rc.Help = f.help.View(f)
+
+	imgPreview := t.Dialog.ImagePreview.Align(lipgloss.Center).Width(innerWidth).Render(f.imagePreview(imgPrevWidth, imgPrevHeight))
+	rc.AddPart(imgPreview)
+
+	files := strings.TrimSpace(f.fp.View())
+	rc.AddPart(files)
+
+	view := rc.Render()
+
+	DrawCenter(scr, area, view)
+	return nil
+}
+
+var (
+	imagePreviewCache = map[string]string{}
+	imagePreviewMutex sync.RWMutex
+)
+
+// imagePreview returns the image preview section of the [FilePicker] dialog.
+func (f *FilePicker) imagePreview(imgPrevWidth, imgPrevHeight int) string {
+	if !f.previewingImage {
+		key := fmt.Sprintf("%dx%d", imgPrevWidth, imgPrevHeight)
+		imagePreviewMutex.RLock()
+		cached, ok := imagePreviewCache[key]
+		imagePreviewMutex.RUnlock()
+		if ok {
+			return cached
+		}
+
+		var sb strings.Builder
+		for y := range imgPrevHeight {
+			for range imgPrevWidth {
+				sb.WriteRune('█')
+			}
+			if y < imgPrevHeight-1 {
+				sb.WriteRune('\n')
+			}
+		}
+
+		imagePreviewMutex.Lock()
+		imagePreviewCache[key] = sb.String()
+		imagePreviewMutex.Unlock()
+
+		return sb.String()
+	}
+
+	if id := f.fp.HighlightedPath(); id != "" {
+		r := f.imgEnc.Render(id, imgPrevWidth, imgPrevHeight)
+		return r
+	}
+
+	return ""
+}
+
+func loadImage(path string) (img image.Image, err error) {
+	file, err := os.Open(path)
+	if err != nil {
+		return nil, err
+	}
+	defer file.Close()
+
+	img, _, err = image.Decode(file)
+	if err != nil {
+		return nil, err
+	}
+
+	return img, nil
+}

+ 483 - 0
internal/ui/dialog/models.go

@@ -0,0 +1,483 @@
+package dialog
+
+import (
+	"cmp"
+	"fmt"
+	"slices"
+	"strings"
+
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/textinput"
+	tea "charm.land/bubbletea/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/uiutil"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+// ModelType represents the type of model to select.
+type ModelType int
+
+const (
+	ModelTypeLarge ModelType = iota
+	ModelTypeSmall
+)
+
+// String returns the string representation of the [ModelType].
+func (mt ModelType) String() string {
+	switch mt {
+	case ModelTypeLarge:
+		return "Large Task"
+	case ModelTypeSmall:
+		return "Small Task"
+	default:
+		return "Unknown"
+	}
+}
+
+// Config returns the corresponding config model type.
+func (mt ModelType) Config() config.SelectedModelType {
+	switch mt {
+	case ModelTypeLarge:
+		return config.SelectedModelTypeLarge
+	case ModelTypeSmall:
+		return config.SelectedModelTypeSmall
+	default:
+		return ""
+	}
+}
+
+// Placeholder returns the input placeholder for the model type.
+func (mt ModelType) Placeholder() string {
+	switch mt {
+	case ModelTypeLarge:
+		return largeModelInputPlaceholder
+	case ModelTypeSmall:
+		return smallModelInputPlaceholder
+	default:
+		return ""
+	}
+}
+
+const (
+	largeModelInputPlaceholder = "Choose a model for large, complex tasks"
+	smallModelInputPlaceholder = "Choose a model for small, simple tasks"
+)
+
+// ModelsID is the identifier for the model selection dialog.
+const ModelsID = "models"
+
+const defaultModelsDialogMaxWidth = 70
+
+// Models represents a model selection dialog.
+type Models struct {
+	com *common.Common
+
+	modelType ModelType
+	providers []catwalk.Provider
+
+	keyMap struct {
+		Tab      key.Binding
+		UpDown   key.Binding
+		Select   key.Binding
+		Next     key.Binding
+		Previous key.Binding
+		Close    key.Binding
+	}
+	list  *ModelsList
+	input textinput.Model
+	help  help.Model
+}
+
+var _ Dialog = (*Models)(nil)
+
+// NewModels creates a new Models dialog.
+func NewModels(com *common.Common) (*Models, error) {
+	t := com.Styles
+	m := &Models{}
+	m.com = com
+	help := help.New()
+	help.Styles = t.DialogHelpStyles()
+
+	m.help = help
+	m.list = NewModelsList(t)
+	m.list.Focus()
+	m.list.SetSelected(0)
+
+	m.input = textinput.New()
+	m.input.SetVirtualCursor(false)
+	m.input.Placeholder = largeModelInputPlaceholder
+	m.input.SetStyles(com.Styles.TextInput)
+	m.input.Focus()
+
+	m.keyMap.Tab = key.NewBinding(
+		key.WithKeys("tab", "shift+tab"),
+		key.WithHelp("tab", "toggle type"),
+	)
+	m.keyMap.Select = key.NewBinding(
+		key.WithKeys("enter", "ctrl+y"),
+		key.WithHelp("enter", "confirm"),
+	)
+	m.keyMap.UpDown = key.NewBinding(
+		key.WithKeys("up", "down"),
+		key.WithHelp("↑/↓", "choose"),
+	)
+	m.keyMap.Next = key.NewBinding(
+		key.WithKeys("down", "ctrl+n"),
+		key.WithHelp("↓", "next item"),
+	)
+	m.keyMap.Previous = key.NewBinding(
+		key.WithKeys("up", "ctrl+p"),
+		key.WithHelp("↑", "previous item"),
+	)
+	m.keyMap.Close = CloseKey
+
+	providers, err := getFilteredProviders(com.Config())
+	if err != nil {
+		return nil, fmt.Errorf("failed to get providers: %w", err)
+	}
+
+	m.providers = providers
+	if err := m.setProviderItems(); err != nil {
+		return nil, fmt.Errorf("failed to set provider items: %w", err)
+	}
+
+	return m, nil
+}
+
+// ID implements Dialog.
+func (m *Models) ID() string {
+	return ModelsID
+}
+
+// HandleMsg implements Dialog.
+func (m *Models) HandleMsg(msg tea.Msg) Action {
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, m.keyMap.Close):
+			return ActionClose{}
+		case key.Matches(msg, m.keyMap.Previous):
+			m.list.Focus()
+			if m.list.IsSelectedFirst() {
+				m.list.SelectLast()
+				m.list.ScrollToBottom()
+				break
+			}
+			m.list.SelectPrev()
+			m.list.ScrollToSelected()
+		case key.Matches(msg, m.keyMap.Next):
+			m.list.Focus()
+			if m.list.IsSelectedLast() {
+				m.list.SelectFirst()
+				m.list.ScrollToTop()
+				break
+			}
+			m.list.SelectNext()
+			m.list.ScrollToSelected()
+		case key.Matches(msg, m.keyMap.Select):
+			selectedItem := m.list.SelectedItem()
+			if selectedItem == nil {
+				break
+			}
+
+			modelItem, ok := selectedItem.(*ModelItem)
+			if !ok {
+				break
+			}
+
+			return ActionSelectModel{
+				Provider:  modelItem.prov,
+				Model:     modelItem.SelectedModel(),
+				ModelType: modelItem.SelectedModelType(),
+			}
+		case key.Matches(msg, m.keyMap.Tab):
+			if m.modelType == ModelTypeLarge {
+				m.modelType = ModelTypeSmall
+			} else {
+				m.modelType = ModelTypeLarge
+			}
+			if err := m.setProviderItems(); err != nil {
+				return uiutil.ReportError(err)
+			}
+		default:
+			var cmd tea.Cmd
+			m.input, cmd = m.input.Update(msg)
+			value := m.input.Value()
+			m.list.Focus()
+			m.list.SetFilter(value)
+			m.list.SelectFirst()
+			m.list.ScrollToTop()
+			return ActionCmd{cmd}
+		}
+	}
+	return nil
+}
+
+// Cursor returns the cursor for the dialog.
+func (m *Models) Cursor() *tea.Cursor {
+	return InputCursor(m.com.Styles, m.input.Cursor())
+}
+
+// modelTypeRadioView returns the radio view for model type selection.
+func (m *Models) modelTypeRadioView() string {
+	t := m.com.Styles
+	textStyle := t.HalfMuted
+	largeRadioStyle := t.RadioOff
+	smallRadioStyle := t.RadioOff
+	if m.modelType == ModelTypeLarge {
+		largeRadioStyle = t.RadioOn
+	} else {
+		smallRadioStyle = t.RadioOn
+	}
+
+	largeRadio := largeRadioStyle.Padding(0, 1).Render()
+	smallRadio := smallRadioStyle.Padding(0, 1).Render()
+
+	return fmt.Sprintf("%s%s  %s%s",
+		largeRadio, textStyle.Render(ModelTypeLarge.String()),
+		smallRadio, textStyle.Render(ModelTypeSmall.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()))
+	innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
+	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
+		t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
+		t.Dialog.HelpView.GetVerticalFrameSize() +
+		t.Dialog.View.GetVerticalFrameSize()
+	m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
+	m.list.SetSize(innerWidth, height-heightOffset)
+	m.help.SetWidth(innerWidth)
+
+	rc := NewRenderContext(t, width)
+	rc.Title = "Switch Model"
+	rc.TitleInfo = m.modelTypeRadioView()
+	inputView := t.Dialog.InputPrompt.Render(m.input.View())
+	rc.AddPart(inputView)
+	listView := t.Dialog.List.Height(m.list.Height()).Render(m.list.Render())
+	rc.AddPart(listView)
+	rc.Help = m.help.View(m)
+
+	view := rc.Render()
+
+	cur := m.Cursor()
+	DrawCenterCursor(scr, area, view, cur)
+	return cur
+}
+
+// ShortHelp returns the short help view.
+func (m *Models) ShortHelp() []key.Binding {
+	return []key.Binding{
+		m.keyMap.UpDown,
+		m.keyMap.Tab,
+		m.keyMap.Select,
+		m.keyMap.Close,
+	}
+}
+
+// FullHelp returns the full help view.
+func (m *Models) FullHelp() [][]key.Binding {
+	return [][]key.Binding{
+		{
+			m.keyMap.Select,
+			m.keyMap.Next,
+			m.keyMap.Previous,
+			m.keyMap.Tab,
+		},
+		{
+			m.keyMap.Close,
+		},
+	}
+}
+
+// setProviderItems sets the provider items in the list.
+func (m *Models) setProviderItems() error {
+	t := m.com.Styles
+	cfg := m.com.Config()
+
+	var selectedItemID string
+	selectedType := m.modelType.Config()
+	currentModel := cfg.Models[selectedType]
+	recentItems := cfg.RecentModels[selectedType]
+
+	// Track providers already added to avoid duplicates
+	addedProviders := make(map[string]bool)
+
+	// Get a list of known providers to compare against
+	knownProviders, err := config.Providers(cfg)
+	if err != nil {
+		return fmt.Errorf("failed to get providers: %w", err)
+	}
+
+	containsProviderFunc := func(id string) func(p catwalk.Provider) bool {
+		return func(p catwalk.Provider) bool {
+			return p.ID == catwalk.InferenceProvider(id)
+		}
+	}
+
+	// itemsMap contains the keys of added model items.
+	itemsMap := make(map[string]*ModelItem)
+	groups := []ModelGroup{}
+	for id, p := range cfg.Providers.Seq2() {
+		if p.Disable {
+			continue
+		}
+
+		// Check if this provider is not in the known providers list
+		if !slices.ContainsFunc(knownProviders, containsProviderFunc(id)) ||
+			!slices.ContainsFunc(m.providers, containsProviderFunc(id)) {
+			provider := p.ToProvider()
+
+			// Add this unknown provider to the list
+			name := cmp.Or(p.Name, id)
+
+			addedProviders[id] = true
+
+			group := NewModelGroup(t, name, true)
+			for _, model := range p.Models {
+				item := NewModelItem(t, provider, model, m.modelType, false)
+				group.AppendItems(item)
+				itemsMap[item.ID()] = item
+				if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider {
+					selectedItemID = item.ID()
+				}
+			}
+			if len(group.Items) > 0 {
+				groups = append(groups, group)
+			}
+		}
+	}
+
+	// Now add known providers from the predefined list
+	for _, provider := range m.providers {
+		providerID := string(provider.ID)
+		if addedProviders[providerID] {
+			continue
+		}
+
+		providerConfig, providerConfigured := cfg.Providers.Get(providerID)
+		if providerConfigured && providerConfig.Disable {
+			continue
+		}
+
+		displayProvider := provider
+		if providerConfigured {
+			displayProvider.Name = cmp.Or(providerConfig.Name, displayProvider.Name)
+			modelIndex := make(map[string]int, len(displayProvider.Models))
+			for i, model := range displayProvider.Models {
+				modelIndex[model.ID] = i
+			}
+			for _, model := range providerConfig.Models {
+				if model.ID == "" {
+					continue
+				}
+				if idx, ok := modelIndex[model.ID]; ok {
+					if model.Name != "" {
+						displayProvider.Models[idx].Name = model.Name
+					}
+					continue
+				}
+				if model.Name == "" {
+					model.Name = model.ID
+				}
+				displayProvider.Models = append(displayProvider.Models, model)
+				modelIndex[model.ID] = len(displayProvider.Models) - 1
+			}
+		}
+
+		name := displayProvider.Name
+		if name == "" {
+			name = providerID
+		}
+
+		group := NewModelGroup(t, name, providerConfigured)
+		for _, model := range displayProvider.Models {
+			item := NewModelItem(t, provider, model, m.modelType, false)
+			group.AppendItems(item)
+			itemsMap[item.ID()] = item
+			if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider {
+				selectedItemID = item.ID()
+			}
+		}
+
+		groups = append(groups, group)
+	}
+
+	if len(recentItems) > 0 {
+		recentGroup := NewModelGroup(t, "Recently used", false)
+
+		var validRecentItems []config.SelectedModel
+		for _, recent := range recentItems {
+			key := modelKey(recent.Provider, recent.Model)
+			item, ok := itemsMap[key]
+			if !ok {
+				continue
+			}
+
+			// Show provider for recent items
+			item = NewModelItem(t, item.prov, item.model, m.modelType, true)
+			item.showProvider = true
+
+			validRecentItems = append(validRecentItems, recent)
+			recentGroup.AppendItems(item)
+			if recent.Model == currentModel.Model && recent.Provider == currentModel.Provider {
+				selectedItemID = item.ID()
+			}
+		}
+
+		if len(validRecentItems) != len(recentItems) {
+			// FIXME: Does this need to be here? Is it mutating the config during a read?
+			if err := cfg.SetConfigField(fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil {
+				return fmt.Errorf("failed to update recent models: %w", err)
+			}
+		}
+
+		if len(recentGroup.Items) > 0 {
+			groups = append([]ModelGroup{recentGroup}, groups...)
+		}
+	}
+
+	// Set model groups in the list.
+	m.list.SetGroups(groups...)
+	m.list.SetSelectedItem(selectedItemID)
+	m.list.ScrollToSelected()
+
+	// Update placeholder based on model type
+	m.input.Placeholder = m.modelType.Placeholder()
+
+	return nil
+}
+
+func getFilteredProviders(cfg *config.Config) ([]catwalk.Provider, error) {
+	providers, err := config.Providers(cfg)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get providers: %w", err)
+	}
+	var filteredProviders []catwalk.Provider
+	for _, p := range providers {
+		var (
+			isAzure         = p.ID == catwalk.InferenceProviderAzure
+			isCopilot       = p.ID == catwalk.InferenceProviderCopilot
+			isHyper         = string(p.ID) == "hyper"
+			hasAPIKeyEnv    = strings.HasPrefix(p.APIKey, "$")
+			_, isConfigured = cfg.Providers.Get(string(p.ID))
+		)
+		if isAzure || isCopilot || isHyper || hasAPIKeyEnv || isConfigured {
+			filteredProviders = append(filteredProviders, p)
+		}
+	}
+	return filteredProviders, nil
+}
+
+func modelKey(providerID, modelID string) string {
+	if providerID == "" || modelID == "" {
+		return ""
+	}
+	return providerID + ":" + modelID
+}

+ 124 - 0
internal/ui/dialog/models_item.go

@@ -0,0 +1,124 @@
+package dialog
+
+import (
+	"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"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/sahilm/fuzzy"
+)
+
+// ModelGroup represents a group of model items.
+type ModelGroup struct {
+	Title      string
+	Items      []*ModelItem
+	configured bool
+	t          *styles.Styles
+}
+
+// NewModelGroup creates a new ModelGroup.
+func NewModelGroup(t *styles.Styles, title string, configured bool, items ...*ModelItem) ModelGroup {
+	return ModelGroup{
+		Title:      title,
+		Items:      items,
+		configured: configured,
+		t:          t,
+	}
+}
+
+// AppendItems appends [ModelItem]s to the group.
+func (m *ModelGroup) AppendItems(items ...*ModelItem) {
+	m.Items = append(m.Items, items...)
+}
+
+// Render implements [list.Item].
+func (m *ModelGroup) Render(width int) string {
+	var configured string
+	if m.configured {
+		configuredIcon := m.t.ToolCallSuccess.Render()
+		configuredText := m.t.Subtle.Render("Configured")
+		configured = configuredIcon + " " + configuredText
+	}
+
+	title := " " + m.Title + " "
+	title = ansi.Truncate(title, max(0, width-lipgloss.Width(configured)-1), "…")
+
+	return common.Section(m.t, title, width, configured)
+}
+
+// ModelItem represents a list item for a model type.
+type ModelItem struct {
+	prov      catwalk.Provider
+	model     catwalk.Model
+	modelType ModelType
+
+	cache        map[int]string
+	t            *styles.Styles
+	m            fuzzy.Match
+	focused      bool
+	showProvider bool
+}
+
+// SelectedModel returns this model item as a [config.SelectedModel] instance.
+func (m *ModelItem) SelectedModel() config.SelectedModel {
+	return config.SelectedModel{
+		Model:           m.model.ID,
+		Provider:        string(m.prov.ID),
+		ReasoningEffort: m.model.DefaultReasoningEffort,
+		MaxTokens:       m.model.DefaultMaxTokens,
+	}
+}
+
+// SelectedModelType returns the type of model represented by this item.
+func (m *ModelItem) SelectedModelType() config.SelectedModelType {
+	return m.modelType.Config()
+}
+
+var _ ListItem = &ModelItem{}
+
+// NewModelItem creates a new ModelItem.
+func NewModelItem(t *styles.Styles, prov catwalk.Provider, model catwalk.Model, typ ModelType, showProvider bool) *ModelItem {
+	return &ModelItem{
+		prov:         prov,
+		model:        model,
+		modelType:    typ,
+		t:            t,
+		cache:        make(map[int]string),
+		showProvider: showProvider,
+	}
+}
+
+// Filter implements ListItem.
+func (m *ModelItem) Filter() string {
+	return m.model.Name
+}
+
+// ID implements ListItem.
+func (m *ModelItem) ID() string {
+	return modelKey(string(m.prov.ID), m.model.ID)
+}
+
+// Render implements ListItem.
+func (m *ModelItem) Render(width int) string {
+	var providerInfo string
+	if m.showProvider {
+		providerInfo = string(m.prov.Name)
+	}
+	return renderItem(m.t, m.model.Name, providerInfo, m.focused, width, m.cache, &m.m)
+}
+
+// SetFocused implements ListItem.
+func (m *ModelItem) SetFocused(focused bool) {
+	if m.focused != focused {
+		m.cache = nil
+	}
+	m.focused = focused
+}
+
+// SetMatch implements ListItem.
+func (m *ModelItem) SetMatch(fm fuzzy.Match) {
+	m.cache = nil
+	m.m = fm
+}

+ 281 - 0
internal/ui/dialog/models_list.go

@@ -0,0 +1,281 @@
+package dialog
+
+import (
+	"fmt"
+	"slices"
+	"sort"
+	"strings"
+
+	"github.com/charmbracelet/crush/internal/ui/list"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/sahilm/fuzzy"
+)
+
+// ModelsList is a list specifically for model items and groups.
+type ModelsList struct {
+	*list.List
+	groups []ModelGroup
+	query  string
+	t      *styles.Styles
+}
+
+// NewModelsList creates a new list suitable for model items and groups.
+func NewModelsList(sty *styles.Styles, groups ...ModelGroup) *ModelsList {
+	f := &ModelsList{
+		List:   list.NewList(),
+		groups: groups,
+		t:      sty,
+	}
+	f.RegisterRenderCallback(list.FocusedRenderCallback(f.List))
+	return f
+}
+
+// Len returns the number of model items across all groups.
+func (f *ModelsList) Len() int {
+	n := 0
+	for _, g := range f.groups {
+		n += len(g.Items)
+	}
+	return n
+}
+
+// SetGroups sets the model groups and updates the list items.
+func (f *ModelsList) SetGroups(groups ...ModelGroup) {
+	f.groups = groups
+	items := []list.Item{}
+	for _, g := range f.groups {
+		items = append(items, &g)
+		for _, item := range g.Items {
+			items = append(items, item)
+		}
+		// Add a space separator after each provider section
+		items = append(items, list.NewSpacerItem(1))
+	}
+	f.SetItems(items...)
+}
+
+// SetFilter sets the filter query and updates the list items.
+func (f *ModelsList) SetFilter(q string) {
+	f.query = q
+	f.SetItems(f.VisibleItems()...)
+}
+
+// SetSelected sets the selected item index. It overrides the base method to
+// skip non-model items.
+func (f *ModelsList) SetSelected(index int) {
+	if index < 0 || index >= f.Len() {
+		f.List.SetSelected(index)
+		return
+	}
+
+	f.List.SetSelected(index)
+	for {
+		selectedItem := f.SelectedItem()
+		if _, ok := selectedItem.(*ModelItem); ok {
+			return
+		}
+		f.List.SetSelected(index + 1)
+		index++
+		if index >= f.Len() {
+			return
+		}
+	}
+}
+
+// SetSelectedItem sets the selected item in the list by item ID.
+func (f *ModelsList) SetSelectedItem(itemID string) {
+	if itemID == "" {
+		f.SetSelected(0)
+		return
+	}
+
+	count := 0
+	for _, g := range f.groups {
+		for _, item := range g.Items {
+			if item.ID() == itemID {
+				f.SetSelected(count)
+				return
+			}
+			count++
+		}
+	}
+}
+
+// SelectNext selects the next model item, skipping any non-focusable items
+// like group headers and spacers.
+func (f *ModelsList) SelectNext() (v bool) {
+	v = f.List.SelectNext()
+	for v {
+		selectedItem := f.SelectedItem()
+		if _, ok := selectedItem.(*ModelItem); ok {
+			return v
+		}
+		v = f.List.SelectNext()
+	}
+	return v
+}
+
+// SelectPrev selects the previous model item, skipping any non-focusable items
+// like group headers and spacers.
+func (f *ModelsList) SelectPrev() (v bool) {
+	v = f.List.SelectPrev()
+	for v {
+		selectedItem := f.SelectedItem()
+		if _, ok := selectedItem.(*ModelItem); ok {
+			return v
+		}
+		v = f.List.SelectPrev()
+	}
+	return v
+}
+
+// SelectFirst selects the first model item in the list.
+func (f *ModelsList) SelectFirst() (v bool) {
+	v = f.List.SelectFirst()
+	for v {
+		selectedItem := f.SelectedItem()
+		_, ok := selectedItem.(*ModelItem)
+		if ok {
+			return v
+		}
+		v = f.List.SelectNext()
+	}
+	return v
+}
+
+// SelectLast selects the last model item in the list.
+func (f *ModelsList) SelectLast() (v bool) {
+	v = f.List.SelectLast()
+	for v {
+		selectedItem := f.SelectedItem()
+		if _, ok := selectedItem.(*ModelItem); ok {
+			return v
+		}
+		v = f.List.SelectPrev()
+	}
+	return v
+}
+
+// IsSelectedFirst checks if the selected item is the first model item.
+func (f *ModelsList) IsSelectedFirst() bool {
+	originalIndex := f.Selected()
+	f.SelectFirst()
+	isFirst := f.Selected() == originalIndex
+	f.List.SetSelected(originalIndex)
+	return isFirst
+}
+
+// IsSelectedLast checks if the selected item is the last model item.
+func (f *ModelsList) IsSelectedLast() bool {
+	originalIndex := f.Selected()
+	f.SelectLast()
+	isLast := f.Selected() == originalIndex
+	f.List.SetSelected(originalIndex)
+	return isLast
+}
+
+// VisibleItems returns the visible items after filtering.
+func (f *ModelsList) VisibleItems() []list.Item {
+	query := strings.ToLower(strings.ReplaceAll(f.query, " ", ""))
+
+	if query == "" {
+		// No filter, return all items with group headers
+		items := []list.Item{}
+		for _, g := range f.groups {
+			items = append(items, &g)
+			for _, item := range g.Items {
+				item.SetMatch(fuzzy.Match{})
+				items = append(items, item)
+			}
+			// Add a space separator after each provider section
+			items = append(items, list.NewSpacerItem(1))
+		}
+		return items
+	}
+
+	filterableItems := make([]list.FilterableItem, 0, f.Len())
+	for _, g := range f.groups {
+		for _, item := range g.Items {
+			filterableItems = append(filterableItems, item)
+		}
+	}
+
+	items := []list.Item{}
+	visitedGroups := map[int]bool{}
+
+	// Reconstruct groups with matched items
+	// Find which group this item belongs to
+	for gi, g := range f.groups {
+		addedCount := 0
+		name := strings.ToLower(g.Title) + " "
+
+		names := make([]string, len(filterableItems))
+		for i, item := range filterableItems {
+			ms := item.(*ModelItem)
+			names[i] = fmt.Sprintf("%s%s", name, ms.Filter())
+		}
+
+		matches := fuzzy.Find(query, names)
+		sort.SliceStable(matches, func(i, j int) bool {
+			return matches[i].Score > matches[j].Score
+		})
+
+		for _, match := range matches {
+			item := filterableItems[match.Index].(*ModelItem)
+			idxs := []int{}
+			for _, idx := range match.MatchedIndexes {
+				// Adjusts removing provider name highlights
+				if idx < len(name) {
+					continue
+				}
+				idxs = append(idxs, idx-len(name))
+			}
+
+			match.MatchedIndexes = idxs
+			if slices.Contains(g.Items, item) {
+				if !visitedGroups[gi] {
+					// Add section header
+					items = append(items, &g)
+					visitedGroups[gi] = true
+				}
+				// Add the matched item
+				item.SetMatch(match)
+				items = append(items, item)
+				addedCount++
+			}
+		}
+		if addedCount > 0 {
+			// Add a space separator after each provider section
+			items = append(items, list.NewSpacerItem(1))
+		}
+	}
+
+	return items
+}
+
+// Render renders the filterable list.
+func (f *ModelsList) Render() string {
+	f.SetItems(f.VisibleItems()...)
+	return f.List.Render()
+}
+
+type modelGroups []ModelGroup
+
+func (m modelGroups) Len() int {
+	n := 0
+	for _, g := range m {
+		n += len(g.Items)
+	}
+	return n
+}
+
+func (m modelGroups) String(i int) string {
+	count := 0
+	for _, g := range m {
+		if i < count+len(g.Items) {
+			return g.Items[i-count].Filter()
+		}
+		count += len(g.Items)
+	}
+	return ""
+}

+ 369 - 0
internal/ui/dialog/oauth.go

@@ -0,0 +1,369 @@
+package dialog
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/spinner"
+	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/oauth"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/uiutil"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/pkg/browser"
+)
+
+type OAuthProvider interface {
+	name() string
+	initiateAuth() tea.Msg
+	startPolling(deviceCode string, expiresIn int) tea.Cmd
+	stopPolling() tea.Msg
+}
+
+// OAuthState represents the current state of the device flow.
+type OAuthState int
+
+const (
+	OAuthStateInitializing OAuthState = iota
+	OAuthStateDisplay
+	OAuthStateSuccess
+	OAuthStateError
+)
+
+// OAuthID is the identifier for the model selection dialog.
+const OAuthID = "oauth"
+
+// OAuth handles the OAuth flow authentication.
+type OAuth struct {
+	com *common.Common
+
+	provider      catwalk.Provider
+	model         config.SelectedModel
+	modelType     config.SelectedModelType
+	oAuthProvider OAuthProvider
+
+	State OAuthState
+
+	spinner spinner.Model
+	help    help.Model
+	keyMap  struct {
+		Copy   key.Binding
+		Submit key.Binding
+		Close  key.Binding
+	}
+
+	width           int
+	deviceCode      string
+	userCode        string
+	verificationURL string
+	expiresIn       int
+	interval        int
+	token           *oauth.Token
+	cancelFunc      context.CancelFunc
+}
+
+var _ Dialog = (*OAuth)(nil)
+
+// newOAuth creates a new device flow component.
+func newOAuth(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType, oAuthProvider OAuthProvider) (*OAuth, tea.Cmd) {
+	t := com.Styles
+
+	m := OAuth{}
+	m.com = com
+	m.provider = provider
+	m.model = model
+	m.modelType = modelType
+	m.oAuthProvider = oAuthProvider
+	m.width = 60
+	m.State = OAuthStateInitializing
+
+	m.spinner = spinner.New(
+		spinner.WithSpinner(spinner.Dot),
+		spinner.WithStyle(t.Base.Foreground(t.GreenLight)),
+	)
+
+	m.help = help.New()
+	m.help.Styles = t.DialogHelpStyles()
+
+	m.keyMap.Copy = key.NewBinding(
+		key.WithKeys("c"),
+		key.WithHelp("c", "copy code"),
+	)
+	m.keyMap.Submit = key.NewBinding(
+		key.WithKeys("enter", "ctrl+y"),
+		key.WithHelp("enter", "copy & open"),
+	)
+	m.keyMap.Close = CloseKey
+
+	return &m, tea.Batch(m.spinner.Tick, m.oAuthProvider.initiateAuth)
+}
+
+// ID implements Dialog.
+func (m *OAuth) ID() string {
+	return OAuthID
+}
+
+// HandleMsg handles messages and state transitions.
+func (m *OAuth) HandleMsg(msg tea.Msg) Action {
+	switch msg := msg.(type) {
+	case spinner.TickMsg:
+		switch m.State {
+		case OAuthStateInitializing, OAuthStateDisplay:
+			var cmd tea.Cmd
+			m.spinner, cmd = m.spinner.Update(msg)
+			if cmd != nil {
+				return ActionCmd{cmd}
+			}
+		}
+
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, m.keyMap.Copy):
+			cmd := m.copyCode()
+			return ActionCmd{cmd}
+
+		case key.Matches(msg, m.keyMap.Submit):
+			switch m.State {
+			case OAuthStateSuccess:
+				return m.saveKeyAndContinue()
+
+			default:
+				cmd := m.copyCodeAndOpenURL()
+				return ActionCmd{cmd}
+			}
+
+		case key.Matches(msg, m.keyMap.Close):
+			switch m.State {
+			case OAuthStateSuccess:
+				return m.saveKeyAndContinue()
+
+			default:
+				return ActionClose{}
+			}
+		}
+
+	case ActionInitiateOAuth:
+		m.deviceCode = msg.DeviceCode
+		m.userCode = msg.UserCode
+		m.expiresIn = msg.ExpiresIn
+		m.verificationURL = msg.VerificationURL
+		m.interval = msg.Interval
+		m.State = OAuthStateDisplay
+		return ActionCmd{m.oAuthProvider.startPolling(msg.DeviceCode, msg.ExpiresIn)}
+
+	case ActionCompleteOAuth:
+		m.State = OAuthStateSuccess
+		m.token = msg.Token
+		return ActionCmd{m.oAuthProvider.stopPolling}
+
+	case ActionOAuthErrored:
+		m.State = OAuthStateError
+		cmd := tea.Batch(m.oAuthProvider.stopPolling, uiutil.ReportError(msg.Error))
+		return ActionCmd{cmd}
+	}
+	return nil
+}
+
+// View renders the device flow dialog.
+func (m *OAuth) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	var (
+		t           = m.com.Styles
+		dialogStyle = t.Dialog.View.Width(m.width)
+		view        = dialogStyle.Render(m.dialogContent())
+	)
+	DrawCenterCursor(scr, area, view, nil)
+	return nil
+}
+
+func (m *OAuth) dialogContent() string {
+	var (
+		t         = m.com.Styles
+		helpStyle = t.Dialog.HelpView
+	)
+
+	switch m.State {
+	case OAuthStateInitializing:
+		return m.innerDialogContent()
+
+	default:
+		elements := []string{
+			m.headerContent(),
+			m.innerDialogContent(),
+			helpStyle.Render(m.help.View(m)),
+		}
+		return strings.Join(elements, "\n")
+	}
+}
+
+func (m *OAuth) headerContent() string {
+	var (
+		t            = m.com.Styles
+		titleStyle   = t.Dialog.Title
+		dialogStyle  = t.Dialog.View.Width(m.width)
+		headerOffset = titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
+	)
+	return common.DialogTitle(t, titleStyle.Render("Authenticate with "+m.oAuthProvider.name()), m.width-headerOffset)
+}
+
+func (m *OAuth) innerDialogContent() string {
+	var (
+		t            = m.com.Styles
+		whiteStyle   = lipgloss.NewStyle().Foreground(t.White)
+		primaryStyle = lipgloss.NewStyle().Foreground(t.Primary)
+		greenStyle   = lipgloss.NewStyle().Foreground(t.GreenLight)
+		linkStyle    = lipgloss.NewStyle().Foreground(t.GreenDark).Underline(true)
+		errorStyle   = lipgloss.NewStyle().Foreground(t.Error)
+		mutedStyle   = lipgloss.NewStyle().Foreground(t.FgMuted)
+	)
+
+	switch m.State {
+	case OAuthStateInitializing:
+		return lipgloss.NewStyle().
+			Margin(1, 1).
+			Width(m.width - 2).
+			Align(lipgloss.Center).
+			Render(
+				greenStyle.Render(m.spinner.View()) +
+					mutedStyle.Render("Initializing..."),
+			)
+
+	case OAuthStateDisplay:
+		instructions := lipgloss.NewStyle().
+			Margin(0, 1).
+			Width(m.width - 2).
+			Render(
+				whiteStyle.Render("Press ") +
+					primaryStyle.Render("enter") +
+					whiteStyle.Render(" to copy the code below and open the browser."),
+			)
+
+		codeBox := lipgloss.NewStyle().
+			Width(m.width-2).
+			Height(7).
+			Align(lipgloss.Center, lipgloss.Center).
+			Background(t.BgBaseLighter).
+			Margin(0, 1).
+			Render(
+				lipgloss.NewStyle().
+					Bold(true).
+					Foreground(t.White).
+					Render(m.userCode),
+			)
+
+		link := linkStyle.Hyperlink(m.verificationURL, "id=oauth-verify").Render(m.verificationURL)
+		url := mutedStyle.
+			Margin(0, 1).
+			Width(m.width - 2).
+			Render("Browser not opening? Refer to\n" + link)
+
+		waiting := lipgloss.NewStyle().
+			Margin(0, 1).
+			Width(m.width - 2).
+			Render(
+				greenStyle.Render(m.spinner.View()) + mutedStyle.Render("Verifying..."),
+			)
+
+		return lipgloss.JoinVertical(
+			lipgloss.Left,
+			"",
+			instructions,
+			"",
+			codeBox,
+			"",
+			url,
+			"",
+			waiting,
+			"",
+		)
+
+	case OAuthStateSuccess:
+		return greenStyle.
+			Margin(1).
+			Width(m.width - 2).
+			Render("Authentication successful!")
+
+	case OAuthStateError:
+		return lipgloss.NewStyle().
+			Margin(1).
+			Width(m.width - 2).
+			Render(errorStyle.Render("Authentication failed."))
+
+	default:
+		return ""
+	}
+}
+
+// FullHelp returns the full help view.
+func (m *OAuth) FullHelp() [][]key.Binding {
+	return [][]key.Binding{m.ShortHelp()}
+}
+
+// ShortHelp returns the full help view.
+func (m *OAuth) ShortHelp() []key.Binding {
+	switch m.State {
+	case OAuthStateError:
+		return []key.Binding{m.keyMap.Close}
+
+	case OAuthStateSuccess:
+		return []key.Binding{
+			key.NewBinding(
+				key.WithKeys("finish", "ctrl+y", "esc"),
+				key.WithHelp("enter", "finish"),
+			),
+		}
+
+	default:
+		return []key.Binding{
+			m.keyMap.Copy,
+			m.keyMap.Submit,
+			m.keyMap.Close,
+		}
+	}
+}
+
+func (d *OAuth) copyCode() tea.Cmd {
+	if d.State != OAuthStateDisplay {
+		return nil
+	}
+	return tea.Sequence(
+		tea.SetClipboard(d.userCode),
+		uiutil.ReportInfo("Code copied to clipboard"),
+	)
+}
+
+func (d *OAuth) copyCodeAndOpenURL() tea.Cmd {
+	if d.State != OAuthStateDisplay {
+		return nil
+	}
+	return tea.Sequence(
+		tea.SetClipboard(d.userCode),
+		func() tea.Msg {
+			if err := browser.OpenURL(d.verificationURL); err != nil {
+				return ActionOAuthErrored{fmt.Errorf("failed to open browser: %w", err)}
+			}
+			return nil
+		},
+		uiutil.ReportInfo("Code copied and URL opened"),
+	)
+}
+
+func (m *OAuth) saveKeyAndContinue() Action {
+	cfg := m.com.Config()
+
+	err := cfg.SetProviderAPIKey(string(m.provider.ID), m.token)
+	if err != nil {
+		return ActionCmd{uiutil.ReportError(fmt.Errorf("failed to save API key: %w", err))}
+	}
+
+	return ActionSelectModel{
+		Provider:  m.provider,
+		Model:     m.model,
+		ModelType: m.modelType,
+	}
+}

+ 72 - 0
internal/ui/dialog/oauth_copilot.go

@@ -0,0 +1,72 @@
+package dialog
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/oauth/copilot"
+	"github.com/charmbracelet/crush/internal/ui/common"
+)
+
+func NewOAuthCopilot(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, tea.Cmd) {
+	return newOAuth(com, provider, model, modelType, &OAuthCopilot{})
+}
+
+type OAuthCopilot struct {
+	deviceCode *copilot.DeviceCode
+	cancelFunc func()
+}
+
+var _ OAuthProvider = (*OAuthCopilot)(nil)
+
+func (m *OAuthCopilot) name() string {
+	return "GitHub Copilot"
+}
+
+func (m *OAuthCopilot) initiateAuth() tea.Msg {
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+
+	deviceCode, err := copilot.RequestDeviceCode(ctx)
+	if err != nil {
+		return ActionOAuthErrored{Error: fmt.Errorf("failed to initiate device auth: %w", err)}
+	}
+
+	m.deviceCode = deviceCode
+
+	return ActionInitiateOAuth{
+		DeviceCode:      deviceCode.DeviceCode,
+		UserCode:        deviceCode.UserCode,
+		VerificationURL: deviceCode.VerificationURI,
+		ExpiresIn:       deviceCode.ExpiresIn,
+		Interval:        deviceCode.Interval,
+	}
+}
+
+func (m *OAuthCopilot) startPolling(deviceCode string, expiresIn int) tea.Cmd {
+	return func() tea.Msg {
+		ctx, cancel := context.WithCancel(context.Background())
+		m.cancelFunc = cancel
+
+		token, err := copilot.PollForToken(ctx, m.deviceCode)
+		if err != nil {
+			if ctx.Err() != nil {
+				return nil // cancelled, don't report error.
+			}
+			return ActionOAuthErrored{Error: err}
+		}
+
+		return ActionCompleteOAuth{Token: token}
+	}
+}
+
+func (m *OAuthCopilot) stopPolling() tea.Msg {
+	if m.cancelFunc != nil {
+		m.cancelFunc()
+	}
+	return nil
+}

+ 90 - 0
internal/ui/dialog/oauth_hyper.go

@@ -0,0 +1,90 @@
+package dialog
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/oauth/hyper"
+	"github.com/charmbracelet/crush/internal/ui/common"
+)
+
+func NewOAuthHyper(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, tea.Cmd) {
+	return newOAuth(com, provider, model, modelType, &OAuthHyper{})
+}
+
+type OAuthHyper struct {
+	cancelFunc func()
+}
+
+var _ OAuthProvider = (*OAuthHyper)(nil)
+
+func (m *OAuthHyper) name() string {
+	return "Hyper"
+}
+
+func (m *OAuthHyper) initiateAuth() tea.Msg {
+	minimumWait := 750 * time.Millisecond
+	startTime := time.Now()
+
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+
+	authResp, err := hyper.InitiateDeviceAuth(ctx)
+
+	ellapsed := time.Since(startTime)
+	if ellapsed < minimumWait {
+		time.Sleep(minimumWait - ellapsed)
+	}
+
+	if err != nil {
+		return ActionOAuthErrored{fmt.Errorf("failed to initiate device auth: %w", err)}
+	}
+
+	return ActionInitiateOAuth{
+		DeviceCode:      authResp.DeviceCode,
+		UserCode:        authResp.UserCode,
+		ExpiresIn:       authResp.ExpiresIn,
+		VerificationURL: authResp.VerificationURL,
+	}
+}
+
+func (m *OAuthHyper) startPolling(deviceCode string, expiresIn int) tea.Cmd {
+	return func() tea.Msg {
+		ctx, cancel := context.WithCancel(context.Background())
+		m.cancelFunc = cancel
+
+		refreshToken, err := hyper.PollForToken(ctx, deviceCode, expiresIn)
+		if err != nil {
+			if ctx.Err() != nil {
+				return nil
+			}
+			return ActionOAuthErrored{err}
+		}
+
+		token, err := hyper.ExchangeToken(ctx, refreshToken)
+		if err != nil {
+			return ActionOAuthErrored{fmt.Errorf("token exchange failed: %w", err)}
+		}
+
+		introspect, err := hyper.IntrospectToken(ctx, token.AccessToken)
+		if err != nil {
+			return ActionOAuthErrored{fmt.Errorf("token introspection failed: %w", err)}
+		}
+		if !introspect.Active {
+			return ActionOAuthErrored{fmt.Errorf("access token is not active")}
+		}
+
+		return ActionCompleteOAuth{token}
+	}
+}
+
+func (m *OAuthHyper) stopPolling() tea.Msg {
+	if m.cancelFunc != nil {
+		m.cancelFunc()
+	}
+	return nil
+}

+ 760 - 0
internal/ui/dialog/permissions.go

@@ -0,0 +1,760 @@
+package dialog
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/viewport"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/stringext"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+// PermissionsID is the identifier for the permissions dialog.
+const PermissionsID = "permissions"
+
+// PermissionAction represents the user's response to a permission request.
+type PermissionAction string
+
+const (
+	PermissionAllow           PermissionAction = "allow"
+	PermissionAllowForSession PermissionAction = "allow_session"
+	PermissionDeny            PermissionAction = "deny"
+)
+
+// Permissions dialog sizing constants.
+const (
+	// diffMaxWidth is the maximum width for diff views.
+	diffMaxWidth = 180
+	// diffSizeRatio is the size ratio for diff views relative to window.
+	diffSizeRatio = 0.8
+	// simpleMaxWidth is the maximum width for simple content dialogs.
+	simpleMaxWidth = 100
+	// simpleSizeRatio is the size ratio for simple content dialogs.
+	simpleSizeRatio = 0.6
+	// simpleHeightRatio is the height ratio for simple content dialogs.
+	simpleHeightRatio = 0.5
+	// splitModeMinWidth is the minimum width to enable split diff mode.
+	splitModeMinWidth = 140
+	// layoutSpacingLines is the number of empty lines used for layout spacing.
+	layoutSpacingLines = 4
+	// minWindowWidth is the minimum window width before forcing fullscreen.
+	minWindowWidth = 60
+	// minWindowHeight is the minimum window height before forcing fullscreen.
+	minWindowHeight = 20
+)
+
+// Permissions represents a dialog for permission requests.
+type Permissions struct {
+	com          *common.Common
+	windowWidth  int // Terminal window dimensions.
+	windowHeight int
+	fullscreen   bool // true when dialog is fullscreen
+
+	permission     permission.PermissionRequest
+	selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
+
+	viewport      viewport.Model
+	viewportDirty bool // true when viewport content needs to be re-rendered
+	viewportWidth int
+
+	// Diff view state.
+	diffSplitMode        *bool // nil means use default based on width
+	defaultDiffSplitMode bool  // default split mode based on width
+	unifiedDiffContent   string
+	splitDiffContent     string
+
+	help   help.Model
+	keyMap permissionsKeyMap
+}
+
+type permissionsKeyMap struct {
+	Left             key.Binding
+	Right            key.Binding
+	Tab              key.Binding
+	Select           key.Binding
+	Allow            key.Binding
+	AllowSession     key.Binding
+	Deny             key.Binding
+	Close            key.Binding
+	ToggleDiffMode   key.Binding
+	ToggleFullscreen key.Binding
+	ScrollUp         key.Binding
+	ScrollDown       key.Binding
+	ScrollLeft       key.Binding
+	ScrollRight      key.Binding
+	Choose           key.Binding
+	Scroll           key.Binding
+}
+
+func defaultPermissionsKeyMap() permissionsKeyMap {
+	return permissionsKeyMap{
+		Left: key.NewBinding(
+			key.WithKeys("left", "h"),
+			key.WithHelp("←", "previous"),
+		),
+		Right: key.NewBinding(
+			key.WithKeys("right", "l"),
+			key.WithHelp("→", "next"),
+		),
+		Tab: key.NewBinding(
+			key.WithKeys("tab"),
+			key.WithHelp("tab", "next option"),
+		),
+		Select: key.NewBinding(
+			key.WithKeys("enter", "ctrl+y"),
+			key.WithHelp("enter", "confirm"),
+		),
+		Allow: key.NewBinding(
+			key.WithKeys("a", "A", "ctrl+a"),
+			key.WithHelp("a", "allow"),
+		),
+		AllowSession: key.NewBinding(
+			key.WithKeys("s", "S", "ctrl+s"),
+			key.WithHelp("s", "allow session"),
+		),
+		Deny: key.NewBinding(
+			key.WithKeys("d", "D"),
+			key.WithHelp("d", "deny"),
+		),
+		Close: CloseKey,
+		ToggleDiffMode: key.NewBinding(
+			key.WithKeys("t"),
+			key.WithHelp("t", "toggle diff view"),
+		),
+		ToggleFullscreen: key.NewBinding(
+			key.WithKeys("f"),
+			key.WithHelp("f", "toggle fullscreen"),
+		),
+		ScrollUp: key.NewBinding(
+			key.WithKeys("shift+up", "K"),
+			key.WithHelp("shift+↑", "scroll up"),
+		),
+		ScrollDown: key.NewBinding(
+			key.WithKeys("shift+down", "J"),
+			key.WithHelp("shift+↓", "scroll down"),
+		),
+		ScrollLeft: key.NewBinding(
+			key.WithKeys("shift+left", "H"),
+			key.WithHelp("shift+←", "scroll left"),
+		),
+		ScrollRight: key.NewBinding(
+			key.WithKeys("shift+right", "L"),
+			key.WithHelp("shift+→", "scroll right"),
+		),
+		Choose: key.NewBinding(
+			key.WithKeys("left", "right"),
+			key.WithHelp("←/→", "choose"),
+		),
+		Scroll: key.NewBinding(
+			key.WithKeys("shift+left", "shift+down", "shift+up", "shift+right"),
+			key.WithHelp("shift+←↓↑→", "scroll"),
+		),
+	}
+}
+
+var _ Dialog = (*Permissions)(nil)
+
+// PermissionsOption configures the permissions dialog.
+type PermissionsOption func(*Permissions)
+
+// WithDiffMode sets the initial diff mode (split or unified).
+func WithDiffMode(split bool) PermissionsOption {
+	return func(p *Permissions) {
+		p.diffSplitMode = &split
+	}
+}
+
+// NewPermissions creates a new permissions dialog.
+func NewPermissions(com *common.Common, perm permission.PermissionRequest, opts ...PermissionsOption) *Permissions {
+	h := help.New()
+	h.Styles = com.Styles.DialogHelpStyles()
+
+	km := defaultPermissionsKeyMap()
+
+	// Configure viewport with matching keybindings.
+	vp := viewport.New()
+	vp.KeyMap = viewport.KeyMap{
+		Up:    km.ScrollUp,
+		Down:  km.ScrollDown,
+		Left:  km.ScrollLeft,
+		Right: km.ScrollRight,
+		// Disable other viewport keys to avoid conflicts with dialog shortcuts.
+		PageUp:       key.NewBinding(key.WithDisabled()),
+		PageDown:     key.NewBinding(key.WithDisabled()),
+		HalfPageUp:   key.NewBinding(key.WithDisabled()),
+		HalfPageDown: key.NewBinding(key.WithDisabled()),
+	}
+
+	p := &Permissions{
+		com:            com,
+		permission:     perm,
+		selectedOption: 0,
+		viewport:       vp,
+		help:           h,
+		keyMap:         km,
+	}
+
+	for _, opt := range opts {
+		opt(p)
+	}
+
+	return p
+}
+
+// Calculate usable content width (dialog border + horizontal padding).
+func (p *Permissions) calculateContentWidth(width int) int {
+	t := p.com.Styles
+	const dialogHorizontalPadding = 2
+	return width - t.Dialog.View.GetHorizontalFrameSize() - dialogHorizontalPadding
+}
+
+// ID implements [Dialog].
+func (*Permissions) ID() string {
+	return PermissionsID
+}
+
+// HandleMsg implements [Dialog].
+func (p *Permissions) HandleMsg(msg tea.Msg) Action {
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, p.keyMap.Close):
+			// Escape denies the permission request.
+			return p.respond(PermissionDeny)
+		case key.Matches(msg, p.keyMap.Right), key.Matches(msg, p.keyMap.Tab):
+			p.selectedOption = (p.selectedOption + 1) % 3
+		case key.Matches(msg, p.keyMap.Left):
+			// Add 2 instead of subtracting 1 to avoid negative modulo.
+			p.selectedOption = (p.selectedOption + 2) % 3
+		case key.Matches(msg, p.keyMap.Select):
+			return p.selectCurrentOption()
+		case key.Matches(msg, p.keyMap.Allow):
+			return p.respond(PermissionAllow)
+		case key.Matches(msg, p.keyMap.AllowSession):
+			return p.respond(PermissionAllowForSession)
+		case key.Matches(msg, p.keyMap.Deny):
+			return p.respond(PermissionDeny)
+		case key.Matches(msg, p.keyMap.ToggleDiffMode):
+			if p.hasDiffView() {
+				newMode := !p.isSplitMode()
+				p.diffSplitMode = &newMode
+				p.viewportDirty = true
+			}
+		case key.Matches(msg, p.keyMap.ToggleFullscreen):
+			if p.hasDiffView() {
+				p.fullscreen = !p.fullscreen
+			}
+		case key.Matches(msg, p.keyMap.ScrollDown):
+			p.viewport, _ = p.viewport.Update(msg)
+		case key.Matches(msg, p.keyMap.ScrollUp):
+			p.viewport, _ = p.viewport.Update(msg)
+		case key.Matches(msg, p.keyMap.ScrollLeft):
+			p.viewport, _ = p.viewport.Update(msg)
+		case key.Matches(msg, p.keyMap.ScrollRight):
+			p.viewport, _ = p.viewport.Update(msg)
+		}
+	case tea.MouseWheelMsg:
+		p.viewport, _ = p.viewport.Update(msg)
+	default:
+		// Pass unhandled keys to viewport for non-diff content scrolling.
+		if !p.hasDiffView() {
+			p.viewport, _ = p.viewport.Update(msg)
+			p.viewportDirty = true
+		}
+	}
+
+	return nil
+}
+
+func (p *Permissions) selectCurrentOption() tea.Msg {
+	switch p.selectedOption {
+	case 0:
+		return p.respond(PermissionAllow)
+	case 1:
+		return p.respond(PermissionAllowForSession)
+	default:
+		return p.respond(PermissionDeny)
+	}
+}
+
+func (p *Permissions) respond(action PermissionAction) tea.Msg {
+	return ActionPermissionResponse{
+		Permission: p.permission,
+		Action:     action,
+	}
+}
+
+func (p *Permissions) hasDiffView() bool {
+	switch p.permission.ToolName {
+	case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName:
+		return true
+	}
+	return false
+}
+
+func (p *Permissions) isSplitMode() bool {
+	if p.diffSplitMode != nil {
+		return *p.diffSplitMode
+	}
+	return p.defaultDiffSplitMode
+}
+
+// Draw implements [Dialog].
+func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	t := p.com.Styles
+	// Force fullscreen when window is too small.
+	forceFullscreen := area.Dx() <= minWindowWidth || area.Dy() <= minWindowHeight
+
+	// Calculate dialog dimensions based on fullscreen state and content type.
+	var width, maxHeight int
+	if forceFullscreen || (p.fullscreen && p.hasDiffView()) {
+		// Use nearly full window for fullscreen.
+		width = area.Dx()
+		maxHeight = area.Dy()
+	} else if p.hasDiffView() {
+		// Wide for side-by-side diffs, capped for readability.
+		width = min(int(float64(area.Dx())*diffSizeRatio), diffMaxWidth)
+		maxHeight = int(float64(area.Dy()) * diffSizeRatio)
+	} else {
+		// Narrower for simple content like commands/URLs.
+		width = min(int(float64(area.Dx())*simpleSizeRatio), simpleMaxWidth)
+		maxHeight = int(float64(area.Dy()) * simpleHeightRatio)
+	}
+
+	dialogStyle := t.Dialog.View.Width(width).Padding(0, 1)
+
+	contentWidth := p.calculateContentWidth(width)
+	header := p.renderHeader(contentWidth)
+	buttons := p.renderButtons(contentWidth)
+	helpView := p.help.View(p)
+
+	// Calculate available height for content.
+	headerHeight := lipgloss.Height(header)
+	buttonsHeight := lipgloss.Height(buttons)
+	helpHeight := lipgloss.Height(helpView)
+	frameHeight := dialogStyle.GetVerticalFrameSize() + layoutSpacingLines
+
+	p.defaultDiffSplitMode = width >= splitModeMinWidth
+
+	// Pre-render content to measure its actual height.
+	renderedContent := p.renderContent(contentWidth)
+	contentHeight := lipgloss.Height(renderedContent)
+
+	// For non-diff views, shrink dialog to fit content if it's smaller than max.
+	var availableHeight int
+	if !p.hasDiffView() && !forceFullscreen {
+		fixedHeight := headerHeight + buttonsHeight + helpHeight + frameHeight
+		neededHeight := fixedHeight + contentHeight
+		if neededHeight < maxHeight {
+			availableHeight = contentHeight
+		} else {
+			availableHeight = maxHeight - fixedHeight
+		}
+	} else {
+		availableHeight = maxHeight - headerHeight - buttonsHeight - helpHeight - frameHeight
+	}
+
+	// Determine if scrollbar is needed.
+	needsScrollbar := p.hasDiffView() || contentHeight > availableHeight
+	viewportWidth := contentWidth
+	if needsScrollbar {
+		viewportWidth = contentWidth - 1 // Reserve space for scrollbar.
+	}
+
+	if p.viewport.Width() != viewportWidth {
+		// Mark content as dirty if width has changed.
+		p.viewportDirty = true
+		renderedContent = p.renderContent(viewportWidth)
+	}
+
+	var content string
+	var scrollbar string
+	p.viewport.SetWidth(viewportWidth)
+	p.viewport.SetHeight(availableHeight)
+	if p.viewportDirty {
+		p.viewport.SetContent(renderedContent)
+		p.viewportWidth = p.viewport.Width()
+		p.viewportDirty = false
+	}
+	content = p.viewport.View()
+	if needsScrollbar {
+		scrollbar = common.Scrollbar(t, availableHeight, p.viewport.TotalLineCount(), availableHeight, p.viewport.YOffset())
+	}
+
+	// Join content with scrollbar if present.
+	if scrollbar != "" {
+		content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar)
+	}
+
+	parts := []string{header}
+	if content != "" {
+		parts = append(parts, "", content)
+	}
+	parts = append(parts, "", buttons, "", helpView)
+
+	innerContent := lipgloss.JoinVertical(lipgloss.Left, parts...)
+	DrawCenterCursor(scr, area, dialogStyle.Render(innerContent), nil)
+	return nil
+}
+
+func (p *Permissions) renderHeader(contentWidth int) string {
+	t := p.com.Styles
+
+	title := common.DialogTitle(t, "Permission Required", contentWidth-t.Dialog.Title.GetHorizontalFrameSize())
+	title = t.Dialog.Title.Render(title)
+
+	// Tool info.
+	toolLine := p.renderToolName(contentWidth)
+	pathLine := p.renderKeyValue("Path", fsext.PrettyPath(p.permission.Path), contentWidth)
+
+	lines := []string{title, "", toolLine, pathLine}
+
+	// Add tool-specific header info.
+	switch p.permission.ToolName {
+	case tools.BashToolName:
+		if params, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
+			lines = append(lines, p.renderKeyValue("Desc", params.Description, contentWidth))
+		}
+	case tools.DownloadToolName:
+		if params, ok := p.permission.Params.(tools.DownloadPermissionsParams); ok {
+			lines = append(lines, p.renderKeyValue("URL", params.URL, contentWidth))
+			lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(params.FilePath), contentWidth))
+		}
+	case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName, tools.ViewToolName:
+		var filePath string
+		switch params := p.permission.Params.(type) {
+		case tools.EditPermissionsParams:
+			filePath = params.FilePath
+		case tools.WritePermissionsParams:
+			filePath = params.FilePath
+		case tools.MultiEditPermissionsParams:
+			filePath = params.FilePath
+		case tools.ViewPermissionsParams:
+			filePath = params.FilePath
+		}
+		if filePath != "" {
+			lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(filePath), contentWidth))
+		}
+	case tools.LSToolName:
+		if params, ok := p.permission.Params.(tools.LSPermissionsParams); ok {
+			lines = append(lines, p.renderKeyValue("Directory", fsext.PrettyPath(params.Path), contentWidth))
+		}
+	}
+
+	return lipgloss.JoinVertical(lipgloss.Left, lines...)
+}
+
+func (p *Permissions) renderKeyValue(key, value string, width int) string {
+	t := p.com.Styles
+	keyStyle := t.Muted
+	valueStyle := t.Base
+
+	keyStr := keyStyle.Render(key)
+	valueStr := valueStyle.Width(width - lipgloss.Width(keyStr) - 1).Render(" " + value)
+
+	return lipgloss.JoinHorizontal(lipgloss.Left, keyStr, valueStr)
+}
+
+func (p *Permissions) renderToolName(width int) string {
+	toolName := p.permission.ToolName
+
+	// Check if this is an MCP tool (format: mcp_<mcpname>_<toolname>).
+	if strings.HasPrefix(toolName, "mcp_") {
+		parts := strings.SplitN(toolName, "_", 3)
+		if len(parts) == 3 {
+			mcpName := prettyName(parts[1])
+			toolPart := prettyName(parts[2])
+			toolName = fmt.Sprintf("%s %s %s", mcpName, styles.ArrowRightIcon, toolPart)
+		}
+	}
+
+	return p.renderKeyValue("Tool", toolName, width)
+}
+
+// prettyName converts snake_case or kebab-case to Title Case.
+func prettyName(name string) string {
+	name = strings.ReplaceAll(name, "_", " ")
+	name = strings.ReplaceAll(name, "-", " ")
+	return stringext.Capitalize(name)
+}
+
+func (p *Permissions) renderContent(width int) string {
+	switch p.permission.ToolName {
+	case tools.BashToolName:
+		return p.renderBashContent(width)
+	case tools.EditToolName:
+		return p.renderEditContent(width)
+	case tools.WriteToolName:
+		return p.renderWriteContent(width)
+	case tools.MultiEditToolName:
+		return p.renderMultiEditContent(width)
+	case tools.DownloadToolName:
+		return p.renderDownloadContent(width)
+	case tools.FetchToolName:
+		return p.renderFetchContent(width)
+	case tools.AgenticFetchToolName:
+		return p.renderAgenticFetchContent(width)
+	case tools.ViewToolName:
+		return p.renderViewContent(width)
+	case tools.LSToolName:
+		return p.renderLSContent(width)
+	default:
+		return p.renderDefaultContent(width)
+	}
+}
+
+func (p *Permissions) renderBashContent(width int) string {
+	params, ok := p.permission.Params.(tools.BashPermissionsParams)
+	if !ok {
+		return ""
+	}
+
+	return p.renderContentPanel(params.Command, width)
+}
+
+func (p *Permissions) renderEditContent(contentWidth int) string {
+	params, ok := p.permission.Params.(tools.EditPermissionsParams)
+	if !ok {
+		return ""
+	}
+	return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
+}
+
+func (p *Permissions) renderWriteContent(contentWidth int) string {
+	params, ok := p.permission.Params.(tools.WritePermissionsParams)
+	if !ok {
+		return ""
+	}
+	return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
+}
+
+func (p *Permissions) renderMultiEditContent(contentWidth int) string {
+	params, ok := p.permission.Params.(tools.MultiEditPermissionsParams)
+	if !ok {
+		return ""
+	}
+	return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
+}
+
+func (p *Permissions) renderDiff(filePath, oldContent, newContent string, contentWidth int) string {
+	if !p.viewportDirty {
+		if p.isSplitMode() {
+			return p.splitDiffContent
+		}
+		return p.unifiedDiffContent
+	}
+
+	isSplitMode := p.isSplitMode()
+	formatter := common.DiffFormatter(p.com.Styles).
+		Before(fsext.PrettyPath(filePath), oldContent).
+		After(fsext.PrettyPath(filePath), newContent).
+		// TODO: Allow horizontal scrolling instead of cropping. However, the
+		// diffview currently would only background color the width of the
+		// content. If the viewport is wider than the content, the rest of the
+		// line would not be colored properly.
+		Width(contentWidth)
+
+	var result string
+	if isSplitMode {
+		formatter = formatter.Split()
+		p.splitDiffContent = formatter.String()
+		result = p.splitDiffContent
+	} else {
+		formatter = formatter.Unified()
+		p.unifiedDiffContent = formatter.String()
+		result = p.unifiedDiffContent
+	}
+
+	return result
+}
+
+func (p *Permissions) renderDownloadContent(width int) string {
+	params, ok := p.permission.Params.(tools.DownloadPermissionsParams)
+	if !ok {
+		return ""
+	}
+
+	content := fmt.Sprintf("URL: %s\nFile: %s", params.URL, fsext.PrettyPath(params.FilePath))
+	if params.Timeout > 0 {
+		content += fmt.Sprintf("\nTimeout: %ds", params.Timeout)
+	}
+
+	return p.renderContentPanel(content, width)
+}
+
+func (p *Permissions) renderFetchContent(width int) string {
+	params, ok := p.permission.Params.(tools.FetchPermissionsParams)
+	if !ok {
+		return ""
+	}
+
+	return p.renderContentPanel(params.URL, width)
+}
+
+func (p *Permissions) renderAgenticFetchContent(width int) string {
+	params, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams)
+	if !ok {
+		return ""
+	}
+
+	var content string
+	if params.URL != "" {
+		content = fmt.Sprintf("URL: %s\n\nPrompt: %s", params.URL, params.Prompt)
+	} else {
+		content = fmt.Sprintf("Prompt: %s", params.Prompt)
+	}
+
+	return p.renderContentPanel(content, width)
+}
+
+func (p *Permissions) renderViewContent(width int) string {
+	params, ok := p.permission.Params.(tools.ViewPermissionsParams)
+	if !ok {
+		return ""
+	}
+
+	content := fmt.Sprintf("File: %s", fsext.PrettyPath(params.FilePath))
+	if params.Offset > 0 {
+		content += fmt.Sprintf("\nStarting from line: %d", params.Offset+1)
+	}
+	if params.Limit > 0 && params.Limit != 2000 {
+		content += fmt.Sprintf("\nLines to read: %d", params.Limit)
+	}
+
+	return p.renderContentPanel(content, width)
+}
+
+func (p *Permissions) renderLSContent(width int) string {
+	params, ok := p.permission.Params.(tools.LSPermissionsParams)
+	if !ok {
+		return ""
+	}
+
+	content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(params.Path))
+	if len(params.Ignore) > 0 {
+		content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(params.Ignore, ", "))
+	}
+
+	return p.renderContentPanel(content, width)
+}
+
+func (p *Permissions) renderDefaultContent(width int) string {
+	t := p.com.Styles
+	var content string
+	// do not add the description for mcp tools
+	if !strings.HasPrefix(p.permission.ToolName, "mcp_") {
+		content = p.permission.Description
+	}
+
+	// Pretty-print JSON params if available.
+	if p.permission.Params != nil {
+		var paramStr string
+		if str, ok := p.permission.Params.(string); ok {
+			paramStr = str
+		} else {
+			paramStr = fmt.Sprintf("%v", p.permission.Params)
+		}
+
+		var parsed any
+		if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil {
+			if b, err := json.MarshalIndent(parsed, "", "  "); err == nil {
+				jsonContent := string(b)
+				highlighted, err := common.SyntaxHighlight(t, jsonContent, "params.json", t.BgSubtle)
+				if err == nil {
+					jsonContent = highlighted
+				}
+				if content != "" {
+					content += "\n\n"
+				}
+				content += jsonContent
+			}
+		} else if paramStr != "" {
+			if content != "" {
+				content += "\n\n"
+			}
+			content += paramStr
+		}
+	}
+
+	if content == "" {
+		return ""
+	}
+
+	return p.renderContentPanel(strings.TrimSpace(content), width)
+}
+
+// renderContentPanel renders content in a panel with the full width.
+func (p *Permissions) renderContentPanel(content string, width int) string {
+	panelStyle := p.com.Styles.Dialog.ContentPanel
+	return panelStyle.Width(width).Render(content)
+}
+
+func (p *Permissions) renderButtons(contentWidth int) string {
+	buttons := []common.ButtonOpts{
+		{Text: "Allow", UnderlineIndex: 0, Selected: p.selectedOption == 0},
+		{Text: "Allow for Session", UnderlineIndex: 10, Selected: p.selectedOption == 1},
+		{Text: "Deny", UnderlineIndex: 0, Selected: p.selectedOption == 2},
+	}
+
+	content := common.ButtonGroup(p.com.Styles, buttons, "  ")
+
+	// If buttons are too wide, stack them vertically.
+	if lipgloss.Width(content) > contentWidth {
+		content = common.ButtonGroup(p.com.Styles, buttons, "\n")
+		return lipgloss.NewStyle().
+			Width(contentWidth).
+			Align(lipgloss.Center).
+			Render(content)
+	}
+
+	return lipgloss.NewStyle().
+		Width(contentWidth).
+		Align(lipgloss.Right).
+		Render(content)
+}
+
+func (p *Permissions) canScroll() bool {
+	if p.hasDiffView() {
+		// Diff views can always scroll.
+		return true
+	}
+	// For non-diff content, check if viewport has scrollable content.
+	return !p.viewport.AtTop() || !p.viewport.AtBottom()
+}
+
+// ShortHelp implements [help.KeyMap].
+func (p *Permissions) ShortHelp() []key.Binding {
+	bindings := []key.Binding{
+		p.keyMap.Choose,
+		p.keyMap.Select,
+		p.keyMap.Close,
+	}
+
+	if p.canScroll() {
+		bindings = append(bindings, p.keyMap.Scroll)
+	}
+
+	if p.hasDiffView() {
+		bindings = append(bindings,
+			p.keyMap.ToggleDiffMode,
+			p.keyMap.ToggleFullscreen,
+		)
+	}
+
+	return bindings
+}
+
+// FullHelp implements [help.KeyMap].
+func (p *Permissions) FullHelp() [][]key.Binding {
+	return [][]key.Binding{p.ShortHelp()}
+}

+ 133 - 0
internal/ui/dialog/quit.go

@@ -0,0 +1,133 @@
+package dialog
+
+import (
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+// QuitID is the identifier for the quit dialog.
+const QuitID = "quit"
+
+// Quit represents a confirmation dialog for quitting the application.
+type Quit struct {
+	com        *common.Common
+	selectedNo bool // true if "No" button is selected
+	keyMap     struct {
+		LeftRight,
+		EnterSpace,
+		Yes,
+		No,
+		Tab,
+		Close,
+		Quit key.Binding
+	}
+}
+
+var _ Dialog = (*Quit)(nil)
+
+// NewQuit creates a new quit confirmation dialog.
+func NewQuit(com *common.Common) *Quit {
+	q := &Quit{
+		com:        com,
+		selectedNo: true,
+	}
+	q.keyMap.LeftRight = key.NewBinding(
+		key.WithKeys("left", "right"),
+		key.WithHelp("←/→", "switch options"),
+	)
+	q.keyMap.EnterSpace = key.NewBinding(
+		key.WithKeys("enter", " "),
+		key.WithHelp("enter/space", "confirm"),
+	)
+	q.keyMap.Yes = key.NewBinding(
+		key.WithKeys("y", "Y", "ctrl+c"),
+		key.WithHelp("y/Y/ctrl+c", "yes"),
+	)
+	q.keyMap.No = key.NewBinding(
+		key.WithKeys("n", "N"),
+		key.WithHelp("n/N", "no"),
+	)
+	q.keyMap.Tab = key.NewBinding(
+		key.WithKeys("tab"),
+		key.WithHelp("tab", "switch options"),
+	)
+	q.keyMap.Close = CloseKey
+	q.keyMap.Quit = key.NewBinding(
+		key.WithKeys("ctrl+c"),
+		key.WithHelp("ctrl+c", "quit"),
+	)
+	return q
+}
+
+// ID implements [Model].
+func (*Quit) ID() string {
+	return QuitID
+}
+
+// HandleMsg implements [Model].
+func (q *Quit) HandleMsg(msg tea.Msg) Action {
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, q.keyMap.Quit):
+			return ActionQuit{}
+		case key.Matches(msg, q.keyMap.Close):
+			return ActionClose{}
+		case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab):
+			q.selectedNo = !q.selectedNo
+		case key.Matches(msg, q.keyMap.EnterSpace):
+			if !q.selectedNo {
+				return ActionQuit{}
+			}
+			return ActionClose{}
+		case key.Matches(msg, q.keyMap.Yes):
+			return ActionQuit{}
+		case key.Matches(msg, q.keyMap.No, q.keyMap.Close):
+			return ActionClose{}
+		}
+	}
+
+	return nil
+}
+
+// Draw implements [Dialog].
+func (q *Quit) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	const question = "Are you sure you want to quit?"
+	baseStyle := q.com.Styles.Base
+	buttonOpts := []common.ButtonOpts{
+		{Text: "Yep!", Selected: !q.selectedNo, Padding: 3},
+		{Text: "Nope", Selected: q.selectedNo, Padding: 3},
+	}
+	buttons := common.ButtonGroup(q.com.Styles, buttonOpts, " ")
+	content := baseStyle.Render(
+		lipgloss.JoinVertical(
+			lipgloss.Center,
+			question,
+			"",
+			buttons,
+		),
+	)
+
+	view := q.com.Styles.BorderFocus.Render(content)
+	DrawCenter(scr, area, view)
+	return nil
+}
+
+// ShortHelp implements [help.KeyMap].
+func (q *Quit) ShortHelp() []key.Binding {
+	return []key.Binding{
+		q.keyMap.LeftRight,
+		q.keyMap.EnterSpace,
+	}
+}
+
+// FullHelp implements [help.KeyMap].
+func (q *Quit) FullHelp() [][]key.Binding {
+	return [][]key.Binding{
+		{q.keyMap.LeftRight, q.keyMap.EnterSpace, q.keyMap.Yes, q.keyMap.No},
+		{q.keyMap.Tab, q.keyMap.Close},
+	}
+}

+ 297 - 0
internal/ui/dialog/reasoning.go

@@ -0,0 +1,297 @@
+package dialog
+
+import (
+	"errors"
+
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/textinput"
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/list"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/sahilm/fuzzy"
+	"golang.org/x/text/cases"
+	"golang.org/x/text/language"
+)
+
+const (
+	// ReasoningID is the identifier for the reasoning effort dialog.
+	ReasoningID              = "reasoning"
+	reasoningDialogMaxWidth  = 80
+	reasoningDialogMaxHeight = 12
+)
+
+// Reasoning represents a dialog for selecting reasoning effort.
+type Reasoning struct {
+	com   *common.Common
+	help  help.Model
+	list  *list.FilterableList
+	input textinput.Model
+
+	keyMap struct {
+		Select   key.Binding
+		Next     key.Binding
+		Previous key.Binding
+		UpDown   key.Binding
+		Close    key.Binding
+	}
+}
+
+// ReasoningItem represents a reasoning effort list item.
+type ReasoningItem struct {
+	effort    string
+	title     string
+	isCurrent bool
+	t         *styles.Styles
+	m         fuzzy.Match
+	cache     map[int]string
+	focused   bool
+}
+
+var (
+	_ Dialog   = (*Reasoning)(nil)
+	_ ListItem = (*ReasoningItem)(nil)
+)
+
+// NewReasoning creates a new reasoning effort dialog.
+func NewReasoning(com *common.Common) (*Reasoning, error) {
+	r := &Reasoning{com: com}
+
+	help := help.New()
+	help.Styles = com.Styles.DialogHelpStyles()
+	r.help = help
+
+	r.list = list.NewFilterableList()
+	r.list.Focus()
+
+	r.input = textinput.New()
+	r.input.SetVirtualCursor(false)
+	r.input.Placeholder = "Type to filter"
+	r.input.SetStyles(com.Styles.TextInput)
+	r.input.Focus()
+
+	r.keyMap.Select = key.NewBinding(
+		key.WithKeys("enter", "ctrl+y"),
+		key.WithHelp("enter", "confirm"),
+	)
+	r.keyMap.Next = key.NewBinding(
+		key.WithKeys("down", "ctrl+n"),
+		key.WithHelp("↓", "next item"),
+	)
+	r.keyMap.Previous = key.NewBinding(
+		key.WithKeys("up", "ctrl+p"),
+		key.WithHelp("↑", "previous item"),
+	)
+	r.keyMap.UpDown = key.NewBinding(
+		key.WithKeys("up", "down"),
+		key.WithHelp("↑/↓", "choose"),
+	)
+	r.keyMap.Close = CloseKey
+
+	if err := r.setReasoningItems(); err != nil {
+		return nil, err
+	}
+
+	return r, nil
+}
+
+// ID implements Dialog.
+func (r *Reasoning) ID() string {
+	return ReasoningID
+}
+
+// HandleMsg implements [Dialog].
+func (r *Reasoning) HandleMsg(msg tea.Msg) Action {
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, r.keyMap.Close):
+			return ActionClose{}
+		case key.Matches(msg, r.keyMap.Previous):
+			r.list.Focus()
+			if r.list.IsSelectedFirst() {
+				r.list.SelectLast()
+				r.list.ScrollToBottom()
+				break
+			}
+			r.list.SelectPrev()
+			r.list.ScrollToSelected()
+		case key.Matches(msg, r.keyMap.Next):
+			r.list.Focus()
+			if r.list.IsSelectedLast() {
+				r.list.SelectFirst()
+				r.list.ScrollToTop()
+				break
+			}
+			r.list.SelectNext()
+			r.list.ScrollToSelected()
+		case key.Matches(msg, r.keyMap.Select):
+			selectedItem := r.list.SelectedItem()
+			if selectedItem == nil {
+				break
+			}
+			reasoningItem, ok := selectedItem.(*ReasoningItem)
+			if !ok {
+				break
+			}
+			return ActionSelectReasoningEffort{Effort: reasoningItem.effort}
+		default:
+			var cmd tea.Cmd
+			r.input, cmd = r.input.Update(msg)
+			value := r.input.Value()
+			r.list.SetFilter(value)
+			r.list.ScrollToTop()
+			r.list.SetSelected(0)
+			return ActionCmd{cmd}
+		}
+	}
+	return nil
+}
+
+// Cursor returns the cursor position relative to the dialog.
+func (r *Reasoning) Cursor() *tea.Cursor {
+	return InputCursor(r.com.Styles, r.input.Cursor())
+}
+
+// Draw implements [Dialog].
+func (r *Reasoning) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	t := r.com.Styles
+	width := max(0, min(reasoningDialogMaxWidth, area.Dx()))
+	height := max(0, min(reasoningDialogMaxHeight, area.Dy()))
+	innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
+	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
+		t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
+		t.Dialog.HelpView.GetVerticalFrameSize() +
+		t.Dialog.View.GetVerticalFrameSize()
+
+	r.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1)
+	r.list.SetSize(innerWidth, height-heightOffset)
+	r.help.SetWidth(innerWidth)
+
+	rc := NewRenderContext(t, width)
+	rc.Title = "Select Reasoning Effort"
+	inputView := t.Dialog.InputPrompt.Render(r.input.View())
+	rc.AddPart(inputView)
+
+	visibleCount := len(r.list.FilteredItems())
+	if r.list.Height() >= visibleCount {
+		r.list.ScrollToTop()
+	} else {
+		r.list.ScrollToSelected()
+	}
+
+	listView := t.Dialog.List.Height(r.list.Height()).Render(r.list.Render())
+	rc.AddPart(listView)
+	rc.Help = r.help.View(r)
+
+	view := rc.Render()
+
+	cur := r.Cursor()
+	DrawCenterCursor(scr, area, view, cur)
+	return cur
+}
+
+// ShortHelp implements [help.KeyMap].
+func (r *Reasoning) ShortHelp() []key.Binding {
+	return []key.Binding{
+		r.keyMap.UpDown,
+		r.keyMap.Select,
+		r.keyMap.Close,
+	}
+}
+
+// FullHelp implements [help.KeyMap].
+func (r *Reasoning) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := []key.Binding{
+		r.keyMap.Select,
+		r.keyMap.Next,
+		r.keyMap.Previous,
+		r.keyMap.Close,
+	}
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+func (r *Reasoning) setReasoningItems() error {
+	cfg := r.com.Config()
+	agentCfg, ok := cfg.Agents[config.AgentCoder]
+	if !ok {
+		return errors.New("agent configuration not found")
+	}
+
+	selectedModel := cfg.Models[agentCfg.Model]
+	model := cfg.GetModelByType(agentCfg.Model)
+	if model == nil {
+		return errors.New("model configuration not found")
+	}
+
+	if len(model.ReasoningLevels) == 0 {
+		return errors.New("no reasoning levels available")
+	}
+
+	currentEffort := selectedModel.ReasoningEffort
+	if currentEffort == "" {
+		currentEffort = model.DefaultReasoningEffort
+	}
+
+	caser := cases.Title(language.English)
+	items := make([]list.FilterableItem, 0, len(model.ReasoningLevels))
+	selectedIndex := 0
+	for i, effort := range model.ReasoningLevels {
+		item := &ReasoningItem{
+			effort:    effort,
+			title:     caser.String(effort),
+			isCurrent: effort == currentEffort,
+			t:         r.com.Styles,
+		}
+		items = append(items, item)
+		if effort == currentEffort {
+			selectedIndex = i
+		}
+	}
+
+	r.list.SetItems(items...)
+	r.list.SetSelected(selectedIndex)
+	r.list.ScrollToSelected()
+	return nil
+}
+
+// Filter returns the filter value for the reasoning item.
+func (r *ReasoningItem) Filter() string {
+	return r.title
+}
+
+// ID returns the unique identifier for the reasoning effort.
+func (r *ReasoningItem) ID() string {
+	return r.effort
+}
+
+// SetFocused sets the focus state of the reasoning item.
+func (r *ReasoningItem) SetFocused(focused bool) {
+	if r.focused != focused {
+		r.cache = nil
+	}
+	r.focused = focused
+}
+
+// SetMatch sets the fuzzy match for the reasoning item.
+func (r *ReasoningItem) SetMatch(m fuzzy.Match) {
+	r.cache = nil
+	r.m = m
+}
+
+// Render returns the string representation of the reasoning item.
+func (r *ReasoningItem) Render(width int) string {
+	info := ""
+	if r.isCurrent {
+		info = "current"
+	}
+	return renderItem(r.t, r.title, info, r.focused, width, r.cache, &r.m)
+}

+ 201 - 0
internal/ui/dialog/sessions.go

@@ -0,0 +1,201 @@
+package dialog
+
+import (
+	"context"
+
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/textinput"
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/list"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+// SessionsID is the identifier for the session selector dialog.
+const SessionsID = "session"
+
+// Session is a session selector dialog.
+type Session struct {
+	com                *common.Common
+	help               help.Model
+	list               *list.FilterableList
+	input              textinput.Model
+	selectedSessionInx int
+
+	keyMap struct {
+		Select   key.Binding
+		Next     key.Binding
+		Previous key.Binding
+		UpDown   key.Binding
+		Close    key.Binding
+	}
+}
+
+var _ Dialog = (*Session)(nil)
+
+// NewSessions creates a new Session dialog.
+func NewSessions(com *common.Common, selectedSessionID string) (*Session, error) {
+	s := new(Session)
+	s.com = com
+	sessions, err := com.App.Sessions.List(context.TODO())
+	if err != nil {
+		return nil, err
+	}
+
+	for i, sess := range sessions {
+		if sess.ID == selectedSessionID {
+			s.selectedSessionInx = i
+			break
+		}
+	}
+
+	help := help.New()
+	help.Styles = com.Styles.DialogHelpStyles()
+
+	s.help = help
+	s.list = list.NewFilterableList(sessionItems(com.Styles, sessions...)...)
+	s.list.Focus()
+	s.list.SetSelected(s.selectedSessionInx)
+
+	s.input = textinput.New()
+	s.input.SetVirtualCursor(false)
+	s.input.Placeholder = "Enter session name"
+	s.input.SetStyles(com.Styles.TextInput)
+	s.input.Focus()
+
+	s.keyMap.Select = key.NewBinding(
+		key.WithKeys("enter", "tab", "ctrl+y"),
+		key.WithHelp("enter", "choose"),
+	)
+	s.keyMap.Next = key.NewBinding(
+		key.WithKeys("down", "ctrl+n"),
+		key.WithHelp("↓", "next item"),
+	)
+	s.keyMap.Previous = key.NewBinding(
+		key.WithKeys("up", "ctrl+p"),
+		key.WithHelp("↑", "previous item"),
+	)
+	s.keyMap.UpDown = key.NewBinding(
+		key.WithKeys("up", "down"),
+		key.WithHelp("↑↓", "choose"),
+	)
+	s.keyMap.Close = CloseKey
+
+	return s, nil
+}
+
+// ID implements Dialog.
+func (s *Session) ID() string {
+	return SessionsID
+}
+
+// HandleMsg implements Dialog.
+func (s *Session) HandleMsg(msg tea.Msg) Action {
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, s.keyMap.Close):
+			return ActionClose{}
+		case key.Matches(msg, s.keyMap.Previous):
+			s.list.Focus()
+			if s.list.IsSelectedFirst() {
+				s.list.SelectLast()
+				s.list.ScrollToBottom()
+				break
+			}
+			s.list.SelectPrev()
+			s.list.ScrollToSelected()
+		case key.Matches(msg, s.keyMap.Next):
+			s.list.Focus()
+			if s.list.IsSelectedLast() {
+				s.list.SelectFirst()
+				s.list.ScrollToTop()
+				break
+			}
+			s.list.SelectNext()
+			s.list.ScrollToSelected()
+		case key.Matches(msg, s.keyMap.Select):
+			if item := s.list.SelectedItem(); item != nil {
+				sessionItem := item.(*SessionItem)
+				return ActionSelectSession{sessionItem.Session}
+			}
+		default:
+			var cmd tea.Cmd
+			s.input, cmd = s.input.Update(msg)
+			value := s.input.Value()
+			s.list.SetFilter(value)
+			s.list.ScrollToTop()
+			s.list.SetSelected(0)
+			return ActionCmd{cmd}
+		}
+	}
+	return nil
+}
+
+// Cursor returns the cursor position relative to the dialog.
+func (s *Session) Cursor() *tea.Cursor {
+	return InputCursor(s.com.Styles, s.input.Cursor())
+}
+
+// Draw implements [Dialog].
+func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	t := s.com.Styles
+	width := max(0, min(defaultDialogMaxWidth, area.Dx()))
+	height := max(0, min(defaultDialogHeight, area.Dy()))
+	innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2
+	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
+		t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
+		t.Dialog.HelpView.GetVerticalFrameSize() +
+		t.Dialog.View.GetVerticalFrameSize()
+	s.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
+	s.list.SetSize(innerWidth, height-heightOffset)
+	s.help.SetWidth(innerWidth)
+
+	// This makes it so we do not scroll the list if we don't have to
+	start, end := s.list.VisibleItemIndices()
+
+	// if selected index is outside visible range, scroll to it
+	if s.selectedSessionInx < start || s.selectedSessionInx > end {
+		s.list.ScrollToSelected()
+	}
+
+	rc := NewRenderContext(t, width)
+	rc.Title = "Switch Session"
+	inputView := t.Dialog.InputPrompt.Render(s.input.View())
+	rc.AddPart(inputView)
+	listView := t.Dialog.List.Height(s.list.Height()).Render(s.list.Render())
+	rc.AddPart(listView)
+	rc.Help = s.help.View(s)
+
+	view := rc.Render()
+
+	cur := s.Cursor()
+	DrawCenterCursor(scr, area, view, cur)
+	return cur
+}
+
+// ShortHelp implements [help.KeyMap].
+func (s *Session) ShortHelp() []key.Binding {
+	return []key.Binding{
+		s.keyMap.UpDown,
+		s.keyMap.Select,
+		s.keyMap.Close,
+	}
+}
+
+// FullHelp implements [help.KeyMap].
+func (s *Session) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := []key.Binding{
+		s.keyMap.Select,
+		s.keyMap.Next,
+		s.keyMap.Previous,
+		s.keyMap.Close,
+	}
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}

+ 187 - 0
internal/ui/dialog/sessions_item.go

@@ -0,0 +1,187 @@
+package dialog
+
+import (
+	"fmt"
+	"strings"
+	"time"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/ui/list"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/dustin/go-humanize"
+	"github.com/rivo/uniseg"
+	"github.com/sahilm/fuzzy"
+)
+
+// ListItem represents a selectable and searchable item in a dialog list.
+type ListItem interface {
+	list.FilterableItem
+	list.Focusable
+	list.MatchSettable
+
+	// ID returns the unique identifier of the item.
+	ID() string
+}
+
+// SessionItem wraps a [session.Session] to implement the [ListItem] interface.
+type SessionItem struct {
+	session.Session
+	t       *styles.Styles
+	m       fuzzy.Match
+	cache   map[int]string
+	focused bool
+}
+
+var _ ListItem = &SessionItem{}
+
+// Filter returns the filterable value of the session.
+func (s *SessionItem) Filter() string {
+	return s.Title
+}
+
+// ID returns the unique identifier of the session.
+func (s *SessionItem) ID() string {
+	return s.Session.ID
+}
+
+// SetMatch sets the fuzzy match for the session item.
+func (s *SessionItem) SetMatch(m fuzzy.Match) {
+	s.cache = nil
+	s.m = m
+}
+
+// Render returns the string representation of the session item.
+func (s *SessionItem) Render(width int) string {
+	info := humanize.Time(time.Unix(s.UpdatedAt, 0))
+	return renderItem(s.t, s.Title, info, s.focused, width, s.cache, &s.m)
+}
+
+func renderItem(t *styles.Styles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
+	if cache == nil {
+		cache = make(map[int]string)
+	}
+
+	cached, ok := cache[width]
+	if ok {
+		return cached
+	}
+
+	style := t.Dialog.NormalItem
+	if focused {
+		style = t.Dialog.SelectedItem
+	}
+
+	var infoText string
+	var infoWidth int
+	lineWidth := width
+	if len(info) > 0 {
+		infoText = fmt.Sprintf(" %s ", info)
+		if focused {
+			infoText = t.Base.Render(infoText)
+		} else {
+			infoText = t.Subtle.Render(infoText)
+		}
+
+		infoWidth = lipgloss.Width(infoText)
+	}
+
+	title = ansi.Truncate(title, max(0, lineWidth-infoWidth), "")
+	titleWidth := lipgloss.Width(title)
+	gap := strings.Repeat(" ", max(0, lineWidth-titleWidth-infoWidth))
+	content := title
+	if matches := len(m.MatchedIndexes); matches > 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])
+			}
+			// NOTE: We're using [ansi.Style] here instead of [lipglosStyle]
+			// because we can control the underline start and stop more
+			// precisely via [ansi.AttrUnderline] and [ansi.AttrNoUnderline]
+			// which only affect the underline attribute without interfering
+			// with other style
+			parts = append(parts,
+				ansi.NewStyle().Underline(true).String(),
+				title[start:stop+1],
+				ansi.NewStyle().Underline(false).String(),
+			)
+			lastPos = stop + 1
+		}
+		if lastPos < len(title) {
+			parts = append(parts, title[lastPos:])
+		}
+
+		content = strings.Join(parts, "")
+	}
+
+	content = style.Render(content + gap + infoText)
+	cache[width] = content
+	return content
+}
+
+// SetFocused sets the focus state of the session item.
+func (s *SessionItem) SetFocused(focused bool) {
+	if s.focused != focused {
+		s.cache = nil
+	}
+	s.focused = focused
+}
+
+// sessionItems takes a slice of [session.Session]s and convert them to a slice
+// of [ListItem]s.
+func sessionItems(t *styles.Styles, sessions ...session.Session) []list.FilterableItem {
+	items := make([]list.FilterableItem, len(sessions))
+	for i, s := range sessions {
+		items[i] = &SessionItem{Session: s, t: t}
+	}
+	return items
+}
+
+func matchedRanges(in []int) [][2]int {
+	if len(in) == 0 {
+		return [][2]int{}
+	}
+	current := [2]int{in[0], in[0]}
+	if len(in) == 1 {
+		return [][2]int{current}
+	}
+	var out [][2]int
+	for i := 1; i < len(in); i++ {
+		if in[i] == current[1]+1 {
+			current[1] = in[i]
+		} else {
+			out = append(out, current)
+			current = [2]int{in[i], in[i]}
+		}
+	}
+	out = append(out, current)
+	return out
+}
+
+func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
+	bytePos, byteStart, byteStop := 0, rng[0], rng[1]
+	pos, start, stop := 0, 0, 0
+	gr := uniseg.NewGraphemes(str)
+	for byteStart > bytePos {
+		if !gr.Next() {
+			break
+		}
+		bytePos += len(gr.Str())
+		pos += max(1, gr.Width())
+	}
+	start = pos
+	for byteStop > bytePos {
+		if !gr.Next() {
+			break
+		}
+		bytePos += len(gr.Str())
+		pos += max(1, gr.Width())
+	}
+	stop = pos
+	return start, stop
+}

+ 331 - 0
internal/ui/image/image.go

@@ -0,0 +1,331 @@
+package image
+
+import (
+	"bytes"
+	"fmt"
+	"hash/fnv"
+	"image"
+	"image/color"
+	"io"
+	"log/slog"
+	"strings"
+	"sync"
+
+	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 {
+	ID string
+}
+
+// Encoding represents the encoding format of the image.
+type Encoding byte
+
+// Image encodings.
+const (
+	EncodingBlocks Encoding = iota
+	EncodingKitty
+)
+
+type imageKey struct {
+	id   string
+	cols int
+	rows int
+}
+
+// Hash returns a hash value for the image key.
+// This uses FNV-32a for simplicity and speed.
+func (k imageKey) Hash() uint32 {
+	h := fnv.New32a()
+	_, _ = io.WriteString(h, k.ID())
+	return h.Sum32()
+}
+
+// ID returns a unique string representation of the image key.
+func (k imageKey) ID() string {
+	return fmt.Sprintf("%s-%dx%d", k.id, k.cols, k.rows)
+}
+
+// CellSize represents the size of a single terminal cell in pixels.
+type CellSize struct {
+	Width, Height int
+}
+
+type cachedImage struct {
+	img        image.Image
+	cols, rows int
+}
+
+var (
+	cachedImages = map[imageKey]cachedImage{}
+	cachedMutex  sync.RWMutex
+)
+
+// fitImage resizes the image to fit within the specified dimensions in
+// terminal cells, maintaining the aspect ratio.
+func fitImage(id string, img image.Image, cs CellSize, cols, rows int) image.Image {
+	if img == nil {
+		return nil
+	}
+
+	key := imageKey{id: id, cols: cols, rows: rows}
+
+	cachedMutex.RLock()
+	cached, ok := cachedImages[key]
+	cachedMutex.RUnlock()
+	if ok {
+		return cached.img
+	}
+
+	if cs.Width == 0 || cs.Height == 0 {
+		return img
+	}
+
+	maxWidth := cols * cs.Width
+	maxHeight := rows * cs.Height
+
+	img = imaging.Fit(img, maxWidth, maxHeight, imaging.Lanczos)
+
+	cachedMutex.Lock()
+	cachedImages[key] = cachedImage{
+		img:  img,
+		cols: cols,
+		rows: rows,
+	}
+	cachedMutex.Unlock()
+
+	return img
+}
+
+// HasTransmitted checks if the image with the given ID has already been
+// transmitted to the terminal.
+func HasTransmitted(id string, cols, rows int) bool {
+	key := imageKey{id: id, cols: cols, rows: rows}
+
+	cachedMutex.RLock()
+	_, ok := cachedImages[key]
+	cachedMutex.RUnlock()
+	return ok
+}
+
+// Transmit transmits the image data to the terminal if needed. This is used to
+// cache the image on the terminal for later rendering.
+func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows int, tmux bool) tea.Cmd {
+	if img == nil {
+		return nil
+	}
+
+	key := imageKey{id: id, cols: cols, rows: rows}
+
+	cachedMutex.RLock()
+	_, ok := cachedImages[key]
+	cachedMutex.RUnlock()
+	if ok {
+		return nil
+	}
+
+	cmd := func() tea.Msg {
+		if e != EncodingKitty {
+			cachedMutex.Lock()
+			cachedImages[key] = cachedImage{
+				img:  img,
+				cols: cols,
+				rows: rows,
+			}
+			cachedMutex.Unlock()
+			return TransmittedMsg{ID: key.ID()}
+		}
+
+		var buf bytes.Buffer
+		img := fitImage(id, img, cs, cols, rows)
+		bounds := img.Bounds()
+		imgWidth := bounds.Dx()
+		imgHeight := bounds.Dy()
+		imgID := int(key.Hash())
+		if err := kitty.EncodeGraphics(&buf, img, &kitty.Options{
+			ID:               imgID,
+			Action:           kitty.TransmitAndPut,
+			Transmission:     kitty.Direct,
+			Format:           kitty.RGBA,
+			ImageWidth:       imgWidth,
+			ImageHeight:      imgHeight,
+			Columns:          cols,
+			Rows:             rows,
+			VirtualPlacement: true,
+			Quite:            1,
+			Chunk:            true,
+			ChunkFormatter: func(chunk string) string {
+				if tmux {
+					return ansi.TmuxPassthrough(chunk)
+				}
+				return chunk
+			},
+		}); err != nil {
+			slog.Error("failed to encode image for kitty graphics", "err", err)
+			return uiutil.InfoMsg{
+				Type: uiutil.InfoTypeError,
+				Msg:  "failed to encode image",
+			}
+		}
+
+		return tea.RawMsg{Msg: buf.String()}
+	}
+
+	return cmd
+}
+
+// Render renders the given image within the specified dimensions using the
+// specified encoding.
+func (e Encoding) Render(id string, cols, rows int) string {
+	key := imageKey{id: id, cols: cols, rows: rows}
+	cachedMutex.RLock()
+	cached, ok := cachedImages[key]
+	cachedMutex.RUnlock()
+	if !ok {
+		return ""
+	}
+
+	img := cached.img
+
+	switch e {
+	case EncodingBlocks:
+		canvas := paintbrush.New()
+		canvas.SetImage(img)
+		canvas.SetWidth(cols)
+		canvas.SetHeight(rows)
+		canvas.Weights = map[rune]float64{
+			'': .95,
+			'': .95,
+			'▁': .9,
+			'▂': .9,
+			'▃': .9,
+			'▄': .9,
+			'▅': .9,
+			'▆': .85,
+			'█': .85,
+			'▊': .95,
+			'▋': .95,
+			'▌': .95,
+			'▍': .95,
+			'▎': .95,
+			'▏': .95,
+			'●': .95,
+			'◀': .95,
+			'▲': .95,
+			'▶': .95,
+			'▼': .9,
+			'○': .8,
+			'◉': .95,
+			'◧': .9,
+			'◨': .9,
+			'◩': .9,
+			'◪': .9,
+		}
+		canvas.Paint()
+		return strings.TrimSpace(canvas.GetResult())
+	case EncodingKitty:
+		// Build Kitty graphics unicode place holders
+		var fg color.Color
+		var extra int
+		var r, g, b int
+		hashedID := key.Hash()
+		id := int(hashedID)
+		extra, r, g, b = id>>24&0xff, id>>16&0xff, id>>8&0xff, id&0xff
+
+		if id <= 255 {
+			fg = ansi.IndexedColor(b)
+		} else {
+			fg = color.RGBA{
+				R: uint8(r), //nolint:gosec
+				G: uint8(g), //nolint:gosec
+				B: uint8(b), //nolint:gosec
+				A: 0xff,
+			}
+		}
+
+		fgStyle := ansi.NewStyle().ForegroundColor(fg).String()
+
+		var buf bytes.Buffer
+		for y := range rows {
+			// As an optimization, we only write the fg color sequence id, and
+			// column-row data once on the first cell. The terminal will handle
+			// the rest.
+			buf.WriteString(fgStyle)
+			buf.WriteRune(kitty.Placeholder)
+			buf.WriteRune(kitty.Diacritic(y))
+			buf.WriteRune(kitty.Diacritic(0))
+			if extra > 0 {
+				buf.WriteRune(kitty.Diacritic(extra))
+			}
+			for x := 1; x < cols; x++ {
+				buf.WriteString(fgStyle)
+				buf.WriteRune(kitty.Placeholder)
+			}
+			if y < rows-1 {
+				buf.WriteByte('\n')
+			}
+		}
+
+		return buf.String()
+
+	default:
+		return ""
+	}
+}

+ 125 - 0
internal/ui/list/filterable.go

@@ -0,0 +1,125 @@
+package list
+
+import (
+	"github.com/sahilm/fuzzy"
+)
+
+// FilterableItem is an item that can be filtered via a query.
+type FilterableItem interface {
+	Item
+	// Filter returns the value to be used for filtering.
+	Filter() string
+}
+
+// MatchSettable is an interface for items that can have their match indexes
+// and match score set.
+type MatchSettable interface {
+	SetMatch(fuzzy.Match)
+}
+
+// FilterableList is a list that takes filterable items that can be filtered
+// via a settable query.
+type FilterableList struct {
+	*List
+	items []FilterableItem
+	query string
+}
+
+// NewFilterableList creates a new filterable list.
+func NewFilterableList(items ...FilterableItem) *FilterableList {
+	f := &FilterableList{
+		List:  NewList(),
+		items: items,
+	}
+	f.RegisterRenderCallback(FocusedRenderCallback(f.List))
+	f.SetItems(items...)
+	return f
+}
+
+// SetItems sets the list items and updates the filtered items.
+func (f *FilterableList) SetItems(items ...FilterableItem) {
+	f.items = items
+	fitems := make([]Item, len(items))
+	for i, item := range items {
+		fitems[i] = item
+	}
+	f.List.SetItems(fitems...)
+}
+
+// AppendItems appends items to the list and updates the filtered items.
+func (f *FilterableList) AppendItems(items ...FilterableItem) {
+	f.items = append(f.items, items...)
+	itms := make([]Item, len(f.items))
+	for i, item := range f.items {
+		itms[i] = item
+	}
+	f.List.SetItems(itms...)
+}
+
+// PrependItems prepends items to the list and updates the filtered items.
+func (f *FilterableList) PrependItems(items ...FilterableItem) {
+	f.items = append(items, f.items...)
+	itms := make([]Item, len(f.items))
+	for i, item := range f.items {
+		itms[i] = item
+	}
+	f.List.SetItems(itms...)
+}
+
+// SetFilter sets the filter query and updates the list items.
+func (f *FilterableList) SetFilter(q string) {
+	f.query = q
+	f.List.SetItems(f.FilteredItems()...)
+	f.ScrollToTop()
+}
+
+// FilterableItemsSource is a type that implements [fuzzy.Source] for filtering
+// [FilterableItem]s.
+type FilterableItemsSource []FilterableItem
+
+// Len returns the length of the source.
+func (f FilterableItemsSource) Len() int {
+	return len(f)
+}
+
+// String returns the string representation of the item at index i.
+func (f FilterableItemsSource) String(i int) string {
+	return f[i].Filter()
+}
+
+// FilteredItems returns the visible items after filtering.
+func (f *FilterableList) FilteredItems() []Item {
+	if f.query == "" {
+		items := make([]Item, len(f.items))
+		for i, item := range f.items {
+			if ms, ok := item.(MatchSettable); ok {
+				ms.SetMatch(fuzzy.Match{})
+				item = ms.(FilterableItem)
+			}
+			items[i] = item
+		}
+		return items
+	}
+
+	items := FilterableItemsSource(f.items)
+	matches := fuzzy.FindFrom(f.query, items)
+	matchedItems := []Item{}
+	resultSize := len(matches)
+	for i := range resultSize {
+		match := matches[i]
+		item := items[match.Index]
+		if ms, ok := item.(MatchSettable); ok {
+			ms.SetMatch(match)
+			item = ms.(FilterableItem)
+		}
+		matchedItems = append(matchedItems, item)
+	}
+
+	return matchedItems
+}
+
+// Render renders the filterable list.
+func (f *FilterableList) Render() string {
+	f.List.SetItems(f.FilteredItems()...)
+	return f.List.Render()
+}

+ 13 - 0
internal/ui/list/focus.go

@@ -0,0 +1,13 @@
+package list
+
+// FocusedRenderCallback is a helper function that returns a render callback
+// that marks items as focused during rendering.
+func FocusedRenderCallback(list *List) RenderCallback {
+	return func(idx, selectedIdx int, item Item) Item {
+		if focusable, ok := item.(Focusable); ok {
+			focusable.SetFocused(list.Focused() && idx == selectedIdx)
+			return focusable.(Item)
+		}
+		return item
+	}
+}

+ 208 - 0
internal/ui/list/highlight.go

@@ -0,0 +1,208 @@
+package list
+
+import (
+	"image"
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+// DefaultHighlighter is the default highlighter function that applies inverse style.
+var DefaultHighlighter Highlighter = func(x, y int, c *uv.Cell) *uv.Cell {
+	if c == nil {
+		return c
+	}
+	c.Style.Attrs |= uv.AttrReverse
+	return c
+}
+
+// Highlighter represents a function that defines how to highlight text.
+type Highlighter func(x, y int, c *uv.Cell) *uv.Cell
+
+// HighlightContent returns the content with highlighted regions based on the specified parameters.
+func HighlightContent(content string, area image.Rectangle, startLine, startCol, endLine, endCol int) string {
+	var sb strings.Builder
+	pos := image.Pt(-1, -1)
+	HighlightBuffer(content, area, startLine, startCol, endLine, endCol, func(x, y int, c *uv.Cell) *uv.Cell {
+		pos.X = x
+		if pos.Y == -1 {
+			pos.Y = y
+		} else if y > pos.Y {
+			sb.WriteString(strings.Repeat("\n", y-pos.Y))
+			pos.Y = y
+		}
+		sb.WriteString(c.Content)
+		return c
+	})
+	if sb.Len() > 0 {
+		sb.WriteString("\n")
+	}
+	return sb.String()
+}
+
+// Highlight highlights a region of text within the given content and region.
+func Highlight(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) string {
+	buf := HighlightBuffer(content, area, startLine, startCol, endLine, endCol, highlighter)
+	if buf == nil {
+		return content
+	}
+	return buf.Render()
+}
+
+// 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 {
+	if startLine < 0 || startCol < 0 {
+		return nil
+	}
+
+	if highlighter == nil {
+		highlighter = DefaultHighlighter
+	}
+
+	width, height := area.Dx(), area.Dy()
+	buf := uv.NewScreenBuffer(width, height)
+	styled := uv.NewStyledString(content)
+	styled.Draw(&buf, area)
+
+	// Treat -1 as "end of content"
+	if endLine < 0 {
+		endLine = height - 1
+	}
+	if endCol < 0 {
+		endCol = width
+	}
+
+	for y := startLine; y <= endLine && y < height; y++ {
+		if y >= buf.Height() {
+			break
+		}
+
+		line := buf.Line(y)
+
+		// Determine column range for this line
+		colStart := 0
+		if y == startLine {
+			colStart = min(startCol, len(line))
+		}
+
+		colEnd := len(line)
+		if y == endLine {
+			colEnd = min(endCol, len(line))
+		}
+
+		// Track last non-empty position as we go
+		lastContentX := -1
+
+		// Single pass: check content and track last non-empty position
+		for x := colStart; x < colEnd; x++ {
+			cell := line.At(x)
+			if cell == nil {
+				continue
+			}
+
+			// Update last content position if non-empty
+			if cell.Content != "" && cell.Content != " " {
+				lastContentX = x
+			}
+		}
+
+		// Only apply highlight up to last content position
+		highlightEnd := colEnd
+		if lastContentX >= 0 {
+			highlightEnd = lastContentX + 1
+		} else if lastContentX == -1 {
+			highlightEnd = colStart // No content on this line
+		}
+
+		// Apply highlight style only to cells with content
+		for x := colStart; x < highlightEnd; x++ {
+			if !image.Pt(x, y).In(area) {
+				continue
+			}
+			cell := line.At(x)
+			if cell != nil {
+				line.Set(x, highlighter(x, y, cell))
+			}
+		}
+	}
+
+	return &buf
+}
+
+// ToHighlighter converts a [lipgloss.Style] to a [Highlighter].
+func ToHighlighter(lgStyle lipgloss.Style) Highlighter {
+	return func(_ int, _ int, c *uv.Cell) *uv.Cell {
+		if c != nil {
+			c.Style = ToStyle(lgStyle)
+		}
+		return c
+	}
+}
+
+// ToStyle converts an inline [lipgloss.Style] to a [uv.Style].
+func ToStyle(lgStyle lipgloss.Style) uv.Style {
+	var uvStyle uv.Style
+
+	// Colors are already color.Color
+	uvStyle.Fg = lgStyle.GetForeground()
+	uvStyle.Bg = lgStyle.GetBackground()
+
+	// Build attributes using bitwise OR
+	var attrs uint8
+
+	if lgStyle.GetBold() {
+		attrs |= uv.AttrBold
+	}
+
+	if lgStyle.GetItalic() {
+		attrs |= uv.AttrItalic
+	}
+
+	if lgStyle.GetUnderline() {
+		uvStyle.Underline = uv.UnderlineSingle
+	}
+
+	if lgStyle.GetStrikethrough() {
+		attrs |= uv.AttrStrikethrough
+	}
+
+	if lgStyle.GetFaint() {
+		attrs |= uv.AttrFaint
+	}
+
+	if lgStyle.GetBlink() {
+		attrs |= uv.AttrBlink
+	}
+
+	if lgStyle.GetReverse() {
+		attrs |= uv.AttrReverse
+	}
+
+	uvStyle.Attrs = attrs
+
+	return uvStyle
+}
+
+// AdjustArea adjusts the given area rectangle by subtracting margins, borders,
+// and padding from the style.
+func AdjustArea(area image.Rectangle, style lipgloss.Style) image.Rectangle {
+	topMargin, rightMargin, bottomMargin, leftMargin := style.GetMargin()
+	topBorder, rightBorder, bottomBorder, leftBorder := style.GetBorderTopSize(),
+		style.GetBorderRightSize(),
+		style.GetBorderBottomSize(),
+		style.GetBorderLeftSize()
+	topPadding, rightPadding, bottomPadding, leftPadding := style.GetPadding()
+
+	return image.Rectangle{
+		Min: image.Point{
+			X: area.Min.X + leftMargin + leftBorder + leftPadding,
+			Y: area.Min.Y + topMargin + topBorder + topPadding,
+		},
+		Max: image.Point{
+			X: area.Max.X - (rightMargin + rightBorder + rightPadding),
+			Y: area.Max.Y - (bottomMargin + bottomBorder + bottomPadding),
+		},
+	}
+}

+ 61 - 0
internal/ui/list/item.go

@@ -0,0 +1,61 @@
+package list
+
+import (
+	"strings"
+
+	"github.com/charmbracelet/x/ansi"
+)
+
+// Item represents a single item in the lazy-loaded list.
+type Item interface {
+	// Render returns the string representation of the item for the given
+	// width.
+	Render(width int) string
+}
+
+// RawRenderable represents an item that can provide a raw rendering
+// without additional styling.
+type RawRenderable interface {
+	// RawRender returns the raw rendered string without any additional
+	// styling.
+	RawRender(width int) string
+}
+
+// Focusable represents an item that can be aware of focus state changes.
+type Focusable interface {
+	// SetFocused sets the focus state of the item.
+	SetFocused(focused bool)
+}
+
+// Highlightable represents an item that can highlight a portion of its content.
+type Highlightable interface {
+	// SetHighlight highlights the content from the given start to end
+	// positions. Use -1 for no highlight.
+	SetHighlight(startLine, startCol, endLine, endCol int)
+	// Highlight returns the current highlight positions within the item.
+	Highlight() (startLine, startCol, endLine, endCol int)
+}
+
+// MouseClickable represents an item that can handle mouse click events.
+type MouseClickable interface {
+	// HandleMouseClick processes a mouse click event at the given coordinates.
+	// It returns true if the event was handled, false otherwise.
+	HandleMouseClick(btn ansi.MouseButton, x, y int) bool
+}
+
+// SpacerItem is a spacer item that adds vertical space in the list.
+type SpacerItem struct {
+	Height int
+}
+
+// NewSpacerItem creates a new [SpacerItem] with the specified height.
+func NewSpacerItem(height int) *SpacerItem {
+	return &SpacerItem{
+		Height: max(0, height-1),
+	}
+}
+
+// Render implements the Item interface for [SpacerItem].
+func (s *SpacerItem) Render(width int) string {
+	return strings.Repeat("\n", s.Height)
+}

+ 660 - 0
internal/ui/list/list.go

@@ -0,0 +1,660 @@
+package list
+
+import (
+	"strings"
+)
+
+// List represents a list of items that can be lazily rendered. A list is
+// always rendered like a chat conversation where items are stacked vertically
+// from top to bottom.
+type List struct {
+	// Viewport size
+	width, height int
+
+	// Items in the list
+	items []Item
+
+	// Gap between items (0 or less means no gap)
+	gap int
+
+	// show list in reverse order
+	reverse bool
+
+	// Focus and selection state
+	focused     bool
+	selectedIdx int // The current selected index -1 means no selection
+
+	// offsetIdx is the index of the first visible item in the viewport.
+	offsetIdx int
+	// offsetLine is the number of lines of the item at offsetIdx that are
+	// scrolled out of view (above the viewport).
+	// It must always be >= 0.
+	offsetLine int
+
+	// renderCallbacks is a list of callbacks to apply when rendering items.
+	renderCallbacks []func(idx, selectedIdx int, item Item) Item
+}
+
+// renderedItem holds the rendered content and height of an item.
+type renderedItem struct {
+	content string
+	height  int
+}
+
+// NewList creates a new lazy-loaded list.
+func NewList(items ...Item) *List {
+	l := new(List)
+	l.items = items
+	l.selectedIdx = -1
+	return l
+}
+
+// RenderCallback defines a function that can modify an item before it is
+// rendered.
+type RenderCallback func(idx, selectedIdx int, item Item) Item
+
+// RegisterRenderCallback registers a callback to be called when rendering
+// items. This can be used to modify items before they are rendered.
+func (l *List) RegisterRenderCallback(cb RenderCallback) {
+	l.renderCallbacks = append(l.renderCallbacks, cb)
+}
+
+// SetSize sets the size of the list viewport.
+func (l *List) SetSize(width, height int) {
+	l.width = width
+	l.height = height
+}
+
+// SetGap sets the gap between items.
+func (l *List) SetGap(gap int) {
+	l.gap = gap
+}
+
+// Gap returns the gap between items.
+func (l *List) Gap() int {
+	return l.gap
+}
+
+// AtBottom returns whether the list is scrolled to the bottom.
+func (l *List) AtBottom() bool {
+	if len(l.items) == 0 {
+		return true
+	}
+
+	// Calculate total height of all items from the bottom.
+	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
+		}
+	}
+
+	// All items fit in viewport - we're at bottom if at top.
+	return l.offsetIdx == 0 && l.offsetLine == 0
+}
+
+// SetReverse shows the list in reverse order.
+func (l *List) SetReverse(reverse bool) {
+	l.reverse = reverse
+}
+
+// Width returns the width of the list viewport.
+func (l *List) Width() int {
+	return l.width
+}
+
+// Height returns the height of the list viewport.
+func (l *List) Height() int {
+	return l.height
+}
+
+// Len returns the number of items in the list.
+func (l *List) Len() int {
+	return len(l.items)
+}
+
+// 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) {
+		return renderedItem{}
+	}
+
+	item := l.items[idx]
+	if len(l.renderCallbacks) > 0 {
+		for _, cb := range l.renderCallbacks {
+			if it := cb(idx, l.selectedIdx, item); it != nil {
+				item = it
+			}
+		}
+	}
+
+	rendered := item.Render(l.width)
+	rendered = strings.TrimRight(rendered, "\n")
+	height := countLines(rendered)
+	ri := renderedItem{
+		content: rendered,
+		height:  height,
+	}
+
+	return ri
+}
+
+// ScrollToIndex scrolls the list to the given item index.
+func (l *List) ScrollToIndex(index int) {
+	if index < 0 {
+		index = 0
+	}
+	if index >= len(l.items) {
+		index = len(l.items) - 1
+	}
+	l.offsetIdx = index
+	l.offsetLine = 0
+}
+
+// ScrollBy scrolls the list by the given number of lines.
+func (l *List) ScrollBy(lines int) {
+	if len(l.items) == 0 || lines == 0 {
+		return
+	}
+
+	if l.reverse {
+		lines = -lines
+	}
+
+	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
+			if l.gap > 0 {
+				totalHeight += l.gap
+			}
+
+			if l.offsetIdx >= lastItemIdx || l.offsetLine < totalHeight {
+				// Valid offset
+				break
+			}
+
+			// Move to next item
+			l.offsetLine -= totalHeight
+			l.offsetIdx++
+		}
+
+		if l.offsetLine >= item.height {
+			l.offsetLine = item.height
+		}
+	} else if lines < 0 {
+		// Scroll up
+		l.offsetLine += lines // lines is negative
+		for l.offsetLine < 0 {
+			if l.offsetIdx <= 0 {
+				// Reached top
+				l.ScrollToTop()
+				break
+			}
+
+			// Move to previous item
+			l.offsetIdx--
+			prevItem := l.getItem(l.offsetIdx)
+			totalHeight := prevItem.height
+			if l.gap > 0 {
+				totalHeight += l.gap
+			}
+			l.offsetLine += totalHeight
+		}
+	}
+}
+
+// VisibleItemIndices finds the range of items that are visible in the viewport.
+// This is used for checking if selected item is in view.
+func (l *List) VisibleItemIndices() (startIdx, endIdx int) {
+	if len(l.items) == 0 {
+		return 0, 0
+	}
+
+	startIdx = l.offsetIdx
+	currentIdx := startIdx
+	visibleHeight := -l.offsetLine
+
+	for currentIdx < len(l.items) {
+		item := l.getItem(currentIdx)
+		visibleHeight += item.height
+		if l.gap > 0 {
+			visibleHeight += l.gap
+		}
+
+		if visibleHeight >= l.height {
+			break
+		}
+		currentIdx++
+	}
+
+	endIdx = currentIdx
+	if endIdx >= len(l.items) {
+		endIdx = len(l.items) - 1
+	}
+
+	return startIdx, endIdx
+}
+
+// Render renders the list and returns the visible lines.
+func (l *List) Render() string {
+	if len(l.items) == 0 {
+		return ""
+	}
+
+	var lines []string
+	currentIdx := l.offsetIdx
+	currentOffset := l.offsetLine
+
+	linesNeeded := l.height
+
+	for linesNeeded > 0 && currentIdx < len(l.items) {
+		item := l.getItem(currentIdx)
+		itemLines := strings.Split(item.content, "\n")
+		itemHeight := len(itemLines)
+
+		if currentOffset >= 0 && currentOffset < itemHeight {
+			// Add visible content lines
+			lines = append(lines, itemLines[currentOffset:]...)
+
+			// Add gap if this is not the absolute last visual element (conceptually gaps are between items)
+			// But in the loop we can just add it and trim later
+			if l.gap > 0 {
+				for i := 0; i < l.gap; i++ {
+					lines = append(lines, "")
+				}
+			}
+		} else {
+			// offsetLine starts in the gap
+			gapOffset := currentOffset - itemHeight
+			gapRemaining := l.gap - gapOffset
+			if gapRemaining > 0 {
+				for range gapRemaining {
+					lines = append(lines, "")
+				}
+			}
+		}
+
+		linesNeeded = l.height - len(lines)
+		currentIdx++
+		currentOffset = 0 // Reset offset for subsequent items
+	}
+
+	if len(lines) > l.height {
+		lines = lines[:l.height]
+	}
+
+	if l.reverse {
+		// Reverse the lines so the list renders bottom-to-top.
+		for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 {
+			lines[i], lines[j] = lines[j], lines[i]
+		}
+	}
+
+	return strings.Join(lines, "\n")
+}
+
+// PrependItems prepends items to the list.
+func (l *List) PrependItems(items ...Item) {
+	l.items = append(items, l.items...)
+
+	// Keep view position relative to the content that was visible
+	l.offsetIdx += len(items)
+
+	// Update selection index if valid
+	if l.selectedIdx != -1 {
+		l.selectedIdx += len(items)
+	}
+}
+
+// SetItems sets the items in the list.
+func (l *List) SetItems(items ...Item) {
+	l.setItems(true, items...)
+}
+
+// setItems sets the items in the list. If evict is true, it clears the
+// rendered item cache.
+func (l *List) setItems(evict bool, items ...Item) {
+	l.items = items
+	l.selectedIdx = min(l.selectedIdx, len(l.items)-1)
+	l.offsetIdx = min(l.offsetIdx, len(l.items)-1)
+	l.offsetLine = 0
+}
+
+// AppendItems appends items to the list.
+func (l *List) AppendItems(items ...Item) {
+	l.items = append(l.items, items...)
+}
+
+// RemoveItem removes the item at the given index from the list.
+func (l *List) RemoveItem(idx int) {
+	if idx < 0 || idx >= len(l.items) {
+		return
+	}
+
+	// Remove the item
+	l.items = append(l.items[:idx], l.items[idx+1:]...)
+
+	// Adjust selection if needed
+	if l.selectedIdx == idx {
+		l.selectedIdx = -1
+	} else if l.selectedIdx > idx {
+		l.selectedIdx--
+	}
+
+	// Adjust offset if needed
+	if l.offsetIdx > idx {
+		l.offsetIdx--
+	} else if l.offsetIdx == idx && l.offsetIdx >= len(l.items) {
+		l.offsetIdx = max(0, len(l.items)-1)
+		l.offsetLine = 0
+	}
+}
+
+// Focused returns whether the list is focused.
+func (l *List) Focused() bool {
+	return l.focused
+}
+
+// Focus sets the focus state of the list.
+func (l *List) Focus() {
+	l.focused = true
+}
+
+// Blur removes the focus state from the list.
+func (l *List) Blur() {
+	l.focused = false
+}
+
+// ScrollToTop scrolls the list to the top.
+func (l *List) ScrollToTop() {
+	l.offsetIdx = 0
+	l.offsetLine = 0
+}
+
+// ScrollToBottom scrolls the list to the bottom.
+func (l *List) ScrollToBottom() {
+	if len(l.items) == 0 {
+		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()
+	}
+}
+
+// ScrollToSelected scrolls the list to the selected item.
+func (l *List) ScrollToSelected() {
+	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+		return
+	}
+
+	startIdx, endIdx := l.VisibleItemIndices()
+	if l.selectedIdx < startIdx {
+		// Selected item is above the visible range
+		l.offsetIdx = l.selectedIdx
+		l.offsetLine = 0
+	} else if l.selectedIdx > endIdx {
+		// Selected item is below the visible range
+		// Scroll so that the selected item is at the bottom
+		var totalHeight int
+		for i := l.selectedIdx; i >= 0; i-- {
+			item := l.getItem(i)
+			totalHeight += item.height
+			if l.gap > 0 && i < l.selectedIdx {
+				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()
+		}
+	}
+}
+
+// SelectedItemInView returns whether the selected item is currently in view.
+func (l *List) SelectedItemInView() bool {
+	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+		return false
+	}
+	startIdx, endIdx := l.VisibleItemIndices()
+	return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
+}
+
+// SetSelected sets the selected item index in the list.
+// It returns -1 if the index is out of bounds.
+func (l *List) SetSelected(index int) {
+	if index < 0 || index >= len(l.items) {
+		l.selectedIdx = -1
+	} else {
+		l.selectedIdx = index
+	}
+}
+
+// Selected returns the index of the currently selected item. It returns -1 if
+// no item is selected.
+func (l *List) Selected() int {
+	return l.selectedIdx
+}
+
+// IsSelectedFirst returns whether the first item is selected.
+func (l *List) IsSelectedFirst() bool {
+	return l.selectedIdx == 0
+}
+
+// IsSelectedLast returns whether the last item is selected.
+func (l *List) IsSelectedLast() bool {
+	return l.selectedIdx == len(l.items)-1
+}
+
+// SelectPrev selects the visually previous item (moves toward visual top).
+// It returns whether the selection changed.
+func (l *List) SelectPrev() bool {
+	if l.reverse {
+		// In reverse, visual up = higher index
+		if l.selectedIdx < len(l.items)-1 {
+			l.selectedIdx++
+			return true
+		}
+	} else {
+		// Normal: visual up = lower index
+		if l.selectedIdx > 0 {
+			l.selectedIdx--
+			return true
+		}
+	}
+	return false
+}
+
+// SelectNext selects the next item in the list.
+// It returns whether the selection changed.
+func (l *List) SelectNext() bool {
+	if l.reverse {
+		// In reverse, visual down = lower index
+		if l.selectedIdx > 0 {
+			l.selectedIdx--
+			return true
+		}
+	} else {
+		// Normal: visual down = higher index
+		if l.selectedIdx < len(l.items)-1 {
+			l.selectedIdx++
+			return true
+		}
+	}
+	return false
+}
+
+// SelectFirst selects the first item in the list.
+// It returns whether the selection changed.
+func (l *List) SelectFirst() bool {
+	if len(l.items) == 0 {
+		return false
+	}
+	l.selectedIdx = 0
+	return true
+}
+
+// SelectLast selects the last item in the list (highest index).
+// It returns whether the selection changed.
+func (l *List) SelectLast() bool {
+	if len(l.items) == 0 {
+		return false
+	}
+	l.selectedIdx = len(l.items) - 1
+	return true
+}
+
+// WrapToStart wraps selection to the visual start (for circular navigation).
+// In normal mode, this is index 0. In reverse mode, this is the highest index.
+func (l *List) WrapToStart() bool {
+	if len(l.items) == 0 {
+		return false
+	}
+	if l.reverse {
+		l.selectedIdx = len(l.items) - 1
+	} else {
+		l.selectedIdx = 0
+	}
+	return true
+}
+
+// WrapToEnd wraps selection to the visual end (for circular navigation).
+// In normal mode, this is the highest index. In reverse mode, this is index 0.
+func (l *List) WrapToEnd() bool {
+	if len(l.items) == 0 {
+		return false
+	}
+	if l.reverse {
+		l.selectedIdx = 0
+	} else {
+		l.selectedIdx = len(l.items) - 1
+	}
+	return true
+}
+
+// SelectedItem returns the currently selected item. It may be nil if no item
+// is selected.
+func (l *List) SelectedItem() Item {
+	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+		return nil
+	}
+	return l.items[l.selectedIdx]
+}
+
+// SelectFirstInView selects the first item currently in view.
+func (l *List) SelectFirstInView() {
+	startIdx, _ := l.VisibleItemIndices()
+	l.selectedIdx = startIdx
+}
+
+// SelectLastInView selects the last item currently in view.
+func (l *List) SelectLastInView() {
+	_, endIdx := l.VisibleItemIndices()
+	l.selectedIdx = endIdx
+}
+
+// ItemAt returns the item at the given index.
+func (l *List) ItemAt(index int) Item {
+	if index < 0 || index >= len(l.items) {
+		return nil
+	}
+	return l.items[index]
+}
+
+// ItemIndexAtPosition returns the item at the given viewport-relative y
+// coordinate. Returns the item index and the y offset within that item. It
+// returns -1, -1 if no item is found.
+func (l *List) ItemIndexAtPosition(x, y int) (itemIdx int, itemY int) {
+	return l.findItemAtY(x, y)
+}
+
+// findItemAtY finds the item at the given viewport y coordinate.
+// Returns the item index and the y offset within that item. It returns -1, -1
+// if no item is found.
+func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) {
+	if y < 0 || y >= l.height {
+		return -1, -1
+	}
+
+	// Walk through visible items to find which one contains this y
+	currentIdx := l.offsetIdx
+	currentLine := -l.offsetLine // Negative because offsetLine is how many lines are hidden
+
+	for currentIdx < len(l.items) && currentLine < l.height {
+		item := l.getItem(currentIdx)
+		itemEndLine := currentLine + item.height
+
+		// Check if y is within this item's visible range
+		if y >= currentLine && y < itemEndLine {
+			// Found the item, calculate itemY (offset within the item)
+			itemY = y - currentLine
+			return currentIdx, itemY
+		}
+
+		// Move to next item
+		currentLine = itemEndLine
+		if l.gap > 0 {
+			currentLine += l.gap
+		}
+		currentIdx++
+	}
+
+	return -1, -1
+}
+
+// countLines counts the number of lines in a string.
+func countLines(s string) int {
+	if s == "" {
+		return 1
+	}
+	return strings.Count(s, "\n") + 1
+}

+ 346 - 0
internal/ui/logo/logo.go

@@ -0,0 +1,346 @@
+// Package logo renders a Crush wordmark in a stylized way.
+package logo
+
+import (
+	"fmt"
+	"image/color"
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"github.com/MakeNowJust/heredoc"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/charmbracelet/x/exp/slice"
+)
+
+// letterform represents a letterform. It can be stretched horizontally by
+// a given amount via the boolean argument.
+type letterform func(bool) string
+
+const diag = `╱`
+
+// Opts are the options for rendering the Crush title art.
+type Opts struct {
+	FieldColor   color.Color // diagonal lines
+	TitleColorA  color.Color // left gradient ramp point
+	TitleColorB  color.Color // right gradient ramp point
+	CharmColor   color.Color // Charm™ text color
+	VersionColor color.Color // Version text color
+	Width        int         // width of the rendered logo, used for truncation
+}
+
+// Render renders the Crush logo. Set the argument to true to render the narrow
+// version, intended for use in a sidebar.
+//
+// The compact argument determines whether it renders compact for the sidebar
+// or wider for the main pane.
+func Render(version string, compact bool, o Opts) string {
+	const charm = " Charm™"
+
+	fg := func(c color.Color, s string) string {
+		return lipgloss.NewStyle().Foreground(c).Render(s)
+	}
+
+	// Title.
+	const spacing = 1
+	letterforms := []letterform{
+		letterC,
+		letterR,
+		letterU,
+		letterSStylized,
+		letterH,
+	}
+	stretchIndex := -1 // -1 means no stretching.
+	if !compact {
+		stretchIndex = cachedRandN(len(letterforms))
+	}
+
+	crush := renderWord(spacing, stretchIndex, letterforms...)
+	crushWidth := lipgloss.Width(crush)
+	b := new(strings.Builder)
+	for r := range strings.SplitSeq(crush, "\n") {
+		fmt.Fprintln(b, styles.ApplyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
+	}
+	crush = b.String()
+
+	// Charm and version.
+	metaRowGap := 1
+	maxVersionWidth := crushWidth - lipgloss.Width(charm) - metaRowGap
+	version = ansi.Truncate(version, maxVersionWidth, "…") // truncate version if too long.
+	gap := max(0, crushWidth-lipgloss.Width(charm)-lipgloss.Width(version))
+	metaRow := fg(o.CharmColor, charm) + strings.Repeat(" ", gap) + fg(o.VersionColor, version)
+
+	// Join the meta row and big Crush title.
+	crush = strings.TrimSpace(metaRow + "\n" + crush)
+
+	// Narrow version.
+	if compact {
+		field := fg(o.FieldColor, strings.Repeat(diag, crushWidth))
+		return strings.Join([]string{field, field, crush, field, ""}, "\n")
+	}
+
+	fieldHeight := lipgloss.Height(crush)
+
+	// Left field.
+	const leftWidth = 6
+	leftFieldRow := fg(o.FieldColor, strings.Repeat(diag, leftWidth))
+	leftField := new(strings.Builder)
+	for range fieldHeight {
+		fmt.Fprintln(leftField, leftFieldRow)
+	}
+
+	// Right field.
+	rightWidth := max(15, o.Width-crushWidth-leftWidth-2) // 2 for the gap.
+	const stepDownAt = 0
+	rightField := new(strings.Builder)
+	for i := range fieldHeight {
+		width := rightWidth
+		if i >= stepDownAt {
+			width = rightWidth - (i - stepDownAt)
+		}
+		fmt.Fprint(rightField, fg(o.FieldColor, strings.Repeat(diag, width)), "\n")
+	}
+
+	// Return the wide version.
+	const hGap = " "
+	logo := lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String())
+	if o.Width > 0 {
+		// Truncate the logo to the specified width.
+		lines := strings.Split(logo, "\n")
+		for i, line := range lines {
+			lines[i] = ansi.Truncate(line, o.Width, "")
+		}
+		logo = strings.Join(lines, "\n")
+	}
+	return logo
+}
+
+// SmallRender renders a smaller version of the Crush logo, suitable for
+// smaller windows or sidebar usage.
+func SmallRender(width int) string {
+	t := styles.CurrentTheme()
+	title := t.S().Base.Foreground(t.Secondary).Render("Charm™")
+	title = fmt.Sprintf("%s %s", title, styles.ApplyBoldForegroundGrad("Crush", t.Secondary, t.Primary))
+	remainingWidth := width - lipgloss.Width(title) - 1 // 1 for the space after "Crush"
+	if remainingWidth > 0 {
+		lines := strings.Repeat("╱", remainingWidth)
+		title = fmt.Sprintf("%s %s", title, t.S().Base.Foreground(t.Primary).Render(lines))
+	}
+	return title
+}
+
+// renderWord renders letterforms to fork a word. stretchIndex is the index of
+// the letter to stretch, or -1 if no letter should be stretched.
+func renderWord(spacing int, stretchIndex int, letterforms ...letterform) string {
+	if spacing < 0 {
+		spacing = 0
+	}
+
+	renderedLetterforms := make([]string, len(letterforms))
+
+	// pick one letter randomly to stretch
+	for i, letter := range letterforms {
+		renderedLetterforms[i] = letter(i == stretchIndex)
+	}
+
+	if spacing > 0 {
+		// Add spaces between the letters and render.
+		renderedLetterforms = slice.Intersperse(renderedLetterforms, strings.Repeat(" ", spacing))
+	}
+	return strings.TrimSpace(
+		lipgloss.JoinHorizontal(lipgloss.Top, renderedLetterforms...),
+	)
+}
+
+// letterC renders the letter C in a stylized way. It takes an integer that
+// determines how many cells to stretch the letter. If the stretch is less than
+// 1, it defaults to no stretching.
+func letterC(stretch bool) string {
+	// Here's what we're making:
+	//
+	// ▄▀▀▀▀
+	// █
+	//	▀▀▀▀
+
+	left := heredoc.Doc(`
+		▄
+		█
+	`)
+	right := heredoc.Doc(`
+		▀
+
+		▀
+	`)
+	return joinLetterform(
+		left,
+		stretchLetterformPart(right, letterformProps{
+			stretch:    stretch,
+			width:      4,
+			minStretch: 7,
+			maxStretch: 12,
+		}),
+	)
+}
+
+// letterH renders the letter H in a stylized way. It takes an integer that
+// determines how many cells to stretch the letter. If the stretch is less than
+// 1, it defaults to no stretching.
+func letterH(stretch bool) string {
+	// Here's what we're making:
+	//
+	// █   █
+	// █▀▀▀█
+	// ▀   ▀
+
+	side := heredoc.Doc(`
+		█
+		█
+		▀`)
+	middle := heredoc.Doc(`
+
+		▀
+	`)
+	return joinLetterform(
+		side,
+		stretchLetterformPart(middle, letterformProps{
+			stretch:    stretch,
+			width:      3,
+			minStretch: 8,
+			maxStretch: 12,
+		}),
+		side,
+	)
+}
+
+// letterR renders the letter R in a stylized way. It takes an integer that
+// determines how many cells to stretch the letter. If the stretch is less than
+// 1, it defaults to no stretching.
+func letterR(stretch bool) string {
+	// Here's what we're making:
+	//
+	// █▀▀▀▄
+	// █▀▀▀▄
+	// ▀   ▀
+
+	left := heredoc.Doc(`
+		█
+		█
+		▀
+	`)
+	center := heredoc.Doc(`
+		▀
+		▀
+	`)
+	right := heredoc.Doc(`
+		▄
+		▄
+		▀
+	`)
+	return joinLetterform(
+		left,
+		stretchLetterformPart(center, letterformProps{
+			stretch:    stretch,
+			width:      3,
+			minStretch: 7,
+			maxStretch: 12,
+		}),
+		right,
+	)
+}
+
+// letterSStylized renders the letter S in a stylized way, more so than
+// [letterS]. It takes an integer that determines how many cells to stretch the
+// letter. If the stretch is less than 1, it defaults to no stretching.
+func letterSStylized(stretch bool) string {
+	// Here's what we're making:
+	//
+	// ▄▀▀▀▀▀
+	// ▀▀▀▀▀█
+	// ▀▀▀▀▀
+
+	left := heredoc.Doc(`
+		▄
+		▀
+		▀
+	`)
+	center := heredoc.Doc(`
+		▀
+		▀
+		▀
+	`)
+	right := heredoc.Doc(`
+		▀
+		█
+	`)
+	return joinLetterform(
+		left,
+		stretchLetterformPart(center, letterformProps{
+			stretch:    stretch,
+			width:      3,
+			minStretch: 7,
+			maxStretch: 12,
+		}),
+		right,
+	)
+}
+
+// letterU renders the letter U in a stylized way. It takes an integer that
+// determines how many cells to stretch the letter. If the stretch is less than
+// 1, it defaults to no stretching.
+func letterU(stretch bool) string {
+	// Here's what we're making:
+	//
+	// █   █
+	// █   █
+	//	▀▀▀
+
+	side := heredoc.Doc(`
+		█
+		█
+	`)
+	middle := heredoc.Doc(`
+
+
+		▀
+	`)
+	return joinLetterform(
+		side,
+		stretchLetterformPart(middle, letterformProps{
+			stretch:    stretch,
+			width:      3,
+			minStretch: 7,
+			maxStretch: 12,
+		}),
+		side,
+	)
+}
+
+func joinLetterform(letters ...string) string {
+	return lipgloss.JoinHorizontal(lipgloss.Top, letters...)
+}
+
+// letterformProps defines letterform stretching properties.
+// for readability.
+type letterformProps struct {
+	width      int
+	minStretch int
+	maxStretch int
+	stretch    bool
+}
+
+// stretchLetterformPart is a helper function for letter stretching. If randomize
+// is false the minimum number will be used.
+func stretchLetterformPart(s string, p letterformProps) string {
+	if p.maxStretch < p.minStretch {
+		p.minStretch, p.maxStretch = p.maxStretch, p.minStretch
+	}
+	n := p.width
+	if p.stretch {
+		n = cachedRandN(p.maxStretch-p.minStretch) + p.minStretch //nolint:gosec
+	}
+	parts := make([]string, n)
+	for i := range parts {
+		parts[i] = s
+	}
+	return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
+}

+ 24 - 0
internal/ui/logo/rand.go

@@ -0,0 +1,24 @@
+package logo
+
+import (
+	"math/rand/v2"
+	"sync"
+)
+
+var (
+	randCaches   = make(map[int]int)
+	randCachesMu sync.Mutex
+)
+
+func cachedRandN(n int) int {
+	randCachesMu.Lock()
+	defer randCachesMu.Unlock()
+
+	if n, ok := randCaches[n]; ok {
+		return n
+	}
+
+	r := rand.IntN(n)
+	randCaches[n] = r
+	return r
+}

+ 600 - 0
internal/ui/model/chat.go

@@ -0,0 +1,600 @@
+package model
+
+import (
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/ui/anim"
+	"github.com/charmbracelet/crush/internal/ui/chat"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/list"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// Chat represents the chat UI model that handles chat interactions and
+// messages.
+type Chat struct {
+	com      *common.Common
+	list     *list.List
+	idInxMap map[string]int // Map of message IDs to their indices in the list
+
+	// Animation visibility optimization: track animations paused due to items
+	// being scrolled out of view. When items become visible again, their
+	// animations are restarted.
+	pausedAnimations map[string]struct{}
+
+	// Mouse state
+	mouseDown     bool
+	mouseDownItem int // Item index where mouse was pressed
+	mouseDownX    int // X position in item content (character offset)
+	mouseDownY    int // Y position in item (line offset)
+	mouseDragItem int // Current item index being dragged over
+	mouseDragX    int // Current X in item content
+	mouseDragY    int // Current Y in item
+}
+
+// NewChat creates a new instance of [Chat] that handles chat interactions and
+// messages.
+func NewChat(com *common.Common) *Chat {
+	c := &Chat{
+		com:              com,
+		idInxMap:         make(map[string]int),
+		pausedAnimations: make(map[string]struct{}),
+	}
+	l := list.NewList()
+	l.SetGap(1)
+	l.RegisterRenderCallback(c.applyHighlightRange)
+	l.RegisterRenderCallback(list.FocusedRenderCallback(l))
+	c.list = l
+	c.mouseDownItem = -1
+	c.mouseDragItem = -1
+	return c
+}
+
+// Height returns the height of the chat view port.
+func (m *Chat) Height() int {
+	return m.list.Height()
+}
+
+// Draw renders the chat UI component to the screen and the given area.
+func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
+	uv.NewStyledString(m.list.Render()).Draw(scr, area)
+}
+
+// SetSize sets the size of the chat view port.
+func (m *Chat) SetSize(width, height int) {
+	m.list.SetSize(width, height)
+}
+
+// Len returns the number of items in the chat list.
+func (m *Chat) Len() int {
+	return m.list.Len()
+}
+
+// SetMessages sets the chat messages to the provided list of message items.
+func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
+	m.idInxMap = make(map[string]int)
+	m.pausedAnimations = make(map[string]struct{})
+
+	items := make([]list.Item, len(msgs))
+	for i, msg := range msgs {
+		m.idInxMap[msg.ID()] = i
+		// Register nested tool IDs for tools that contain nested tools.
+		if container, ok := msg.(chat.NestedToolContainer); ok {
+			for _, nested := range container.NestedTools() {
+				m.idInxMap[nested.ID()] = i
+			}
+		}
+		items[i] = msg
+	}
+	m.list.SetItems(items...)
+	m.list.ScrollToBottom()
+}
+
+// AppendMessages appends a new message item to the chat list.
+func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
+	items := make([]list.Item, len(msgs))
+	indexOffset := m.list.Len()
+	for i, msg := range msgs {
+		m.idInxMap[msg.ID()] = indexOffset + i
+		// Register nested tool IDs for tools that contain nested tools.
+		if container, ok := msg.(chat.NestedToolContainer); ok {
+			for _, nested := range container.NestedTools() {
+				m.idInxMap[nested.ID()] = indexOffset + i
+			}
+		}
+		items[i] = msg
+	}
+	m.list.AppendItems(items...)
+}
+
+// UpdateNestedToolIDs updates the ID map for nested tools within a container.
+// Call this after modifying nested tools to ensure animations work correctly.
+func (m *Chat) UpdateNestedToolIDs(containerID string) {
+	idx, ok := m.idInxMap[containerID]
+	if !ok {
+		return
+	}
+
+	item, ok := m.list.ItemAt(idx).(chat.MessageItem)
+	if !ok {
+		return
+	}
+
+	container, ok := item.(chat.NestedToolContainer)
+	if !ok {
+		return
+	}
+
+	// Register all nested tool IDs to point to the container's index.
+	for _, nested := range container.NestedTools() {
+		m.idInxMap[nested.ID()] = idx
+	}
+}
+
+// Animate animates items in the chat list. Only propagates animation messages
+// to visible items to save CPU. When items are not visible, their animation ID
+// is tracked so it can be restarted when they become visible again.
+func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd {
+	idx, ok := m.idInxMap[msg.ID]
+	if !ok {
+		return nil
+	}
+
+	animatable, ok := m.list.ItemAt(idx).(chat.Animatable)
+	if !ok {
+		return nil
+	}
+
+	// Check if item is currently visible.
+	startIdx, endIdx := m.list.VisibleItemIndices()
+	isVisible := idx >= startIdx && idx <= endIdx
+
+	if !isVisible {
+		// Item not visible - pause animation by not propagating.
+		// Track it so we can restart when it becomes visible.
+		m.pausedAnimations[msg.ID] = struct{}{}
+		return nil
+	}
+
+	// Item is visible - remove from paused set and animate.
+	delete(m.pausedAnimations, msg.ID)
+	return animatable.Animate(msg)
+}
+
+// RestartPausedVisibleAnimations restarts animations for items that were paused
+// due to being scrolled out of view but are now visible again.
+func (m *Chat) RestartPausedVisibleAnimations() tea.Cmd {
+	if len(m.pausedAnimations) == 0 {
+		return nil
+	}
+
+	startIdx, endIdx := m.list.VisibleItemIndices()
+	var cmds []tea.Cmd
+
+	for id := range m.pausedAnimations {
+		idx, ok := m.idInxMap[id]
+		if !ok {
+			// Item no longer exists.
+			delete(m.pausedAnimations, id)
+			continue
+		}
+
+		if idx >= startIdx && idx <= endIdx {
+			// Item is now visible - restart its animation.
+			if animatable, ok := m.list.ItemAt(idx).(chat.Animatable); ok {
+				if cmd := animatable.StartAnimation(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+			}
+			delete(m.pausedAnimations, id)
+		}
+	}
+
+	if len(cmds) == 0 {
+		return nil
+	}
+	return tea.Batch(cmds...)
+}
+
+// Focus sets the focus state of the chat component.
+func (m *Chat) Focus() {
+	m.list.Focus()
+}
+
+// Blur removes the focus state from the chat component.
+func (m *Chat) Blur() {
+	m.list.Blur()
+}
+
+// ScrollToTopAndAnimate scrolls the chat view to the top and returns a command to restart
+// any paused animations that are now visible.
+func (m *Chat) ScrollToTopAndAnimate() tea.Cmd {
+	m.list.ScrollToTop()
+	return m.RestartPausedVisibleAnimations()
+}
+
+// ScrollToBottomAndAnimate scrolls the chat view to the bottom and returns a command to
+// restart any paused animations that are now visible.
+func (m *Chat) ScrollToBottomAndAnimate() tea.Cmd {
+	m.list.ScrollToBottom()
+	return m.RestartPausedVisibleAnimations()
+}
+
+// ScrollByAndAnimate scrolls the chat view by the given number of line deltas and returns
+// a command to restart any paused animations that are now visible.
+func (m *Chat) ScrollByAndAnimate(lines int) tea.Cmd {
+	m.list.ScrollBy(lines)
+	return m.RestartPausedVisibleAnimations()
+}
+
+// ScrollToSelectedAndAnimate scrolls the chat view to the selected item and returns a
+// command to restart any paused animations that are now visible.
+func (m *Chat) ScrollToSelectedAndAnimate() tea.Cmd {
+	m.list.ScrollToSelected()
+	return m.RestartPausedVisibleAnimations()
+}
+
+// SelectedItemInView returns whether the selected item is currently in view.
+func (m *Chat) SelectedItemInView() bool {
+	return m.list.SelectedItemInView()
+}
+
+func (m *Chat) isSelectable(index int) bool {
+	item := m.list.ItemAt(index)
+	if item == nil {
+		return false
+	}
+	_, ok := item.(list.Focusable)
+	return ok
+}
+
+// SetSelected sets the selected message index in the chat list.
+func (m *Chat) SetSelected(index int) {
+	m.list.SetSelected(index)
+	if index < 0 || index >= m.list.Len() {
+		return
+	}
+	for {
+		if m.isSelectable(m.list.Selected()) {
+			return
+		}
+		if m.list.SelectNext() {
+			continue
+		}
+		// If we're at the end and the last item isn't selectable, walk backwards
+		// to find the nearest selectable item.
+		for {
+			if !m.list.SelectPrev() {
+				return
+			}
+			if m.isSelectable(m.list.Selected()) {
+				return
+			}
+		}
+	}
+}
+
+// SelectPrev selects the previous message in the chat list.
+func (m *Chat) SelectPrev() {
+	for {
+		if !m.list.SelectPrev() {
+			return
+		}
+		if m.isSelectable(m.list.Selected()) {
+			return
+		}
+	}
+}
+
+// SelectNext selects the next message in the chat list.
+func (m *Chat) SelectNext() {
+	for {
+		if !m.list.SelectNext() {
+			return
+		}
+		if m.isSelectable(m.list.Selected()) {
+			return
+		}
+	}
+}
+
+// SelectFirst selects the first message in the chat list.
+func (m *Chat) SelectFirst() {
+	if !m.list.SelectFirst() {
+		return
+	}
+	if m.isSelectable(m.list.Selected()) {
+		return
+	}
+	for {
+		if !m.list.SelectNext() {
+			return
+		}
+		if m.isSelectable(m.list.Selected()) {
+			return
+		}
+	}
+}
+
+// SelectLast selects the last message in the chat list.
+func (m *Chat) SelectLast() {
+	if !m.list.SelectLast() {
+		return
+	}
+	if m.isSelectable(m.list.Selected()) {
+		return
+	}
+	for {
+		if !m.list.SelectPrev() {
+			return
+		}
+		if m.isSelectable(m.list.Selected()) {
+			return
+		}
+	}
+}
+
+// SelectFirstInView selects the first message currently in view.
+func (m *Chat) SelectFirstInView() {
+	startIdx, endIdx := m.list.VisibleItemIndices()
+	for i := startIdx; i <= endIdx; i++ {
+		if m.isSelectable(i) {
+			m.list.SetSelected(i)
+			return
+		}
+	}
+}
+
+// SelectLastInView selects the last message currently in view.
+func (m *Chat) SelectLastInView() {
+	startIdx, endIdx := m.list.VisibleItemIndices()
+	for i := endIdx; i >= startIdx; i-- {
+		if m.isSelectable(i) {
+			m.list.SetSelected(i)
+			return
+		}
+	}
+}
+
+// ClearMessages removes all messages from the chat list.
+func (m *Chat) ClearMessages() {
+	m.idInxMap = make(map[string]int)
+	m.pausedAnimations = make(map[string]struct{})
+	m.list.SetItems()
+	m.ClearMouse()
+}
+
+// RemoveMessage removes a message from the chat list by its ID.
+func (m *Chat) RemoveMessage(id string) {
+	idx, ok := m.idInxMap[id]
+	if !ok {
+		return
+	}
+
+	// Remove from list
+	m.list.RemoveItem(idx)
+
+	// Remove from index map
+	delete(m.idInxMap, id)
+
+	// Rebuild index map for all items after the removed one
+	for i := idx; i < m.list.Len(); i++ {
+		if item, ok := m.list.ItemAt(i).(chat.MessageItem); ok {
+			m.idInxMap[item.ID()] = i
+		}
+	}
+
+	// Clean up any paused animations for this message
+	delete(m.pausedAnimations, id)
+}
+
+// MessageItem returns the message item with the given ID, or nil if not found.
+func (m *Chat) MessageItem(id string) chat.MessageItem {
+	idx, ok := m.idInxMap[id]
+	if !ok {
+		return nil
+	}
+	item, ok := m.list.ItemAt(idx).(chat.MessageItem)
+	if !ok {
+		return nil
+	}
+	return item
+}
+
+// ToggleExpandedSelectedItem expands the selected message item if it is expandable.
+func (m *Chat) ToggleExpandedSelectedItem() {
+	if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok {
+		expandable.ToggleExpanded()
+	}
+}
+
+// HandleMouseDown handles mouse down events for the chat component.
+func (m *Chat) HandleMouseDown(x, y int) bool {
+	if m.list.Len() == 0 {
+		return false
+	}
+
+	itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
+	if itemIdx < 0 {
+		return false
+	}
+	if !m.isSelectable(itemIdx) {
+		return false
+	}
+
+	m.mouseDown = true
+	m.mouseDownItem = itemIdx
+	m.mouseDownX = x
+	m.mouseDownY = itemY
+	m.mouseDragItem = itemIdx
+	m.mouseDragX = x
+	m.mouseDragY = itemY
+
+	// 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)
+	}
+
+	return true
+}
+
+// HandleMouseUp handles mouse up events for the chat component.
+func (m *Chat) HandleMouseUp(x, y int) bool {
+	if !m.mouseDown {
+		return false
+	}
+
+	m.mouseDown = false
+	return true
+}
+
+// HandleMouseDrag handles mouse drag events for the chat component.
+func (m *Chat) HandleMouseDrag(x, y int) bool {
+	if !m.mouseDown {
+		return false
+	}
+
+	if m.list.Len() == 0 {
+		return false
+	}
+
+	itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
+	if itemIdx < 0 {
+		return false
+	}
+
+	m.mouseDragItem = itemIdx
+	m.mouseDragX = x
+	m.mouseDragY = itemY
+
+	return true
+}
+
+// HasHighlight returns whether there is currently highlighted content.
+func (m *Chat) HasHighlight() bool {
+	startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
+	return startItemIdx >= 0 && endItemIdx >= 0 && (startLine != endLine || startCol != endCol)
+}
+
+// HighlightContent returns the currently highlighted content based on the mouse
+// selection. It returns an empty string if no content is highlighted.
+func (m *Chat) HighlightContent() string {
+	startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
+	if startItemIdx < 0 || endItemIdx < 0 || startLine == endLine && startCol == endCol {
+		return ""
+	}
+
+	var sb strings.Builder
+	for i := startItemIdx; i <= endItemIdx; i++ {
+		item := m.list.ItemAt(i)
+		if hi, ok := item.(list.Highlightable); ok {
+			startLine, startCol, endLine, endCol := hi.Highlight()
+			listWidth := m.list.Width()
+			var rendered string
+			if rr, ok := item.(list.RawRenderable); ok {
+				rendered = rr.RawRender(listWidth)
+			} else {
+				rendered = item.Render(listWidth)
+			}
+			sb.WriteString(list.HighlightContent(
+				rendered,
+				uv.Rect(0, 0, listWidth, lipgloss.Height(rendered)),
+				startLine,
+				startCol,
+				endLine,
+				endCol,
+			))
+			sb.WriteString(strings.Repeat("\n", m.list.Gap()))
+		}
+	}
+
+	return strings.TrimSpace(sb.String())
+}
+
+// ClearMouse clears the current mouse interaction state.
+func (m *Chat) ClearMouse() {
+	m.mouseDown = false
+	m.mouseDownItem = -1
+	m.mouseDragItem = -1
+}
+
+// applyHighlightRange applies the current highlight range to the chat items.
+func (m *Chat) applyHighlightRange(idx, selectedIdx int, item list.Item) list.Item {
+	if hi, ok := item.(list.Highlightable); ok {
+		// Apply highlight
+		startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
+		sLine, sCol, eLine, eCol := -1, -1, -1, -1
+		if idx >= startItemIdx && idx <= endItemIdx {
+			if idx == startItemIdx && idx == endItemIdx {
+				// Single item selection
+				sLine = startLine
+				sCol = startCol
+				eLine = endLine
+				eCol = endCol
+			} else if idx == startItemIdx {
+				// First item - from start position to end of item
+				sLine = startLine
+				sCol = startCol
+				eLine = -1
+				eCol = -1
+			} else if idx == endItemIdx {
+				// Last item - from start of item to end position
+				sLine = 0
+				sCol = 0
+				eLine = endLine
+				eCol = endCol
+			} else {
+				// Middle item - fully highlighted
+				sLine = 0
+				sCol = 0
+				eLine = -1
+				eCol = -1
+			}
+		}
+
+		hi.SetHighlight(sLine, sCol, eLine, eCol)
+		return hi.(list.Item)
+	}
+
+	return item
+}
+
+// getHighlightRange returns the current highlight range.
+func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) {
+	if m.mouseDownItem < 0 {
+		return -1, -1, -1, -1, -1, -1
+	}
+
+	downItemIdx := m.mouseDownItem
+	dragItemIdx := m.mouseDragItem
+
+	// Determine selection direction
+	draggingDown := dragItemIdx > downItemIdx ||
+		(dragItemIdx == downItemIdx && m.mouseDragY > m.mouseDownY) ||
+		(dragItemIdx == downItemIdx && m.mouseDragY == m.mouseDownY && m.mouseDragX >= m.mouseDownX)
+
+	if draggingDown {
+		// Normal forward selection
+		startItemIdx = downItemIdx
+		startLine = m.mouseDownY
+		startCol = m.mouseDownX
+		endItemIdx = dragItemIdx
+		endLine = m.mouseDragY
+		endCol = m.mouseDragX
+	} else {
+		// Backward selection (dragging up)
+		startItemIdx = dragItemIdx
+		startLine = m.mouseDragY
+		startCol = m.mouseDragX
+		endItemIdx = downItemIdx
+		endLine = m.mouseDownY
+		endCol = m.mouseDownX
+	}
+
+	return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
+}

+ 112 - 0
internal/ui/model/header.go

@@ -0,0 +1,112 @@
+package model
+
+import (
+	"fmt"
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+)
+
+const (
+	headerDiag     = "╱"
+	minHeaderDiags = 3
+	leftPadding    = 1
+	rightPadding   = 1
+)
+
+// renderCompactHeader renders the compact header for the given session.
+func renderCompactHeader(
+	com *common.Common,
+	session *session.Session,
+	lspClients *csync.Map[string, *lsp.Client],
+	detailsOpen bool,
+	width int,
+) string {
+	if session == nil || session.ID == "" {
+		return ""
+	}
+
+	t := com.Styles
+
+	var b strings.Builder
+
+	b.WriteString(t.Header.Charm.Render("Charm™"))
+	b.WriteString(" ")
+	b.WriteString(styles.ApplyBoldForegroundGrad(t, "CRUSH", t.Secondary, t.Primary))
+	b.WriteString(" ")
+
+	availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags
+	details := renderHeaderDetails(com, session, lspClients, detailsOpen, availDetailWidth)
+
+	remainingWidth := width -
+		lipgloss.Width(b.String()) -
+		lipgloss.Width(details) -
+		leftPadding -
+		rightPadding
+
+	if remainingWidth > 0 {
+		b.WriteString(t.Header.Diagonals.Render(
+			strings.Repeat(headerDiag, max(minHeaderDiags, remainingWidth)),
+		))
+		b.WriteString(" ")
+	}
+
+	b.WriteString(details)
+
+	return t.Base.Padding(0, rightPadding, 0, leftPadding).Render(b.String())
+}
+
+// renderHeaderDetails renders the details section of the header.
+func renderHeaderDetails(
+	com *common.Common,
+	session *session.Session,
+	lspClients *csync.Map[string, *lsp.Client],
+	detailsOpen bool,
+	availWidth int,
+) string {
+	t := com.Styles
+
+	var parts []string
+
+	errorCount := 0
+	for l := range lspClients.Seq() {
+		errorCount += l.GetDiagnosticCounts().Error
+	}
+
+	if errorCount > 0 {
+		parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount)))
+	}
+
+	agentCfg := config.Get().Agents[config.AgentCoder]
+	model := config.Get().GetModelByType(agentCfg.Model)
+	percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
+	formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage)))
+	parts = append(parts, formattedPercentage)
+
+	const keystroke = "ctrl+d"
+	if detailsOpen {
+		parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" close"))
+	} else {
+		parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" open "))
+	}
+
+	dot := t.Header.Separator.Render(" • ")
+	metadata := strings.Join(parts, dot)
+	metadata = dot + metadata
+
+	const dirTrimLimit = 4
+	cfg := com.Config()
+	cwd := fsext.DirTrim(fsext.PrettyPath(cfg.WorkingDir()), dirTrimLimit)
+	cwd = ansi.Truncate(cwd, max(0, availWidth-lipgloss.Width(metadata)), "…")
+	cwd = t.Header.WorkingDir.Render(cwd)
+
+	return cwd + metadata
+}

+ 246 - 0
internal/ui/model/keys.go

@@ -0,0 +1,246 @@
+package model
+
+import "charm.land/bubbles/v2/key"
+
+type KeyMap struct {
+	Editor struct {
+		AddFile     key.Binding
+		SendMessage key.Binding
+		OpenEditor  key.Binding
+		Newline     key.Binding
+		AddImage    key.Binding
+		MentionFile key.Binding
+
+		// Attachments key maps
+		AttachmentDeleteMode key.Binding
+		Escape               key.Binding
+		DeleteAllAttachments key.Binding
+	}
+
+	Chat struct {
+		NewSession     key.Binding
+		AddAttachment  key.Binding
+		Cancel         key.Binding
+		Tab            key.Binding
+		Details        key.Binding
+		TogglePills    key.Binding
+		PillLeft       key.Binding
+		PillRight      key.Binding
+		Down           key.Binding
+		Up             key.Binding
+		UpDown         key.Binding
+		DownOneItem    key.Binding
+		UpOneItem      key.Binding
+		UpDownOneItem  key.Binding
+		PageDown       key.Binding
+		PageUp         key.Binding
+		HalfPageDown   key.Binding
+		HalfPageUp     key.Binding
+		Home           key.Binding
+		End            key.Binding
+		Copy           key.Binding
+		ClearHighlight key.Binding
+		Expand         key.Binding
+	}
+
+	Initialize struct {
+		Yes,
+		No,
+		Enter,
+		Switch key.Binding
+	}
+
+	// Global key maps
+	Quit     key.Binding
+	Help     key.Binding
+	Commands key.Binding
+	Models   key.Binding
+	Suspend  key.Binding
+	Sessions key.Binding
+	Tab      key.Binding
+}
+
+func DefaultKeyMap() KeyMap {
+	km := KeyMap{
+		Quit: key.NewBinding(
+			key.WithKeys("ctrl+c"),
+			key.WithHelp("ctrl+c", "quit"),
+		),
+		Help: key.NewBinding(
+			key.WithKeys("ctrl+g"),
+			key.WithHelp("ctrl+g", "more"),
+		),
+		Commands: key.NewBinding(
+			key.WithKeys("ctrl+p"),
+			key.WithHelp("ctrl+p", "commands"),
+		),
+		Models: key.NewBinding(
+			key.WithKeys("ctrl+m", "ctrl+l"),
+			key.WithHelp("ctrl+l", "models"),
+		),
+		Suspend: key.NewBinding(
+			key.WithKeys("ctrl+z"),
+			key.WithHelp("ctrl+z", "suspend"),
+		),
+		Sessions: key.NewBinding(
+			key.WithKeys("ctrl+s"),
+			key.WithHelp("ctrl+s", "sessions"),
+		),
+		Tab: key.NewBinding(
+			key.WithKeys("tab"),
+			key.WithHelp("tab", "change focus"),
+		),
+	}
+
+	km.Editor.AddFile = key.NewBinding(
+		key.WithKeys("/"),
+		key.WithHelp("/", "add file"),
+	)
+	km.Editor.SendMessage = key.NewBinding(
+		key.WithKeys("enter"),
+		key.WithHelp("enter", "send"),
+	)
+	km.Editor.OpenEditor = key.NewBinding(
+		key.WithKeys("ctrl+o"),
+		key.WithHelp("ctrl+o", "open editor"),
+	)
+	km.Editor.Newline = key.NewBinding(
+		key.WithKeys("shift+enter", "ctrl+j"),
+		// "ctrl+j" is a common keybinding for newline in many editors. If
+		// the terminal supports "shift+enter", we substitute the help tex
+		// to reflect that.
+		key.WithHelp("ctrl+j", "newline"),
+	)
+	km.Editor.AddImage = key.NewBinding(
+		key.WithKeys("ctrl+f"),
+		key.WithHelp("ctrl+f", "add image"),
+	)
+	km.Editor.MentionFile = key.NewBinding(
+		key.WithKeys("@"),
+		key.WithHelp("@", "mention file"),
+	)
+	km.Editor.AttachmentDeleteMode = key.NewBinding(
+		key.WithKeys("ctrl+r"),
+		key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
+	)
+	km.Editor.Escape = key.NewBinding(
+		key.WithKeys("esc", "alt+esc"),
+		key.WithHelp("esc", "cancel delete mode"),
+	)
+	km.Editor.DeleteAllAttachments = key.NewBinding(
+		key.WithKeys("r"),
+		key.WithHelp("ctrl+r+r", "delete all attachments"),
+	)
+
+	km.Chat.NewSession = key.NewBinding(
+		key.WithKeys("ctrl+n"),
+		key.WithHelp("ctrl+n", "new session"),
+	)
+	km.Chat.AddAttachment = key.NewBinding(
+		key.WithKeys("ctrl+f"),
+		key.WithHelp("ctrl+f", "add attachment"),
+	)
+	km.Chat.Cancel = key.NewBinding(
+		key.WithKeys("esc", "alt+esc"),
+		key.WithHelp("esc", "cancel"),
+	)
+	km.Chat.Tab = key.NewBinding(
+		key.WithKeys("tab"),
+		key.WithHelp("tab", "change focus"),
+	)
+	km.Chat.Details = key.NewBinding(
+		key.WithKeys("ctrl+d"),
+		key.WithHelp("ctrl+d", "toggle details"),
+	)
+	km.Chat.TogglePills = key.NewBinding(
+		key.WithKeys("ctrl+space"),
+		key.WithHelp("ctrl+space", "toggle tasks"),
+	)
+	km.Chat.PillLeft = key.NewBinding(
+		key.WithKeys("left"),
+		key.WithHelp("←/→", "switch section"),
+	)
+	km.Chat.PillRight = key.NewBinding(
+		key.WithKeys("right"),
+		key.WithHelp("←/→", "switch section"),
+	)
+
+	km.Chat.Down = key.NewBinding(
+		key.WithKeys("down", "ctrl+j", "j"),
+		key.WithHelp("↓", "down"),
+	)
+	km.Chat.Up = key.NewBinding(
+		key.WithKeys("up", "ctrl+k", "k"),
+		key.WithHelp("↑", "up"),
+	)
+	km.Chat.UpDown = key.NewBinding(
+		key.WithKeys("up", "down"),
+		key.WithHelp("↑↓", "scroll"),
+	)
+	km.Chat.UpOneItem = key.NewBinding(
+		key.WithKeys("shift+up", "K"),
+		key.WithHelp("shift+↑", "up one item"),
+	)
+	km.Chat.DownOneItem = key.NewBinding(
+		key.WithKeys("shift+down", "J"),
+		key.WithHelp("shift+↓", "down one item"),
+	)
+	km.Chat.UpDownOneItem = key.NewBinding(
+		key.WithKeys("shift+up", "shift+down"),
+		key.WithHelp("shift+↑↓", "scroll one item"),
+	)
+	km.Chat.HalfPageDown = key.NewBinding(
+		key.WithKeys("d"),
+		key.WithHelp("d", "half page down"),
+	)
+	km.Chat.PageDown = key.NewBinding(
+		key.WithKeys("pgdown", " ", "f"),
+		key.WithHelp("f/pgdn", "page down"),
+	)
+	km.Chat.PageUp = key.NewBinding(
+		key.WithKeys("pgup", "b"),
+		key.WithHelp("b/pgup", "page up"),
+	)
+	km.Chat.HalfPageUp = key.NewBinding(
+		key.WithKeys("u"),
+		key.WithHelp("u", "half page up"),
+	)
+	km.Chat.Home = key.NewBinding(
+		key.WithKeys("g", "home"),
+		key.WithHelp("g", "home"),
+	)
+	km.Chat.End = key.NewBinding(
+		key.WithKeys("G", "end"),
+		key.WithHelp("G", "end"),
+	)
+	km.Chat.Copy = key.NewBinding(
+		key.WithKeys("c", "y", "C", "Y"),
+		key.WithHelp("c/y", "copy"),
+	)
+	km.Chat.ClearHighlight = key.NewBinding(
+		key.WithKeys("esc", "alt+esc"),
+		key.WithHelp("esc", "clear selection"),
+	)
+	km.Chat.Expand = key.NewBinding(
+		key.WithKeys("space"),
+		key.WithHelp("space", "expand/collapse"),
+	)
+	km.Initialize.Yes = key.NewBinding(
+		key.WithKeys("y", "Y"),
+		key.WithHelp("y", "yes"),
+	)
+	km.Initialize.No = key.NewBinding(
+		key.WithKeys("n", "N", "esc", "alt+esc"),
+		key.WithHelp("n", "no"),
+	)
+	km.Initialize.Switch = key.NewBinding(
+		key.WithKeys("left", "right", "tab"),
+		key.WithHelp("tab", "switch"),
+	)
+	km.Initialize.Enter = key.NewBinding(
+		key.WithKeys("enter"),
+		key.WithHelp("enter", "select"),
+	)
+
+	return km
+}

+ 50 - 0
internal/ui/model/landing.go

@@ -0,0 +1,50 @@
+package model
+
+import (
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/agent"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+// selectedLargeModel returns the currently selected large language model from
+// the agent coordinator, if one exists.
+func (m *UI) selectedLargeModel() *agent.Model {
+	if m.com.App.AgentCoordinator != nil {
+		model := m.com.App.AgentCoordinator.Model()
+		return &model
+	}
+	return nil
+}
+
+// landingView renders the landing page view showing the current working
+// directory, model information, and LSP/MCP status in a two-column layout.
+func (m *UI) landingView() string {
+	t := m.com.Styles
+	width := m.layout.main.Dx()
+	cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width)
+
+	parts := []string{
+		cwd,
+	}
+
+	parts = append(parts, "", m.modelInfo(width))
+	infoSection := lipgloss.JoinVertical(lipgloss.Left, parts...)
+
+	_, remainingHeightArea := uv.SplitVertical(m.layout.main, uv.Fixed(lipgloss.Height(infoSection)+1))
+
+	mcpLspSectionWidth := min(30, (width-1)/2)
+
+	lspSection := m.lspInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false)
+	mcpSection := m.mcpInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false)
+
+	content := lipgloss.JoinHorizontal(lipgloss.Left, lspSection, " ", mcpSection)
+
+	return lipgloss.NewStyle().
+		Width(width).
+		Height(m.layout.main.Dy() - 1).
+		PaddingTop(1).
+		Render(
+			lipgloss.JoinVertical(lipgloss.Left, infoSection, "", content),
+		)
+}

+ 125 - 0
internal/ui/model/lsp.go

@@ -0,0 +1,125 @@
+package model
+
+import (
+	"fmt"
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
+)
+
+// LSPInfo wraps LSP client information with diagnostic counts by severity.
+type LSPInfo struct {
+	app.LSPClientInfo
+	Diagnostics map[protocol.DiagnosticSeverity]int
+}
+
+// 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
+		}
+
+		client, ok := m.com.App.LSPClients.Get(state.Name)
+		if !ok {
+			continue
+		}
+		counts := client.GetDiagnosticCounts()
+		lspErrs := map[protocol.DiagnosticSeverity]int{
+			protocol.SeverityError:       counts.Error,
+			protocol.SeverityWarning:     counts.Warning,
+			protocol.SeverityHint:        counts.Hint,
+			protocol.SeverityInformation: counts.Information,
+		}
+
+		lsps = append(lsps, LSPInfo{LSPClientInfo: state, Diagnostics: lspErrs})
+	}
+
+	title := t.Subtle.Render("LSPs")
+	if isSection {
+		title = common.Section(t, title, width)
+	}
+	list := t.Subtle.Render("None")
+	if len(lsps) > 0 {
+		list = lspList(t, lsps, width, maxItems)
+	}
+
+	return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
+}
+
+// lspDiagnostics formats diagnostic counts with appropriate icons and colors.
+func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverity]int) string {
+	errs := []string{}
+	if diagnostics[protocol.SeverityError] > 0 {
+		errs = append(errs, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s %d", styles.ErrorIcon, diagnostics[protocol.SeverityError])))
+	}
+	if diagnostics[protocol.SeverityWarning] > 0 {
+		errs = append(errs, t.LSP.WarningDiagnostic.Render(fmt.Sprintf("%s %d", styles.WarningIcon, diagnostics[protocol.SeverityWarning])))
+	}
+	if diagnostics[protocol.SeverityHint] > 0 {
+		errs = append(errs, t.LSP.HintDiagnostic.Render(fmt.Sprintf("%s %d", styles.HintIcon, diagnostics[protocol.SeverityHint])))
+	}
+	if diagnostics[protocol.SeverityInformation] > 0 {
+		errs = append(errs, t.LSP.InfoDiagnostic.Render(fmt.Sprintf("%s %d", styles.InfoIcon, diagnostics[protocol.SeverityInformation])))
+	}
+	return strings.Join(errs, " ")
+}
+
+// lspList renders a list of LSP clients with their status and diagnostics,
+// truncating to maxItems if needed.
+func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string {
+	if maxItems <= 0 {
+		return ""
+	}
+	var renderedLsps []string
+	for _, l := range lsps {
+		var icon string
+		title := l.Name
+		var description string
+		var diagnostics string
+		switch l.State {
+		case lsp.StateStarting:
+			icon = t.ItemBusyIcon.String()
+			description = t.Subtle.Render("starting...")
+		case lsp.StateReady:
+			icon = t.ItemOnlineIcon.String()
+			diagnostics = lspDiagnostics(t, l.Diagnostics)
+		case lsp.StateError:
+			icon = t.ItemErrorIcon.String()
+			description = t.Subtle.Render("error")
+			if l.Error != nil {
+				description = t.Subtle.Render(fmt.Sprintf("error: %s", l.Error.Error()))
+			}
+		case lsp.StateDisabled:
+			icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String()
+			description = t.Subtle.Render("inactive")
+		default:
+			icon = t.ItemOfflineIcon.String()
+		}
+		renderedLsps = append(renderedLsps, common.Status(t, common.StatusOpts{
+			Icon:         icon,
+			Title:        title,
+			Description:  description,
+			ExtraContent: diagnostics,
+		}, width))
+	}
+
+	if len(renderedLsps) > maxItems {
+		visibleItems := renderedLsps[:maxItems-1]
+		remaining := len(renderedLsps) - maxItems
+		visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining)))
+		return lipgloss.JoinVertical(lipgloss.Left, visibleItems...)
+	}
+	return lipgloss.JoinVertical(lipgloss.Left, renderedLsps...)
+}

+ 98 - 0
internal/ui/model/mcp.go

@@ -0,0 +1,98 @@
+package model
+
+import (
+	"fmt"
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// mcpInfo renders the MCP status section showing active MCP clients and their
+// tool/prompt counts.
+func (m *UI) mcpInfo(width, maxItems int, isSection bool) string {
+	var mcps []mcp.ClientInfo
+	t := m.com.Styles
+
+	for _, mcp := range m.com.Config().MCP.Sorted() {
+		if state, ok := m.mcpStates[mcp.Name]; ok {
+			mcps = append(mcps, state)
+		}
+	}
+
+	title := t.Subtle.Render("MCPs")
+	if isSection {
+		title = common.Section(t, title, width)
+	}
+	list := t.Subtle.Render("None")
+	if len(mcps) > 0 {
+		list = mcpList(t, mcps, width, maxItems)
+	}
+
+	return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
+}
+
+// mcpCounts formats tool and prompt counts for display.
+func mcpCounts(t *styles.Styles, counts mcp.Counts) string {
+	parts := []string{}
+	if counts.Tools > 0 {
+		parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d tools", counts.Tools)))
+	}
+	if counts.Prompts > 0 {
+		parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d prompts", counts.Prompts)))
+	}
+	return strings.Join(parts, " ")
+}
+
+// mcpList renders a list of MCP clients with their status and counts,
+// truncating to maxItems if needed.
+func mcpList(t *styles.Styles, mcps []mcp.ClientInfo, width, maxItems int) string {
+	if maxItems <= 0 {
+		return ""
+	}
+	var renderedMcps []string
+
+	for _, m := range mcps {
+		var icon string
+		title := m.Name
+		var description string
+		var extraContent string
+
+		switch m.State {
+		case mcp.StateStarting:
+			icon = t.ItemBusyIcon.String()
+			description = t.Subtle.Render("starting...")
+		case mcp.StateConnected:
+			icon = t.ItemOnlineIcon.String()
+			extraContent = mcpCounts(t, m.Counts)
+		case mcp.StateError:
+			icon = t.ItemErrorIcon.String()
+			description = t.Subtle.Render("error")
+			if m.Error != nil {
+				description = t.Subtle.Render(fmt.Sprintf("error: %s", m.Error.Error()))
+			}
+		case mcp.StateDisabled:
+			icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String()
+			description = t.Subtle.Render("disabled")
+		default:
+			icon = t.ItemOfflineIcon.String()
+		}
+
+		renderedMcps = append(renderedMcps, common.Status(t, common.StatusOpts{
+			Icon:         icon,
+			Title:        title,
+			Description:  description,
+			ExtraContent: extraContent,
+		}, width))
+	}
+
+	if len(renderedMcps) > maxItems {
+		visibleItems := renderedMcps[:maxItems-1]
+		remaining := len(renderedMcps) - maxItems
+		visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining)))
+		return lipgloss.JoinVertical(lipgloss.Left, visibleItems...)
+	}
+	return lipgloss.JoinVertical(lipgloss.Left, renderedMcps...)
+}

+ 114 - 0
internal/ui/model/onboarding.go

@@ -0,0 +1,114 @@
+package model
+
+import (
+	"fmt"
+	"log/slog"
+	"strings"
+
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+
+	"github.com/charmbracelet/crush/internal/agent"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/home"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/uiutil"
+)
+
+// markProjectInitialized marks the current project as initialized in the config.
+func (m *UI) markProjectInitialized() tea.Msg {
+	// TODO: handle error so we show it in the tui footer
+	err := config.MarkProjectInitialized()
+	if err != nil {
+		slog.Error(err.Error())
+	}
+	return nil
+}
+
+// updateInitializeView handles keyboard input for the project initialization prompt.
+func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
+	switch {
+	case key.Matches(msg, m.keyMap.Initialize.Enter):
+		if m.onboarding.yesInitializeSelected {
+			cmds = append(cmds, m.initializeProject())
+		} else {
+			cmds = append(cmds, m.skipInitializeProject())
+		}
+	case key.Matches(msg, m.keyMap.Initialize.Switch):
+		m.onboarding.yesInitializeSelected = !m.onboarding.yesInitializeSelected
+	case key.Matches(msg, m.keyMap.Initialize.Yes):
+		cmds = append(cmds, m.initializeProject())
+	case key.Matches(msg, m.keyMap.Initialize.No):
+		cmds = append(cmds, m.skipInitializeProject())
+	}
+	return cmds
+}
+
+// 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
+
+	initialize := func() tea.Msg {
+		initPrompt, err := agent.InitializePrompt(*cfg)
+		if err != nil {
+			return uiutil.InfoMsg{Type: uiutil.InfoTypeError, Msg: err.Error()}
+		}
+		return sendMessageMsg{Content: initPrompt}
+	}
+	// Mark the project as initialized
+	cmds = append(cmds, initialize, m.markProjectInitialized)
+
+	return tea.Sequence(cmds...)
+}
+
+// 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
+	// mark the project as initialized
+	return m.markProjectInitialized
+}
+
+// initializeView renders the project initialization prompt with Yes/No buttons.
+func (m *UI) initializeView() string {
+	cfg := m.com.Config()
+	s := m.com.Styles.Initialize
+	cwd := home.Short(cfg.WorkingDir())
+	initFile := cfg.Options.InitializeAs
+
+	header := s.Header.Render("Would you like to initialize this project?")
+	path := s.Accent.PaddingLeft(2).Render(cwd)
+	desc := s.Content.Render(fmt.Sprintf("When I initialize your codebase I examine the project and put the result into an %s file which serves as general context.", initFile))
+	hint := s.Content.Render("You can also initialize anytime via ") + s.Accent.Render("ctrl+p") + s.Content.Render(".")
+	prompt := s.Content.Render("Would you like to initialize now?")
+
+	buttons := common.ButtonGroup(m.com.Styles, []common.ButtonOpts{
+		{Text: "Yep!", Selected: m.onboarding.yesInitializeSelected},
+		{Text: "Nope", Selected: !m.onboarding.yesInitializeSelected},
+	}, " ")
+
+	// max width 60 so the text is compact
+	width := min(m.layout.main.Dx(), 60)
+
+	return lipgloss.NewStyle().
+		Width(width).
+		Height(m.layout.main.Dy()).
+		PaddingBottom(1).
+		AlignVertical(lipgloss.Bottom).
+		Render(strings.Join(
+			[]string{
+				header,
+				path,
+				desc,
+				hint,
+				prompt,
+				buttons,
+			},
+			"\n\n",
+		))
+}

+ 283 - 0
internal/ui/model/pills.go

@@ -0,0 +1,283 @@
+package model
+
+import (
+	"fmt"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/ui/chat"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// pillStyle returns the appropriate style for a pill based on focus state.
+func pillStyle(focused, panelFocused bool, t *styles.Styles) lipgloss.Style {
+	if !panelFocused || focused {
+		return t.Pills.Focused
+	}
+	return t.Pills.Blurred
+}
+
+const (
+	// pillHeightWithBorder is the height of a pill including its border.
+	pillHeightWithBorder = 3
+	// maxTaskDisplayLength is the maximum length of a task name in the pill.
+	maxTaskDisplayLength = 40
+	// maxQueueDisplayLength is the maximum length of a queue item in the list.
+	maxQueueDisplayLength = 60
+)
+
+// pillSection represents which section of the pills panel is focused.
+type pillSection int
+
+const (
+	pillSectionTodos pillSection = iota
+	pillSectionQueue
+)
+
+// hasIncompleteTodos returns true if there are any non-completed todos.
+func hasIncompleteTodos(todos []session.Todo) bool {
+	for _, todo := range todos {
+		if todo.Status != session.TodoStatusCompleted {
+			return true
+		}
+	}
+	return false
+}
+
+// hasInProgressTodo returns true if there is at least one in-progress todo.
+func hasInProgressTodo(todos []session.Todo) bool {
+	for _, todo := range todos {
+		if todo.Status == session.TodoStatusInProgress {
+			return true
+		}
+	}
+	return false
+}
+
+// queuePill renders the queue count pill with gradient triangles.
+func queuePill(queue int, focused, panelFocused bool, t *styles.Styles) string {
+	if queue <= 0 {
+		return ""
+	}
+	triangles := styles.ForegroundGrad(t, "▶▶▶▶▶▶▶▶▶", false, t.RedDark, t.Secondary)
+	if queue < len(triangles) {
+		triangles = triangles[:queue]
+	}
+
+	content := fmt.Sprintf("%s %d Queued", strings.Join(triangles, ""), queue)
+	return pillStyle(focused, panelFocused, t).Render(content)
+}
+
+// todoPill renders the todo progress pill with optional spinner and task name.
+func todoPill(todos []session.Todo, spinnerView string, focused, panelFocused bool, t *styles.Styles) string {
+	if !hasIncompleteTodos(todos) {
+		return ""
+	}
+
+	completed := 0
+	var currentTodo *session.Todo
+	for i := range todos {
+		switch todos[i].Status {
+		case session.TodoStatusCompleted:
+			completed++
+		case session.TodoStatusInProgress:
+			if currentTodo == nil {
+				currentTodo = &todos[i]
+			}
+		}
+	}
+
+	total := len(todos)
+
+	label := t.Base.Render("To-Do")
+	progress := t.Muted.Render(fmt.Sprintf("%d/%d", completed, total))
+
+	var content string
+	if panelFocused {
+		content = fmt.Sprintf("%s %s", label, progress)
+	} else if currentTodo != nil {
+		taskText := currentTodo.Content
+		if currentTodo.ActiveForm != "" {
+			taskText = currentTodo.ActiveForm
+		}
+		if len(taskText) > maxTaskDisplayLength {
+			taskText = taskText[:maxTaskDisplayLength-1] + "…"
+		}
+		task := t.Subtle.Render(taskText)
+		content = fmt.Sprintf("%s %s %s  %s", spinnerView, label, progress, task)
+	} else {
+		content = fmt.Sprintf("%s %s", label, progress)
+	}
+
+	return pillStyle(focused, panelFocused, t).Render(content)
+}
+
+// todoList renders the expanded todo list.
+func todoList(sessionTodos []session.Todo, spinnerView string, t *styles.Styles, width int) string {
+	return chat.FormatTodosList(t, sessionTodos, spinnerView, width)
+}
+
+// queueList renders the expanded queue items list.
+func queueList(queueItems []string, t *styles.Styles) string {
+	if len(queueItems) == 0 {
+		return ""
+	}
+
+	var lines []string
+	for _, item := range queueItems {
+		text := item
+		if len(text) > maxQueueDisplayLength {
+			text = text[:maxQueueDisplayLength-1] + "…"
+		}
+		prefix := t.Pills.QueueItemPrefix.Render() + " "
+		lines = append(lines, prefix+t.Muted.Render(text))
+	}
+
+	return strings.Join(lines, "\n")
+}
+
+// togglePillsExpanded toggles the pills panel expansion state.
+func (m *UI) togglePillsExpanded() tea.Cmd {
+	if !m.hasSession() {
+		return nil
+	}
+	if m.layout.pills.Dy() > 0 {
+		if cmd := m.chat.ScrollByAndAnimate(0); cmd != nil {
+			return cmd
+		}
+	}
+	hasPills := hasIncompleteTodos(m.session.Todos) || m.promptQueue > 0
+	if !hasPills {
+		return nil
+	}
+	m.pillsExpanded = !m.pillsExpanded
+	if m.pillsExpanded {
+		if hasIncompleteTodos(m.session.Todos) {
+			m.focusedPillSection = pillSectionTodos
+		} else {
+			m.focusedPillSection = pillSectionQueue
+		}
+	}
+	m.updateLayoutAndSize()
+	return nil
+}
+
+// switchPillSection changes focus between todo and queue sections.
+func (m *UI) switchPillSection(dir int) tea.Cmd {
+	if !m.pillsExpanded || !m.hasSession() {
+		return nil
+	}
+	hasIncompleteTodos := hasIncompleteTodos(m.session.Todos)
+	hasQueue := m.promptQueue > 0
+
+	if dir < 0 && m.focusedPillSection == pillSectionQueue && hasIncompleteTodos {
+		m.focusedPillSection = pillSectionTodos
+		m.updateLayoutAndSize()
+		return nil
+	}
+	if dir > 0 && m.focusedPillSection == pillSectionTodos && hasQueue {
+		m.focusedPillSection = pillSectionQueue
+		m.updateLayoutAndSize()
+		return nil
+	}
+	return nil
+}
+
+// pillsAreaHeight calculates the total height needed for the pills area.
+func (m *UI) pillsAreaHeight() int {
+	if !m.hasSession() {
+		return 0
+	}
+	hasIncomplete := hasIncompleteTodos(m.session.Todos)
+	hasQueue := m.promptQueue > 0
+	hasPills := hasIncomplete || hasQueue
+	if !hasPills {
+		return 0
+	}
+
+	pillsAreaHeight := pillHeightWithBorder
+	if m.pillsExpanded {
+		if m.focusedPillSection == pillSectionTodos && hasIncomplete {
+			pillsAreaHeight += len(m.session.Todos)
+		} else if m.focusedPillSection == pillSectionQueue && hasQueue {
+			pillsAreaHeight += m.promptQueue
+		}
+	}
+	return pillsAreaHeight
+}
+
+// renderPills renders the pills panel and stores it in m.pillsView.
+func (m *UI) renderPills() {
+	m.pillsView = ""
+	if !m.hasSession() {
+		return
+	}
+
+	width := m.layout.pills.Dx()
+	if width <= 0 {
+		return
+	}
+
+	paddingLeft := 3
+	contentWidth := max(width-paddingLeft, 0)
+
+	hasIncomplete := hasIncompleteTodos(m.session.Todos)
+	hasQueue := m.promptQueue > 0
+
+	if !hasIncomplete && !hasQueue {
+		return
+	}
+
+	t := m.com.Styles
+	todosFocused := m.pillsExpanded && m.focusedPillSection == pillSectionTodos
+	queueFocused := m.pillsExpanded && m.focusedPillSection == pillSectionQueue
+
+	inProgressIcon := t.Tool.TodoInProgressIcon.Render(styles.SpinnerIcon)
+	if m.todoIsSpinning {
+		inProgressIcon = m.todoSpinner.View()
+	}
+
+	var pills []string
+	if hasIncomplete {
+		pills = append(pills, todoPill(m.session.Todos, inProgressIcon, todosFocused, m.pillsExpanded, t))
+	}
+	if hasQueue {
+		pills = append(pills, queuePill(m.promptQueue, queueFocused, m.pillsExpanded, t))
+	}
+
+	var expandedList string
+	if m.pillsExpanded {
+		if todosFocused && hasIncomplete {
+			expandedList = todoList(m.session.Todos, inProgressIcon, t, contentWidth)
+		} else if queueFocused && hasQueue {
+			if m.com.App != nil && m.com.App.AgentCoordinator != nil {
+				queueItems := m.com.App.AgentCoordinator.QueuedPromptsList(m.session.ID)
+				expandedList = queueList(queueItems, t)
+			}
+		}
+	}
+
+	if len(pills) == 0 {
+		return
+	}
+
+	pillsRow := lipgloss.JoinHorizontal(lipgloss.Top, pills...)
+
+	helpDesc := "open"
+	if m.pillsExpanded {
+		helpDesc = "close"
+	}
+	helpKey := t.Pills.HelpKey.Render("ctrl+space")
+	helpText := t.Pills.HelpText.Render(helpDesc)
+	helpHint := lipgloss.JoinHorizontal(lipgloss.Center, helpKey, " ", helpText)
+	pillsRow = lipgloss.JoinHorizontal(lipgloss.Center, pillsRow, " ", helpHint)
+
+	pillsArea := pillsRow
+	if expandedList != "" {
+		pillsArea = lipgloss.JoinVertical(lipgloss.Left, pillsRow, expandedList)
+	}
+
+	m.pillsView = t.Pills.Area.MaxWidth(width).PaddingLeft(paddingLeft).Render(pillsArea)
+}

+ 244 - 0
internal/ui/model/session.go

@@ -0,0 +1,244 @@
+package model
+
+import (
+	"context"
+	"fmt"
+	"path/filepath"
+	"slices"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/diff"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/crush/internal/uiutil"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// loadSessionMsg is a message indicating that a session and its files have
+// been loaded.
+type loadSessionMsg struct {
+	session *session.Session
+	files   []SessionFile
+}
+
+// SessionFile tracks the first and latest versions of a file in a session,
+// along with the total additions and deletions.
+type SessionFile struct {
+	FirstVersion  history.File
+	LatestVersion history.File
+	Additions     int
+	Deletions     int
+}
+
+// loadSession loads the session along with its associated files and computes
+// the diff statistics (additions and deletions) for each file in the session.
+// It returns a tea.Cmd that, when executed, fetches the session data and
+// returns a sessionFilesLoadedMsg containing the processed session files.
+func (m *UI) loadSession(sessionID string) tea.Cmd {
+	return func() tea.Msg {
+		session, err := m.com.App.Sessions.Get(context.Background(), sessionID)
+		if err != nil {
+			// TODO: better error handling
+			return uiutil.ReportError(err)()
+		}
+
+		files, err := m.com.App.History.ListBySession(context.Background(), sessionID)
+		if err != nil {
+			// TODO: better error handling
+			return uiutil.ReportError(err)()
+		}
+
+		filesByPath := make(map[string][]history.File)
+		for _, f := range files {
+			filesByPath[f.Path] = append(filesByPath[f.Path], f)
+		}
+
+		sessionFiles := make([]SessionFile, 0, len(filesByPath))
+		for _, versions := range filesByPath {
+			if len(versions) == 0 {
+				continue
+			}
+
+			first := versions[0]
+			last := versions[0]
+			for _, v := range versions {
+				if v.Version < first.Version {
+					first = v
+				}
+				if v.Version > last.Version {
+					last = v
+				}
+			}
+
+			_, additions, deletions := diff.GenerateDiff(first.Content, last.Content, first.Path)
+
+			sessionFiles = append(sessionFiles, SessionFile{
+				FirstVersion:  first,
+				LatestVersion: last,
+				Additions:     additions,
+				Deletions:     deletions,
+			})
+		}
+
+		slices.SortFunc(sessionFiles, func(a, b SessionFile) int {
+			if a.LatestVersion.UpdatedAt > b.LatestVersion.UpdatedAt {
+				return -1
+			}
+			if a.LatestVersion.UpdatedAt < b.LatestVersion.UpdatedAt {
+				return 1
+			}
+			return 0
+		})
+
+		return loadSessionMsg{
+			session: &session,
+			files:   sessionFiles,
+		}
+	}
+}
+
+// handleFileEvent processes file change events and updates the session file
+// list with new or updated file information.
+func (m *UI) handleFileEvent(file history.File) tea.Cmd {
+	if m.session == nil || file.SessionID != m.session.ID {
+		return nil
+	}
+
+	return func() tea.Msg {
+		existingIdx := -1
+		for i, sf := range m.sessionFiles {
+			if sf.FirstVersion.Path == file.Path {
+				existingIdx = i
+				break
+			}
+		}
+
+		if existingIdx == -1 {
+			newFiles := make([]SessionFile, 0, len(m.sessionFiles)+1)
+			newFiles = append(newFiles, SessionFile{
+				FirstVersion:  file,
+				LatestVersion: file,
+				Additions:     0,
+				Deletions:     0,
+			})
+			newFiles = append(newFiles, m.sessionFiles...)
+
+			return loadSessionMsg{
+				session: m.session,
+				files:   newFiles,
+			}
+		}
+
+		updated := m.sessionFiles[existingIdx]
+
+		if file.Version < updated.FirstVersion.Version {
+			updated.FirstVersion = file
+		}
+
+		if file.Version > updated.LatestVersion.Version {
+			updated.LatestVersion = file
+		}
+
+		_, additions, deletions := diff.GenerateDiff(
+			updated.FirstVersion.Content,
+			updated.LatestVersion.Content,
+			updated.FirstVersion.Path,
+		)
+		updated.Additions = additions
+		updated.Deletions = deletions
+
+		newFiles := make([]SessionFile, 0, len(m.sessionFiles))
+		newFiles = append(newFiles, updated)
+		for i, sf := range m.sessionFiles {
+			if i != existingIdx {
+				newFiles = append(newFiles, sf)
+			}
+		}
+
+		return loadSessionMsg{
+			session: m.session,
+			files:   newFiles,
+		}
+	}
+}
+
+// filesInfo renders the modified files section for the sidebar, showing files
+// with their addition/deletion counts.
+func (m *UI) filesInfo(cwd string, width, maxItems int, isSection bool) string {
+	t := m.com.Styles
+
+	title := t.Subtle.Render("Modified Files")
+	if isSection {
+		title = common.Section(t, "Modified Files", width)
+	}
+	list := t.Subtle.Render("None")
+
+	if len(m.sessionFiles) > 0 {
+		list = fileList(t, cwd, m.sessionFiles, width, maxItems)
+	}
+
+	return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
+}
+
+// fileList renders a list of files with their diff statistics, truncating to
+// maxItems and showing a "...and N more" message if needed.
+func fileList(t *styles.Styles, cwd string, files []SessionFile, width, maxItems int) string {
+	if maxItems <= 0 {
+		return ""
+	}
+	var renderedFiles []string
+	filesShown := 0
+
+	var filesWithChanges []SessionFile
+	for _, f := range files {
+		if f.Additions == 0 && f.Deletions == 0 {
+			continue
+		}
+		filesWithChanges = append(filesWithChanges, f)
+	}
+
+	for _, f := range filesWithChanges {
+		// Skip files with no changes
+		if filesShown >= maxItems {
+			break
+		}
+
+		// Build stats string with colors
+		var statusParts []string
+		if f.Additions > 0 {
+			statusParts = append(statusParts, t.Files.Additions.Render(fmt.Sprintf("+%d", f.Additions)))
+		}
+		if f.Deletions > 0 {
+			statusParts = append(statusParts, t.Files.Deletions.Render(fmt.Sprintf("-%d", f.Deletions)))
+		}
+		extraContent := strings.Join(statusParts, " ")
+
+		// Format file path
+		filePath := f.FirstVersion.Path
+		if rel, err := filepath.Rel(cwd, filePath); err == nil {
+			filePath = rel
+		}
+		filePath = fsext.DirTrim(filePath, 2)
+		filePath = ansi.Truncate(filePath, width-(lipgloss.Width(extraContent)-2), "…")
+
+		line := t.Files.Path.Render(filePath)
+		if extraContent != "" {
+			line = fmt.Sprintf("%s %s", line, extraContent)
+		}
+
+		renderedFiles = append(renderedFiles, line)
+		filesShown++
+	}
+
+	if len(filesWithChanges) > maxItems {
+		remaining := len(filesWithChanges) - maxItems
+		renderedFiles = append(renderedFiles, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining)))
+	}
+
+	return lipgloss.JoinVertical(lipgloss.Left, renderedFiles...)
+}

+ 163 - 0
internal/ui/model/sidebar.go

@@ -0,0 +1,163 @@
+package model
+
+import (
+	"cmp"
+	"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"
+	"golang.org/x/text/cases"
+	"golang.org/x/text/language"
+)
+
+// modelInfo renders the current model information including reasoning
+// settings and context usage/cost for the sidebar.
+func (m *UI) modelInfo(width int) string {
+	model := m.selectedLargeModel()
+	reasoningInfo := ""
+	providerName := ""
+
+	if model != nil {
+		// Get provider name first
+		providerConfig, ok := m.com.Config().Providers.Get(model.ModelCfg.Provider)
+		if ok {
+			providerName = providerConfig.Name
+
+			// Only check reasoning if model can reason
+			if model.CatwalkCfg.CanReason {
+				switch providerConfig.Type {
+				case catwalk.TypeAnthropic:
+					if model.ModelCfg.Think {
+						reasoningInfo = "Thinking On"
+					} else {
+						reasoningInfo = "Thinking Off"
+					}
+				default:
+					formatter := cases.Title(language.English, cases.NoLower)
+					reasoningEffort := cmp.Or(model.ModelCfg.ReasoningEffort, model.CatwalkCfg.DefaultReasoningEffort)
+					reasoningInfo = formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))
+				}
+			}
+		}
+	}
+
+	var modelContext *common.ModelContextInfo
+	if m.session != nil {
+		modelContext = &common.ModelContextInfo{
+			ContextUsed:  m.session.CompletionTokens + m.session.PromptTokens,
+			Cost:         m.session.Cost,
+			ModelContext: model.CatwalkCfg.ContextWindow,
+		}
+	}
+	return common.ModelInfo(m.com.Styles, model.CatwalkCfg.Name, providerName, reasoningInfo, modelContext, width)
+}
+
+// getDynamicHeightLimits will give us the num of items to show in each section based on the hight
+// some items are more important than others.
+func getDynamicHeightLimits(availableHeight int) (maxFiles, maxLSPs, maxMCPs int) {
+	const (
+		minItemsPerSection      = 2
+		defaultMaxFilesShown    = 10
+		defaultMaxLSPsShown     = 8
+		defaultMaxMCPsShown     = 8
+		minAvailableHeightLimit = 10
+	)
+
+	// If we have very little space, use minimum values
+	if availableHeight < minAvailableHeightLimit {
+		return minItemsPerSection, minItemsPerSection, minItemsPerSection
+	}
+
+	// Distribute available height among the three sections
+	// Give priority to files, then LSPs, then MCPs
+	totalSections := 3
+	heightPerSection := availableHeight / totalSections
+
+	// Calculate limits for each section, ensuring minimums
+	maxFiles = max(minItemsPerSection, min(defaultMaxFilesShown, heightPerSection))
+	maxLSPs = max(minItemsPerSection, min(defaultMaxLSPsShown, heightPerSection))
+	maxMCPs = max(minItemsPerSection, min(defaultMaxMCPsShown, heightPerSection))
+
+	// If we have extra space, give it to files first
+	remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs)
+	if remainingHeight > 0 {
+		extraForFiles := min(remainingHeight, defaultMaxFilesShown-maxFiles)
+		maxFiles += extraForFiles
+		remainingHeight -= extraForFiles
+
+		if remainingHeight > 0 {
+			extraForLSPs := min(remainingHeight, defaultMaxLSPsShown-maxLSPs)
+			maxLSPs += extraForLSPs
+			remainingHeight -= extraForLSPs
+
+			if remainingHeight > 0 {
+				maxMCPs += min(remainingHeight, defaultMaxMCPsShown-maxMCPs)
+			}
+		}
+	}
+
+	return maxFiles, maxLSPs, maxMCPs
+}
+
+// sidebar renders the chat sidebar containing session title, working
+// directory, model info, file list, LSP status, and MCP status.
+func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
+	if m.session == nil {
+		return
+	}
+
+	const logoHeightBreakpoint = 30
+
+	t := m.com.Styles
+	width := area.Dx()
+	height := area.Dy()
+
+	title := t.Muted.Width(width).MaxHeight(2).Render(m.session.Title)
+	cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width)
+	sidebarLogo := m.sidebarLogo
+	if height < logoHeightBreakpoint {
+		sidebarLogo = logo.SmallRender(width)
+	}
+	blocks := []string{
+		sidebarLogo,
+		title,
+		"",
+		cwd,
+		"",
+		m.modelInfo(width),
+		"",
+	}
+
+	sidebarHeader := lipgloss.JoinVertical(
+		lipgloss.Left,
+		blocks...,
+	)
+
+	_, remainingHeightArea := uv.SplitVertical(m.layout.sidebar, uv.Fixed(lipgloss.Height(sidebarHeader)))
+	remainingHeight := remainingHeightArea.Dy() - 10
+	maxFiles, maxLSPs, maxMCPs := getDynamicHeightLimits(remainingHeight)
+
+	lspSection := m.lspInfo(width, maxLSPs, true)
+	mcpSection := m.mcpInfo(width, maxMCPs, true)
+	filesSection := m.filesInfo(m.com.Config().WorkingDir(), width, maxFiles, true)
+
+	uv.NewStyledString(
+		lipgloss.NewStyle().
+			MaxWidth(width).
+			MaxHeight(height).
+			Render(
+				lipgloss.JoinVertical(
+					lipgloss.Left,
+					sidebarHeader,
+					filesSection,
+					"",
+					lspSection,
+					"",
+					mcpSection,
+				),
+			),
+	).Draw(scr, area)
+}

+ 106 - 0
internal/ui/model/status.go

@@ -0,0 +1,106 @@
+package model
+
+import (
+	"time"
+
+	"charm.land/bubbles/v2/help"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/uiutil"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// DefaultStatusTTL is the default time-to-live for status messages.
+const DefaultStatusTTL = 5 * time.Second
+
+// Status is the status bar and help model.
+type Status struct {
+	com    *common.Common
+	help   help.Model
+	helpKm help.KeyMap
+	msg    uiutil.InfoMsg
+}
+
+// NewStatus creates a new status bar and help model.
+func NewStatus(com *common.Common, km help.KeyMap) *Status {
+	s := new(Status)
+	s.com = com
+	s.help = help.New()
+	s.help.Styles = com.Styles.Help
+	s.helpKm = km
+	return s
+}
+
+// SetInfoMsg sets the status info message.
+func (s *Status) SetInfoMsg(msg uiutil.InfoMsg) {
+	s.msg = msg
+}
+
+// ClearInfoMsg clears the status info message.
+func (s *Status) ClearInfoMsg() {
+	s.msg = uiutil.InfoMsg{}
+}
+
+// SetWidth sets the width of the status bar and help view.
+func (s *Status) SetWidth(width int) {
+	s.help.SetWidth(width)
+}
+
+// ShowingAll returns whether the full help view is shown.
+func (s *Status) ShowingAll() bool {
+	return s.help.ShowAll
+}
+
+// ToggleHelp toggles the full help view.
+func (s *Status) ToggleHelp() {
+	s.help.ShowAll = !s.help.ShowAll
+}
+
+// Draw draws the status bar onto the screen.
+func (s *Status) Draw(scr uv.Screen, area uv.Rectangle) {
+	helpView := s.com.Styles.Status.Help.Render(s.help.View(s.helpKm))
+	uv.NewStyledString(helpView).Draw(scr, area)
+
+	// Render notifications
+	if s.msg.IsEmpty() {
+		return
+	}
+
+	var indStyle lipgloss.Style
+	var msgStyle lipgloss.Style
+	switch s.msg.Type {
+	case uiutil.InfoTypeError:
+		indStyle = s.com.Styles.Status.ErrorIndicator
+		msgStyle = s.com.Styles.Status.ErrorMessage
+	case uiutil.InfoTypeWarn:
+		indStyle = s.com.Styles.Status.WarnIndicator
+		msgStyle = s.com.Styles.Status.WarnMessage
+	case uiutil.InfoTypeUpdate:
+		indStyle = s.com.Styles.Status.UpdateIndicator
+		msgStyle = s.com.Styles.Status.UpdateMessage
+	case uiutil.InfoTypeInfo:
+		indStyle = s.com.Styles.Status.InfoIndicator
+		msgStyle = s.com.Styles.Status.InfoMessage
+	case uiutil.InfoTypeSuccess:
+		indStyle = s.com.Styles.Status.SuccessIndicator
+		msgStyle = s.com.Styles.Status.SuccessMessage
+	}
+
+	ind := indStyle.String()
+	messageWidth := area.Dx() - lipgloss.Width(ind)
+	msg := ansi.Truncate(s.msg.Msg, messageWidth, "…")
+	info := msgStyle.Width(messageWidth).Render(msg)
+
+	// Draw the info message over the help view
+	uv.NewStyledString(ind+info).Draw(scr, area)
+}
+
+// clearInfoMsgCmd returns a command that clears the info message after the
+// given TTL.
+func clearInfoMsgCmd(ttl time.Duration) tea.Cmd {
+	return tea.Tick(ttl, func(time.Time) tea.Msg {
+		return uiutil.ClearStatusMsg{}
+	})
+}

+ 2944 - 0
internal/ui/model/ui.go

@@ -0,0 +1,2944 @@
+package model
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"image"
+	"log/slog"
+	"math/rand"
+	"net/http"
+	"os"
+	"path/filepath"
+	"regexp"
+	"slices"
+	"strconv"
+	"strings"
+	"time"
+
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/spinner"
+	"charm.land/bubbles/v2/textarea"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/atotto/clipboard"
+	"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/history"
+	"github.com/charmbracelet/crush/internal/home"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/pubsub"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/ui/anim"
+	"github.com/charmbracelet/crush/internal/ui/attachments"
+	"github.com/charmbracelet/crush/internal/ui/chat"
+	"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"
+	"github.com/charmbracelet/crush/internal/version"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/ultraviolet/screen"
+	"github.com/charmbracelet/x/editor"
+)
+
+// Compact mode breakpoints.
+const (
+	compactModeWidthBreakpoint  = 120
+	compactModeHeightBreakpoint = 30
+)
+
+// If pasted text has more than 2 newlines, treat it as a file attachment.
+const pasteLinesThreshold = 10
+
+// Session details panel max height.
+const sessionDetailsMaxHeight = 20
+
+// uiFocusState represents the current focus state of the UI.
+type uiFocusState uint8
+
+// Possible uiFocusState values.
+const (
+	uiFocusNone uiFocusState = iota
+	uiFocusEditor
+	uiFocusMain
+)
+
+type uiState uint8
+
+// Possible uiState values.
+const (
+	uiConfigure uiState = iota
+	uiInitialize
+	uiLanding
+	uiChat
+)
+
+type openEditorMsg struct {
+	Text string
+}
+
+type (
+	// cancelTimerExpiredMsg is sent when the cancel timer expires.
+	cancelTimerExpiredMsg struct{}
+	// userCommandsLoadedMsg is sent when user commands are loaded.
+	userCommandsLoadedMsg struct {
+		Commands []commands.CustomCommand
+	}
+	// mcpPromptsLoadedMsg is sent when mcp prompts are loaded.
+	mcpPromptsLoadedMsg struct {
+		Prompts []commands.MCPPrompt
+	}
+	// sendMessageMsg is sent to send a message.
+	// currently only used for mcp prompts.
+	sendMessageMsg struct {
+		Content     string
+		Attachments []message.Attachment
+	}
+
+	// closeDialogMsg is sent to close the current dialog.
+	closeDialogMsg struct{}
+
+	// copyChatHighlightMsg is sent to copy the current chat highlight to clipboard.
+	copyChatHighlightMsg struct{}
+)
+
+// UI represents the main user interface model.
+type UI struct {
+	com          *common.Common
+	session      *session.Session
+	sessionFiles []SessionFile
+
+	lastUserMessageTime int64
+
+	// The width and height of the terminal in cells.
+	width  int
+	height int
+	layout layout
+
+	focus uiFocusState
+	state uiState
+
+	keyMap KeyMap
+	keyenh tea.KeyboardEnhancementsMsg
+
+	dialog *dialog.Overlay
+	status *Status
+
+	// isCanceling tracks whether the user has pressed escape once to cancel.
+	isCanceling bool
+
+	// header is the last cached header logo
+	header string
+
+	// sendProgressBar instructs the TUI to send progress bar updates to the
+	// terminal.
+	sendProgressBar bool
+
+	// QueryVersion instructs the TUI to query for the terminal version when it
+	// starts.
+	QueryVersion bool
+
+	// Editor components
+	textarea textarea.Model
+
+	// Attachment list
+	attachments *attachments.Attachments
+
+	readyPlaceholder   string
+	workingPlaceholder string
+
+	// Completions state
+	completions              *completions.Completions
+	completionsOpen          bool
+	completionsStartIndex    int
+	completionsQuery         string
+	completionsPositionStart image.Point // x,y where user typed '@'
+
+	// Chat components
+	chat *Chat
+
+	// onboarding state
+	onboarding struct {
+		yesInitializeSelected bool
+	}
+
+	// lsp
+	lspStates map[string]app.LSPClientInfo
+
+	// mcp
+	mcpStates map[string]mcp.ClientInfo
+
+	// 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
+
+	// forceCompactMode tracks whether compact mode is forced by user toggle
+	forceCompactMode bool
+
+	// isCompact tracks whether we're currently in compact layout mode (either
+	// by user toggle or auto-switch based on window size)
+	isCompact bool
+
+	// detailsOpen tracks whether the details panel is open (in compact mode)
+	detailsOpen bool
+
+	// pills state
+	pillsExpanded      bool
+	focusedPillSection pillSection
+	promptQueue        int
+	pillsView          string
+
+	// Todo spinner
+	todoSpinner    spinner.Model
+	todoIsSpinning bool
+
+	// mouse highlighting related state
+	lastClickTime time.Time
+}
+
+// New creates a new instance of the [UI] model.
+func New(com *common.Common) *UI {
+	// Editor components
+	ta := textarea.New()
+	ta.SetStyles(com.Styles.TextArea)
+	ta.ShowLineNumbers = false
+	ta.CharLimit = -1
+	ta.SetVirtualCursor(false)
+	ta.Focus()
+
+	ch := NewChat(com)
+
+	keyMap := DefaultKeyMap()
+
+	// Completions component
+	comp := completions.New(
+		com.Styles.Completions.Normal,
+		com.Styles.Completions.Focused,
+		com.Styles.Completions.Match,
+	)
+
+	todoSpinner := spinner.New(
+		spinner.WithSpinner(spinner.MiniDot),
+		spinner.WithStyle(com.Styles.Pills.TodoSpinner),
+	)
+
+	// Attachments component
+	attachments := attachments.New(
+		attachments.NewRenderer(
+			com.Styles.Attachments.Normal,
+			com.Styles.Attachments.Deleting,
+			com.Styles.Attachments.Image,
+			com.Styles.Attachments.Text,
+		),
+		attachments.Keymap{
+			DeleteMode: keyMap.Editor.AttachmentDeleteMode,
+			DeleteAll:  keyMap.Editor.DeleteAllAttachments,
+			Escape:     keyMap.Editor.Escape,
+		},
+	)
+
+	ui := &UI{
+		com:         com,
+		dialog:      dialog.NewOverlay(),
+		keyMap:      keyMap,
+		focus:       uiFocusNone,
+		state:       uiConfigure,
+		textarea:    ta,
+		chat:        ch,
+		completions: comp,
+		attachments: attachments,
+		todoSpinner: todoSpinner,
+		lspStates:   make(map[string]app.LSPClientInfo),
+		mcpStates:   make(map[string]mcp.ClientInfo),
+	}
+
+	status := NewStatus(com, ui)
+
+	// set onboarding state defaults
+	ui.onboarding.yesInitializeSelected = true
+
+	// If no provider is configured show the user the provider list
+	if !com.Config().IsConfigured() {
+		ui.state = uiConfigure
+		// if the project needs initialization show the user the question
+	} else if n, _ := config.ProjectNeedsInitialization(); n {
+		ui.state = uiInitialize
+		// otherwise go to the landing UI
+	} else {
+		ui.state = uiLanding
+		ui.focus = uiFocusEditor
+	}
+
+	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
+
+	return ui
+}
+
+// Init initializes the UI model.
+func (m *UI) Init() tea.Cmd {
+	var cmds []tea.Cmd
+	if m.QueryVersion {
+		cmds = append(cmds, tea.RequestTerminalVersion)
+	}
+	// load the user commands async
+	cmds = append(cmds, m.loadCustomCommands())
+	return tea.Batch(cmds...)
+}
+
+// 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)
+		}
+		return userCommandsLoadedMsg{Commands: customCommands}
+	}
+}
+
+// loadMCPrompts loads the MCP prompts asynchronously.
+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)
+		}
+		if prompts == nil {
+			// flag them as loaded even if there is none or an error
+			prompts = []commands.MCPPrompt{}
+		}
+		return mcpPromptsLoadedMsg{Prompts: prompts}
+	}
+}
+
+// Update handles updates to the UI model.
+func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmds []tea.Cmd
+	if m.hasSession() && m.isAgentBusy() {
+		queueSize := m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID)
+		if queueSize != m.promptQueue {
+			m.promptQueue = queueSize
+			m.updateLayoutAndSize()
+		}
+	}
+	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)
+		// XXX: Right now, we're using the same logic to determine image
+		// support. Terminals like Apple Terminal and possibly others might
+		// bleed characters when querying for Kitty graphics via APC escape
+		// sequences.
+		cmds = append(cmds, timage.RequestCapabilities(m.imgCaps.Env))
+	case loadSessionMsg:
+		m.state = uiChat
+		if m.forceCompactMode {
+			m.isCompact = true
+		}
+		m.session = msg.session
+		m.sessionFiles = msg.files
+		msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
+		if err != nil {
+			cmds = append(cmds, uiutil.ReportError(err))
+			break
+		}
+		if cmd := m.setSessionMessages(msgs); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+		if hasInProgressTodo(m.session.Todos) {
+			// only start spinner if there is an in-progress todo
+			if m.isAgentBusy() {
+				m.todoIsSpinning = true
+				cmds = append(cmds, m.todoSpinner.Tick)
+			}
+			m.updateLayoutAndSize()
+		}
+
+	case sendMessageMsg:
+		cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
+
+	case userCommandsLoadedMsg:
+		m.customCommands = msg.Commands
+		dia := m.dialog.Dialog(dialog.CommandsID)
+		if dia == nil {
+			break
+		}
+
+		commands, ok := dia.(*dialog.Commands)
+		if ok {
+			commands.SetCustomCommands(m.customCommands)
+		}
+	case mcpPromptsLoadedMsg:
+		m.mcpPrompts = msg.Prompts
+		dia := m.dialog.Dialog(dialog.CommandsID)
+		if dia == nil {
+			break
+		}
+
+		commands, ok := dia.(*dialog.Commands)
+		if ok {
+			commands.SetMCPPrompts(m.mcpPrompts)
+		}
+
+	case closeDialogMsg:
+		m.dialog.CloseFrontDialog()
+
+	case pubsub.Event[session.Session]:
+		if m.session != nil && msg.Payload.ID == m.session.ID {
+			prevHasInProgress := hasInProgressTodo(m.session.Todos)
+			m.session = &msg.Payload
+			if !prevHasInProgress && hasInProgressTodo(m.session.Todos) {
+				m.todoIsSpinning = true
+				cmds = append(cmds, m.todoSpinner.Tick)
+				m.updateLayoutAndSize()
+			}
+		}
+	case pubsub.Event[message.Message]:
+		// Check if this is a child session message for an agent tool.
+		if m.session == nil {
+			break
+		}
+		if msg.Payload.SessionID != m.session.ID {
+			// This might be a child session message from an agent tool.
+			if cmd := m.handleChildSessionMessage(msg); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+			break
+		}
+		switch msg.Type {
+		case pubsub.CreatedEvent:
+			cmds = append(cmds, m.appendSessionMessage(msg.Payload))
+		case pubsub.UpdatedEvent:
+			cmds = append(cmds, m.updateSessionMessage(msg.Payload))
+		case pubsub.DeletedEvent:
+			m.chat.RemoveMessage(msg.Payload.ID)
+		}
+		// start the spinner if there is a new message
+		if hasInProgressTodo(m.session.Todos) && m.isAgentBusy() && !m.todoIsSpinning {
+			m.todoIsSpinning = true
+			cmds = append(cmds, m.todoSpinner.Tick)
+		}
+		// stop the spinner if the agent is not busy anymore
+		if m.todoIsSpinning && !m.isAgentBusy() {
+			m.todoIsSpinning = false
+		}
+		// there is a number of things that could change the pills here so we want to re-render
+		m.renderPills()
+	case pubsub.Event[history.File]:
+		cmds = append(cmds, m.handleFileEvent(msg.Payload))
+	case pubsub.Event[app.LSPEvent]:
+		m.lspStates = app.GetLSPStates()
+	case pubsub.Event[mcp.Event]:
+		m.mcpStates = mcp.GetStates()
+		// check if all mcps are initialized
+		initialized := true
+		for _, state := range m.mcpStates {
+			if state.State == mcp.StateStarting {
+				initialized = false
+				break
+			}
+		}
+		if initialized && m.mcpPrompts == nil {
+			cmds = append(cmds, m.loadMCPrompts())
+		}
+	case pubsub.Event[permission.PermissionRequest]:
+		if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case pubsub.Event[permission.PermissionNotification]:
+		m.handlePermissionNotification(msg.Payload)
+	case cancelTimerExpiredMsg:
+		m.isCanceling = false
+	case tea.TerminalVersionMsg:
+		termVersion := strings.ToLower(msg.Name)
+		// Only enable progress bar for the following terminals.
+		if !m.sendProgressBar {
+			m.sendProgressBar = strings.Contains(termVersion, "ghostty")
+		}
+		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() {
+			m.keyMap.Models.SetHelp("ctrl+m", "models")
+			m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
+		}
+	case copyChatHighlightMsg:
+		cmds = append(cmds, m.copyChatHighlight())
+	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...)
+		}
+		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()
+			}
+		}
+
+	case tea.MouseMotionMsg:
+		// Pass mouse events to dialogs first if any are open.
+		if m.dialog.HasDialogs() {
+			m.dialog.Update(msg)
+			return m, tea.Batch(cmds...)
+		}
+
+		switch m.state {
+		case uiChat:
+			if msg.Y <= 0 {
+				if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				if !m.chat.SelectedItemInView() {
+					m.chat.SelectPrev()
+					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
+				}
+			} else if msg.Y >= m.chat.Height()-1 {
+				if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				if !m.chat.SelectedItemInView() {
+					m.chat.SelectNext()
+					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
+				}
+			}
+
+			x, y := msg.X, msg.Y
+			// Adjust for chat area position
+			x -= m.layout.main.Min.X
+			y -= m.layout.main.Min.Y
+			m.chat.HandleMouseDrag(x, y)
+		}
+
+	case tea.MouseReleaseMsg:
+		// Pass mouse events to dialogs first if any are open.
+		if m.dialog.HasDialogs() {
+			m.dialog.Update(msg)
+			return m, tea.Batch(cmds...)
+		}
+		const doubleClickThreshold = 500 * time.Millisecond
+
+		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.HandleMouseUp(x, y) && m.chat.HasHighlight() {
+				cmds = append(cmds, tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
+					if time.Since(m.lastClickTime) >= doubleClickThreshold {
+						return copyChatHighlightMsg{}
+					}
+					return nil
+				}))
+			}
+		}
+	case tea.MouseWheelMsg:
+		// Pass mouse events to dialogs first if any are open.
+		if m.dialog.HasDialogs() {
+			m.dialog.Update(msg)
+			return m, tea.Batch(cmds...)
+		}
+
+		// Otherwise handle mouse wheel for chat.
+		switch m.state {
+		case uiChat:
+			switch msg.Button {
+			case tea.MouseWheelUp:
+				if cmd := m.chat.ScrollByAndAnimate(-5); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				if !m.chat.SelectedItemInView() {
+					m.chat.SelectPrev()
+					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
+				}
+			case tea.MouseWheelDown:
+				if cmd := m.chat.ScrollByAndAnimate(5); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				if !m.chat.SelectedItemInView() {
+					m.chat.SelectNext()
+					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
+				}
+			}
+		}
+	case anim.StepMsg:
+		if m.state == uiChat {
+			if cmd := m.chat.Animate(msg); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+	case spinner.TickMsg:
+		if m.dialog.HasDialogs() {
+			// route to dialog
+			if cmd := m.handleDialogMsg(msg); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+		if m.state == uiChat && m.hasSession() && hasInProgressTodo(m.session.Todos) && m.todoIsSpinning {
+			var cmd tea.Cmd
+			m.todoSpinner, cmd = m.todoSpinner.Update(msg)
+			if cmd != nil {
+				m.renderPills()
+				cmds = append(cmds, cmd)
+			}
+		}
+
+	case tea.KeyPressMsg:
+		if cmd := m.handleKeyPressMsg(msg); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case tea.PasteMsg:
+		if cmd := m.handlePasteMsg(msg); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case openEditorMsg:
+		m.textarea.SetValue(msg.Text)
+		m.textarea.MoveToEnd()
+	case uiutil.InfoMsg:
+		m.status.SetInfoMsg(msg)
+		ttl := msg.TTL
+		if ttl <= 0 {
+			ttl = DefaultStatusTTL
+		}
+		cmds = append(cmds, clearInfoMsgCmd(ttl))
+	case uiutil.ClearStatusMsg:
+		m.status.ClearInfoMsg()
+	case completions.FilesLoadedMsg:
+		// Handle async file loading for completions.
+		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",
+				"response", string(msg.Payload),
+				"options", msg.Options)
+		}
+	default:
+		if m.dialog.HasDialogs() {
+			if cmd := m.handleDialogMsg(msg); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+	}
+
+	// This logic gets triggered on any message type, but should it?
+	switch m.focus {
+	case uiFocusMain:
+	case uiFocusEditor:
+		// Textarea placeholder logic
+		if m.isAgentBusy() {
+			m.textarea.Placeholder = m.workingPlaceholder
+		} else {
+			m.textarea.Placeholder = m.readyPlaceholder
+		}
+		if m.com.App.Permissions.SkipRequests() {
+			m.textarea.Placeholder = "Yolo mode!"
+		}
+	}
+
+	// at this point this can only handle [message.Attachment] message, and we
+	// should return all cmds anyway.
+	_ = m.attachments.Update(msg)
+	return m, tea.Batch(cmds...)
+}
+
+// setSessionMessages sets the messages for the current session in the chat
+func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
+	var cmds []tea.Cmd
+	// Build tool result map to link tool calls with their results
+	msgPtrs := make([]*message.Message, len(msgs))
+	for i := range msgs {
+		msgPtrs[i] = &msgs[i]
+	}
+	toolResultMap := chat.BuildToolResultMap(msgPtrs)
+	if len(msgPtrs) > 0 {
+		m.lastUserMessageTime = msgPtrs[0].CreatedAt
+	}
+
+	// Add messages to chat with linked tool results
+	items := make([]chat.MessageItem, 0, len(msgs)*2)
+	for _, msg := range msgPtrs {
+		switch msg.Role {
+		case message.User:
+			m.lastUserMessageTime = msg.CreatedAt
+			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
+		case message.Assistant:
+			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
+			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
+				infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, time.Unix(m.lastUserMessageTime, 0))
+				items = append(items, infoItem)
+			}
+		default:
+			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
+		}
+	}
+
+	// Load nested tool calls for agent/agentic_fetch tools.
+	m.loadNestedToolCalls(items)
+
+	// If the user switches between sessions while the agent is working we want
+	// to make sure the animations are shown.
+	for _, item := range items {
+		if animatable, ok := item.(chat.Animatable); ok {
+			if cmd := animatable.StartAnimation(); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+	}
+
+	m.chat.SetMessages(items...)
+	if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+	m.chat.SelectLast()
+	return tea.Batch(cmds...)
+}
+
+// loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools.
+func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
+	for _, item := range items {
+		nestedContainer, ok := item.(chat.NestedToolContainer)
+		if !ok {
+			continue
+		}
+		toolItem, ok := item.(chat.ToolMessageItem)
+		if !ok {
+			continue
+		}
+
+		tc := toolItem.ToolCall()
+		messageID := toolItem.MessageID()
+
+		// Get the agent tool session ID.
+		agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID)
+
+		// Fetch nested messages.
+		nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
+		if err != nil || len(nestedMsgs) == 0 {
+			continue
+		}
+
+		// Build tool result map for nested messages.
+		nestedMsgPtrs := make([]*message.Message, len(nestedMsgs))
+		for i := range nestedMsgs {
+			nestedMsgPtrs[i] = &nestedMsgs[i]
+		}
+		nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs)
+
+		// Extract nested tool items.
+		var nestedTools []chat.ToolMessageItem
+		for _, nestedMsg := range nestedMsgPtrs {
+			nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
+			for _, nestedItem := range nestedItems {
+				if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
+					// Mark nested tools as simple (compact) rendering.
+					if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
+						simplifiable.SetCompact(true)
+					}
+					nestedTools = append(nestedTools, nestedToolItem)
+				}
+			}
+		}
+
+		// Recursively load nested tool calls for any agent tools within.
+		nestedMessageItems := make([]chat.MessageItem, len(nestedTools))
+		for i, nt := range nestedTools {
+			nestedMessageItems[i] = nt
+		}
+		m.loadNestedToolCalls(nestedMessageItems)
+
+		// Set nested tools on the parent.
+		nestedContainer.SetNestedTools(nestedTools)
+	}
+}
+
+// appendSessionMessage appends a new message to the current session in the chat
+// 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
+	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
+		items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
+		for _, item := range items {
+			if animatable, ok := item.(chat.Animatable); ok {
+				if cmd := animatable.StartAnimation(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+			}
+		}
+		m.chat.AppendMessages(items...)
+		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case message.Assistant:
+		items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
+		for _, item := range items {
+			if animatable, ok := item.(chat.Animatable); ok {
+				if cmd := animatable.StartAnimation(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+			}
+		}
+		m.chat.AppendMessages(items...)
+		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)
+			}
+		}
+	case message.Tool:
+		for _, tr := range msg.ToolResults() {
+			toolItem := m.chat.MessageItem(tr.ToolCallID)
+			if toolItem == nil {
+				// we should have an item!
+				continue
+			}
+			if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
+				toolMsgItem.SetResult(&tr)
+			}
+		}
+	}
+	return tea.Batch(cmds...)
+}
+
+// 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
+func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
+	var cmds []tea.Cmd
+	existingItem := m.chat.MessageItem(msg.ID)
+	atBottom := m.chat.list.AtBottom()
+
+	if existingItem != nil {
+		if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
+			assistantItem.SetMessage(&msg)
+		}
+	}
+
+	shouldRenderAssistant := chat.ShouldRenderAssistantMessage(&msg)
+	// if the message of the assistant does not have any  response just tool calls we need to remove it
+	if !shouldRenderAssistant && len(msg.ToolCalls()) > 0 && existingItem != nil {
+		m.chat.RemoveMessage(msg.ID)
+		if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem != nil {
+			m.chat.RemoveMessage(chat.AssistantInfoID(msg.ID))
+		}
+	}
+
+	if shouldRenderAssistant && msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
+		if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil {
+			newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0))
+			m.chat.AppendMessages(newInfoItem)
+		}
+	}
+
+	var items []chat.MessageItem
+	for _, tc := range msg.ToolCalls() {
+		existingToolItem := m.chat.MessageItem(tc.ID)
+		if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
+			existingToolCall := toolItem.ToolCall()
+			// only update if finished state changed or input changed
+			// to avoid clearing the cache
+			if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
+				toolItem.SetToolCall(tc)
+			}
+		}
+		if existingToolItem == nil {
+			items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
+		}
+	}
+
+	for _, item := range items {
+		if animatable, ok := item.(chat.Animatable); ok {
+			if cmd := animatable.StartAnimation(); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+	}
+
+	m.chat.AppendMessages(items...)
+	if atBottom {
+		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	}
+
+	return tea.Batch(cmds...)
+}
+
+// handleChildSessionMessage handles messages from child sessions (agent tools).
+func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
+	var cmds []tea.Cmd
+
+	atBottom := m.chat.list.AtBottom()
+	// Only process messages with tool calls or results.
+	if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
+		return nil
+	}
+
+	// Check if this is an agent tool session and parse it.
+	childSessionID := event.Payload.SessionID
+	_, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
+	if !ok {
+		return nil
+	}
+
+	// Find the parent agent tool item.
+	var agentItem chat.NestedToolContainer
+	for i := 0; i < m.chat.Len(); i++ {
+		item := m.chat.MessageItem(toolCallID)
+		if item == nil {
+			continue
+		}
+		if agent, ok := item.(chat.NestedToolContainer); ok {
+			if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
+				if toolMessageItem.ToolCall().ID == toolCallID {
+					// Verify this agent belongs to the correct parent message.
+					// We can't directly check parentMessageID on the item, so we trust the session parsing.
+					agentItem = agent
+					break
+				}
+			}
+		}
+	}
+
+	if agentItem == nil {
+		return nil
+	}
+
+	// Get existing nested tools.
+	nestedTools := agentItem.NestedTools()
+
+	// Update or create nested tool calls.
+	for _, tc := range event.Payload.ToolCalls() {
+		found := false
+		for _, existingTool := range nestedTools {
+			if existingTool.ToolCall().ID == tc.ID {
+				existingTool.SetToolCall(tc)
+				found = true
+				break
+			}
+		}
+		if !found {
+			// Create a new nested tool item.
+			nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
+			if simplifiable, ok := nestedItem.(chat.Compactable); ok {
+				simplifiable.SetCompact(true)
+			}
+			if animatable, ok := nestedItem.(chat.Animatable); ok {
+				if cmd := animatable.StartAnimation(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+			}
+			nestedTools = append(nestedTools, nestedItem)
+		}
+	}
+
+	// Update nested tool results.
+	for _, tr := range event.Payload.ToolResults() {
+		for _, nestedTool := range nestedTools {
+			if nestedTool.ToolCall().ID == tr.ToolCallID {
+				nestedTool.SetResult(&tr)
+				break
+			}
+		}
+	}
+
+	// Update the agent item with the new nested tools.
+	agentItem.SetNestedTools(nestedTools)
+
+	// Update the chat so it updates the index map for animations to work as expected
+	m.chat.UpdateNestedToolIDs(toolCallID)
+
+	if atBottom {
+		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	}
+
+	return tea.Batch(cmds...)
+}
+
+func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
+	var cmds []tea.Cmd
+	action := m.dialog.Update(msg)
+	if action == nil {
+		return tea.Batch(cmds...)
+	}
+
+	switch msg := action.(type) {
+	// Generic dialog messages
+	case dialog.ActionClose:
+		m.dialog.CloseFrontDialog()
+		if m.focus == uiFocusEditor {
+			cmds = append(cmds, m.textarea.Focus())
+		}
+	case dialog.ActionCmd:
+		if msg.Cmd != nil {
+			cmds = append(cmds, msg.Cmd)
+		}
+
+	// Session dialog messages
+	case dialog.ActionSelectSession:
+		m.dialog.CloseDialog(dialog.SessionsID)
+		cmds = append(cmds, m.loadSession(msg.Session.ID))
+
+	// Open dialog message
+	case dialog.ActionOpenDialog:
+		m.dialog.CloseDialog(dialog.CommandsID)
+		if cmd := m.openDialog(msg.DialogID); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+
+	// Command dialog messages
+	case dialog.ActionToggleYoloMode:
+		yolo := !m.com.App.Permissions.SkipRequests()
+		m.com.App.Permissions.SetSkipRequests(yolo)
+		m.setEditorPrompt(yolo)
+		m.dialog.CloseDialog(dialog.CommandsID)
+	case dialog.ActionNewSession:
+		if m.isAgentBusy() {
+			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
+			break
+		}
+		m.newSession()
+		m.dialog.CloseDialog(dialog.CommandsID)
+	case dialog.ActionSummarize:
+		if m.isAgentBusy() {
+			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
+			break
+		}
+		cmds = append(cmds, func() tea.Msg {
+			err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
+			if err != nil {
+				return uiutil.ReportError(err)()
+			}
+			return nil
+		})
+		m.dialog.CloseDialog(dialog.CommandsID)
+	case dialog.ActionToggleHelp:
+		m.status.ToggleHelp()
+		m.dialog.CloseDialog(dialog.CommandsID)
+	case dialog.ActionExternalEditor:
+		if m.isAgentBusy() {
+			cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
+			break
+		}
+		cmds = append(cmds, m.openEditor(m.textarea.Value()))
+		m.dialog.CloseDialog(dialog.CommandsID)
+	case dialog.ActionToggleCompactMode:
+		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 {
+				return uiutil.ReportError(errors.New("configuration not found"))()
+			}
+
+			agentCfg, ok := cfg.Agents[config.AgentCoder]
+			if !ok {
+				return uiutil.ReportError(errors.New("agent configuration not found"))()
+			}
+
+			currentModel := cfg.Models[agentCfg.Model]
+			currentModel.Think = !currentModel.Think
+			if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
+				return uiutil.ReportError(err)()
+			}
+			m.com.App.UpdateAgentModel(context.TODO())
+			status := "disabled"
+			if currentModel.Think {
+				status = "enabled"
+			}
+			return uiutil.NewInfoMsg("Thinking mode " + status)
+		})
+		m.dialog.CloseDialog(dialog.CommandsID)
+	case dialog.ActionQuit:
+		cmds = append(cmds, tea.Quit)
+	case dialog.ActionInitializeProject:
+		if m.isAgentBusy() {
+			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
+			break
+		}
+		cmds = append(cmds, m.initializeProject())
+		m.dialog.CloseDialog(dialog.CommandsID)
+
+	case dialog.ActionSelectModel:
+		if m.isAgentBusy() {
+			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
+			break
+		}
+
+		cfg := m.com.Config()
+		if cfg == nil {
+			cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
+			break
+		}
+
+		var (
+			providerID   = msg.Model.Provider
+			isCopilot    = providerID == string(catwalk.InferenceProviderCopilot)
+			isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
+		)
+
+		// Attempt to import GitHub Copilot tokens from VSCode if available.
+		if isCopilot && !isConfigured() {
+			config.Get().ImportCopilot()
+		}
+
+		if !isConfigured() {
+			m.dialog.CloseDialog(dialog.ModelsID)
+			if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+			break
+		}
+
+		if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
+			cmds = append(cmds, uiutil.ReportError(err))
+		}
+
+		cmds = append(cmds, func() tea.Msg {
+			m.com.App.UpdateAgentModel(context.TODO())
+
+			modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
+
+			return uiutil.NewInfoMsg(modelMsg)
+		})
+
+		m.dialog.CloseDialog(dialog.APIKeyInputID)
+		m.dialog.CloseDialog(dialog.OAuthID)
+		m.dialog.CloseDialog(dialog.ModelsID)
+	case dialog.ActionSelectReasoningEffort:
+		if m.isAgentBusy() {
+			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
+			break
+		}
+
+		cfg := m.com.Config()
+		if cfg == nil {
+			cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
+			break
+		}
+
+		agentCfg, ok := cfg.Agents[config.AgentCoder]
+		if !ok {
+			cmds = append(cmds, uiutil.ReportError(errors.New("agent configuration not found")))
+			break
+		}
+
+		currentModel := cfg.Models[agentCfg.Model]
+		currentModel.ReasoningEffort = msg.Effort
+		if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
+			cmds = append(cmds, uiutil.ReportError(err))
+			break
+		}
+
+		cmds = append(cmds, func() tea.Msg {
+			m.com.App.UpdateAgentModel(context.TODO())
+			return uiutil.NewInfoMsg("Reasoning effort set to " + msg.Effort)
+		})
+		m.dialog.CloseDialog(dialog.ReasoningID)
+	case dialog.ActionPermissionResponse:
+		m.dialog.CloseDialog(dialog.PermissionsID)
+		switch msg.Action {
+		case dialog.PermissionAllow:
+			m.com.App.Permissions.Grant(msg.Permission)
+		case dialog.PermissionAllowForSession:
+			m.com.App.Permissions.GrantPersistent(msg.Permission)
+		case dialog.PermissionDeny:
+			m.com.App.Permissions.Deny(msg.Permission)
+		}
+
+	case dialog.ActionFilePickerSelected:
+		cmds = append(cmds, tea.Sequence(
+			msg.Cmd(),
+			func() tea.Msg {
+				m.dialog.CloseDialog(dialog.FilePickerID)
+				return nil
+			},
+		))
+
+	case dialog.ActionRunCustomCommand:
+		if len(msg.Arguments) > 0 && msg.Args == nil {
+			m.dialog.CloseFrontDialog()
+			argsDialog := dialog.NewArguments(
+				m.com,
+				"Custom Command Arguments",
+				"",
+				msg.Arguments,
+				msg, // Pass the action as the result
+			)
+			m.dialog.OpenDialog(argsDialog)
+			break
+		}
+		content := msg.Content
+		if msg.Args != nil {
+			content = substituteArgs(content, msg.Args)
+		}
+		cmds = append(cmds, m.sendMessage(content))
+		m.dialog.CloseFrontDialog()
+	case dialog.ActionRunMCPPrompt:
+		if len(msg.Arguments) > 0 && msg.Args == nil {
+			m.dialog.CloseFrontDialog()
+			title := msg.Title
+			if title == "" {
+				title = "MCP Prompt Arguments"
+			}
+			argsDialog := dialog.NewArguments(
+				m.com,
+				title,
+				msg.Description,
+				msg.Arguments,
+				msg, // Pass the action as the result
+			)
+			m.dialog.OpenDialog(argsDialog)
+			break
+		}
+		cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
+	default:
+		cmds = append(cmds, uiutil.CmdHandler(msg))
+	}
+
+	return tea.Batch(cmds...)
+}
+
+// substituteArgs replaces $ARG_NAME placeholders in content with actual values.
+func substituteArgs(content string, args map[string]string) string {
+	for name, value := range args {
+		placeholder := "$" + name
+		content = strings.ReplaceAll(content, placeholder, value)
+	}
+	return content
+}
+
+func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
+	var (
+		dlg dialog.Dialog
+		cmd tea.Cmd
+	)
+
+	switch provider.ID {
+	case "hyper":
+		dlg, cmd = dialog.NewOAuthHyper(m.com, provider, model, modelType)
+	case catwalk.InferenceProviderCopilot:
+		dlg, cmd = dialog.NewOAuthCopilot(m.com, provider, model, modelType)
+	default:
+		dlg, cmd = dialog.NewAPIKeyInput(m.com, provider, model, modelType)
+	}
+
+	if m.dialog.ContainsDialog(dlg.ID()) {
+		m.dialog.BringToFront(dlg.ID())
+		return nil
+	}
+
+	m.dialog.OpenDialog(dlg)
+	return cmd
+}
+
+func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
+	var cmds []tea.Cmd
+
+	handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
+		switch {
+		case key.Matches(msg, m.keyMap.Help):
+			m.status.ToggleHelp()
+			m.updateLayoutAndSize()
+			return true
+		case key.Matches(msg, m.keyMap.Commands):
+			if cmd := m.openCommandsDialog(); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+			return true
+		case key.Matches(msg, m.keyMap.Models):
+			if cmd := m.openModelsDialog(); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+			return true
+		case key.Matches(msg, m.keyMap.Sessions):
+			if cmd := m.openSessionsDialog(); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+			return true
+		case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
+			m.detailsOpen = !m.detailsOpen
+			m.updateLayoutAndSize()
+			return true
+		case key.Matches(msg, m.keyMap.Chat.TogglePills):
+			if m.state == uiChat && m.hasSession() {
+				if cmd := m.togglePillsExpanded(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				return true
+			}
+		case key.Matches(msg, m.keyMap.Chat.PillLeft):
+			if m.state == uiChat && m.hasSession() && m.pillsExpanded {
+				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 cmd := m.switchPillSection(1); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				return true
+			}
+		case key.Matches(msg, m.keyMap.Suspend):
+			if m.isAgentBusy() {
+				cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
+				return true
+			}
+			cmds = append(cmds, tea.Suspend)
+			return true
+		}
+		return false
+	}
+
+	if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
+		// Always handle quit keys first
+		if cmd := m.openQuitDialog(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+
+		return tea.Batch(cmds...)
+	}
+
+	// Route all messages to dialog if one is open.
+	if m.dialog.HasDialogs() {
+		return m.handleDialogMsg(msg)
+	}
+
+	// Handle cancel key when agent is busy.
+	if key.Matches(msg, m.keyMap.Chat.Cancel) {
+		if m.isAgentBusy() {
+			if cmd := m.cancelAgent(); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+			return tea.Batch(cmds...)
+		}
+	}
+
+	switch m.state {
+	case uiConfigure:
+		return tea.Batch(cmds...)
+	case uiInitialize:
+		cmds = append(cmds, m.updateInitializeView(msg)...)
+		return tea.Batch(cmds...)
+	case uiChat, uiLanding:
+		switch m.focus {
+		case uiFocusEditor:
+			// Handle completions if open.
+			if m.completionsOpen {
+				if msg, ok := m.completions.Update(msg); ok {
+					switch msg := msg.(type) {
+					case completions.SelectionMsg:
+						// Handle file completion selection.
+						if item, ok := msg.Value.(completions.FileCompletionValue); ok {
+							cmds = append(cmds, m.insertFileCompletion(item.Path))
+						}
+						if !msg.Insert {
+							m.closeCompletions()
+						}
+					case completions.ClosedMsg:
+						m.completionsOpen = false
+					}
+					return tea.Batch(cmds...)
+				}
+			}
+
+			if ok := m.attachments.Update(msg); ok {
+				return tea.Batch(cmds...)
+			}
+
+			switch {
+			case key.Matches(msg, m.keyMap.Editor.AddImage):
+				if cmd := m.openFilesDialog(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+
+			case key.Matches(msg, m.keyMap.Editor.SendMessage):
+				value := m.textarea.Value()
+				if before, ok := strings.CutSuffix(value, "\\"); ok {
+					// If the last character is a backslash, remove it and add a newline.
+					m.textarea.SetValue(before)
+					break
+				}
+
+				// Otherwise, send the message
+				m.textarea.Reset()
+
+				value = strings.TrimSpace(value)
+				if value == "exit" || value == "quit" {
+					return m.openQuitDialog()
+				}
+
+				attachments := m.attachments.List()
+				m.attachments.Reset()
+				if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
+					return nil
+				}
+
+				m.randomizePlaceholders()
+
+				return m.sendMessage(value, attachments...)
+			case key.Matches(msg, m.keyMap.Chat.NewSession):
+				if !m.hasSession() {
+					break
+				}
+				if m.isAgentBusy() {
+					cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
+					break
+				}
+				m.newSession()
+			case key.Matches(msg, m.keyMap.Tab):
+				if m.state != uiLanding {
+					m.focus = uiFocusMain
+					m.textarea.Blur()
+					m.chat.Focus()
+					m.chat.SetSelected(m.chat.Len() - 1)
+				}
+			case key.Matches(msg, m.keyMap.Editor.OpenEditor):
+				if m.isAgentBusy() {
+					cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
+					break
+				}
+				cmds = append(cmds, m.openEditor(m.textarea.Value()))
+			case key.Matches(msg, m.keyMap.Editor.Newline):
+				m.textarea.InsertRune('\n')
+				m.closeCompletions()
+				ta, cmd := m.textarea.Update(msg)
+				m.textarea = ta
+				cmds = append(cmds, cmd)
+			default:
+				if handleGlobalKeys(msg) {
+					// Handle global keys first before passing to textarea.
+					break
+				}
+
+				// Check for @ trigger before passing to textarea.
+				curValue := m.textarea.Value()
+				curIdx := len(curValue)
+
+				// Trigger completions on @.
+				if msg.String() == "@" && !m.completionsOpen {
+					// Only show if beginning of prompt or after whitespace.
+					if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
+						m.completionsOpen = true
+						m.completionsQuery = ""
+						m.completionsStartIndex = curIdx
+						m.completionsPositionStart = m.completionsPosition()
+						depth, limit := m.com.Config().Options.TUI.Completions.Limits()
+						cmds = append(cmds, m.completions.OpenWithFiles(depth, limit))
+					}
+				}
+
+				// remove the details if they are open when user starts typing
+				if m.detailsOpen {
+					m.detailsOpen = false
+					m.updateLayoutAndSize()
+				}
+
+				ta, cmd := m.textarea.Update(msg)
+				m.textarea = ta
+				cmds = append(cmds, cmd)
+
+				// 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() != "@" {
+					newValue := m.textarea.Value()
+					newIdx := len(newValue)
+
+					// Close completions if cursor moved before start.
+					if newIdx <= m.completionsStartIndex {
+						m.closeCompletions()
+					} else if msg.String() == "space" {
+						// Close on space.
+						m.closeCompletions()
+					} else {
+						// Extract current word and filter.
+						word := m.textareaWord()
+						if strings.HasPrefix(word, "@") {
+							m.completionsQuery = word[1:]
+							m.completions.Filter(m.completionsQuery)
+						} else if m.completionsOpen {
+							m.closeCompletions()
+						}
+					}
+				}
+			}
+		case uiFocusMain:
+			switch {
+			case key.Matches(msg, m.keyMap.Tab):
+				m.focus = uiFocusEditor
+				cmds = append(cmds, m.textarea.Focus())
+				m.chat.Blur()
+			case key.Matches(msg, m.keyMap.Chat.NewSession):
+				if !m.hasSession() {
+					break
+				}
+				if m.isAgentBusy() {
+					cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
+					break
+				}
+				m.focus = uiFocusEditor
+				m.newSession()
+			case key.Matches(msg, m.keyMap.Chat.Expand):
+				m.chat.ToggleExpandedSelectedItem()
+			case key.Matches(msg, m.keyMap.Chat.Up):
+				if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				if !m.chat.SelectedItemInView() {
+					m.chat.SelectPrev()
+					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
+				}
+			case key.Matches(msg, m.keyMap.Chat.Down):
+				if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				if !m.chat.SelectedItemInView() {
+					m.chat.SelectNext()
+					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
+				}
+			case key.Matches(msg, m.keyMap.Chat.UpOneItem):
+				m.chat.SelectPrev()
+				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+			case key.Matches(msg, m.keyMap.Chat.DownOneItem):
+				m.chat.SelectNext()
+				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+			case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
+				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				m.chat.SelectFirstInView()
+			case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
+				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				m.chat.SelectLastInView()
+			case key.Matches(msg, m.keyMap.Chat.PageUp):
+				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				m.chat.SelectFirstInView()
+			case key.Matches(msg, m.keyMap.Chat.PageDown):
+				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				m.chat.SelectLastInView()
+			case key.Matches(msg, m.keyMap.Chat.Home):
+				if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				m.chat.SelectFirst()
+			case key.Matches(msg, m.keyMap.Chat.End):
+				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				m.chat.SelectLast()
+			default:
+				handleGlobalKeys(msg)
+			}
+		default:
+			handleGlobalKeys(msg)
+		}
+	default:
+		handleGlobalKeys(msg)
+	}
+
+	return tea.Batch(cmds...)
+}
+
+// Draw implements [uv.Drawable] and draws the UI model.
+func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	layout := m.generateLayout(area.Dx(), area.Dy())
+
+	if m.layout != layout {
+		m.layout = layout
+		m.updateSize()
+	}
+
+	// Clear the screen first
+	screen.Clear(scr)
+
+	switch m.state {
+	case uiConfigure:
+		header := uv.NewStyledString(m.header)
+		header.Draw(scr, layout.header)
+
+		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
+			Height(layout.main.Dy()).
+			Background(lipgloss.ANSIColor(rand.Intn(256))).
+			Render(" Configure ")
+		main := uv.NewStyledString(mainView)
+		main.Draw(scr, layout.main)
+
+	case uiInitialize:
+		header := uv.NewStyledString(m.header)
+		header.Draw(scr, layout.header)
+
+		main := uv.NewStyledString(m.initializeView())
+		main.Draw(scr, layout.main)
+
+	case uiLanding:
+		header := uv.NewStyledString(m.header)
+		header.Draw(scr, layout.header)
+		main := uv.NewStyledString(m.landingView())
+		main.Draw(scr, layout.main)
+
+		editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
+		editor.Draw(scr, layout.editor)
+
+	case uiChat:
+		if m.isCompact {
+			header := uv.NewStyledString(m.header)
+			header.Draw(scr, layout.header)
+		} else {
+			m.drawSidebar(scr, layout.sidebar)
+		}
+
+		m.chat.Draw(scr, layout.main)
+		if layout.pills.Dy() > 0 && m.pillsView != "" {
+			uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
+		}
+
+		editorWidth := scr.Bounds().Dx()
+		if !m.isCompact {
+			editorWidth -= layout.sidebar.Dx()
+		}
+		editor := uv.NewStyledString(m.renderEditorView(editorWidth))
+		editor.Draw(scr, layout.editor)
+
+		// Draw details overlay in compact mode when open
+		if m.isCompact && m.detailsOpen {
+			m.drawSessionDetails(scr, layout.sessionDetails)
+		}
+	}
+
+	// Add status and help layer
+	m.status.Draw(scr, layout.status)
+
+	// Draw completions popup if open
+	if m.completionsOpen && m.completions.HasItems() {
+		w, h := m.completions.Size()
+		x := m.completionsPositionStart.X
+		y := m.completionsPositionStart.Y - h
+
+		screenW := area.Dx()
+		if x+w > screenW {
+			x = screenW - w
+		}
+		x = max(0, x)
+		y = max(0, y)
+
+		completionsView := uv.NewStyledString(m.completions.Render())
+		completionsView.Draw(scr, image.Rectangle{
+			Min: image.Pt(x, y),
+			Max: image.Pt(x+w, y+h),
+		})
+	}
+
+	// Debugging rendering (visually see when the tui rerenders)
+	if os.Getenv("CRUSH_UI_DEBUG") == "true" {
+		debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
+		debug := uv.NewStyledString(debugView.String())
+		debug.Draw(scr, image.Rectangle{
+			Min: image.Pt(4, 1),
+			Max: image.Pt(8, 3),
+		})
+	}
+
+	// This needs to come last to overlay on top of everything. We always pass
+	// the full screen bounds because the dialogs will position themselves
+	// accordingly.
+	if m.dialog.HasDialogs() {
+		return m.dialog.Draw(scr, scr.Bounds())
+	}
+
+	switch m.focus {
+	case uiFocusEditor:
+		if m.layout.editor.Dy() <= 0 {
+			// Don't show cursor if editor is not visible
+			return nil
+		}
+		if m.detailsOpen && m.isCompact {
+			// Don't show cursor if details overlay is open
+			return nil
+		}
+
+		if m.textarea.Focused() {
+			cur := m.textarea.Cursor()
+			cur.X++ // Adjust for app margins
+			cur.Y += m.layout.editor.Min.Y
+			// Offset for attachment row if present.
+			if len(m.attachments.List()) > 0 {
+				cur.Y++
+			}
+			return cur
+		}
+	}
+	return nil
+}
+
+// View renders the UI model's view.
+func (m *UI) View() tea.View {
+	var v tea.View
+	v.AltScreen = true
+	v.BackgroundColor = m.com.Styles.Background
+	v.MouseMode = tea.MouseModeCellMotion
+	v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir())
+
+	canvas := uv.NewScreenBuffer(m.width, m.height)
+	v.Cursor = m.Draw(canvas, canvas.Bounds())
+
+	content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
+	contentLines := strings.Split(content, "\n")
+	for i, line := range contentLines {
+		// Trim trailing spaces for concise rendering
+		contentLines[i] = strings.TrimRight(line, " ")
+	}
+
+	content = strings.Join(contentLines, "\n")
+
+	v.Content = content
+	if 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))
+	}
+
+	return v
+}
+
+// ShortHelp implements [help.KeyMap].
+func (m *UI) ShortHelp() []key.Binding {
+	var binds []key.Binding
+	k := &m.keyMap
+	tab := k.Tab
+	commands := k.Commands
+	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
+		commands.SetHelp("/ or ctrl+p", "commands")
+	}
+
+	switch m.state {
+	case uiInitialize:
+		binds = append(binds, k.Quit)
+	case uiChat:
+		// Show cancel binding if agent is busy.
+		if m.isAgentBusy() {
+			cancelBinding := k.Chat.Cancel
+			if m.isCanceling {
+				cancelBinding.SetHelp("esc", "press again to cancel")
+			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
+				cancelBinding.SetHelp("esc", "clear queue")
+			}
+			binds = append(binds, cancelBinding)
+		}
+
+		if m.focus == uiFocusEditor {
+			tab.SetHelp("tab", "focus chat")
+		} else {
+			tab.SetHelp("tab", "focus editor")
+		}
+
+		binds = append(binds,
+			tab,
+			commands,
+			k.Models,
+		)
+
+		switch m.focus {
+		case uiFocusEditor:
+			binds = append(binds,
+				k.Editor.Newline,
+			)
+		case uiFocusMain:
+			binds = append(binds,
+				k.Chat.UpDown,
+				k.Chat.UpDownOneItem,
+				k.Chat.PageUp,
+				k.Chat.PageDown,
+				k.Chat.Copy,
+			)
+			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
+				binds = append(binds, k.Chat.PillLeft)
+			}
+		}
+	default:
+		// TODO: other states
+		// if m.session == nil {
+		// no session selected
+		binds = append(binds,
+			commands,
+			k.Models,
+			k.Editor.Newline,
+		)
+	}
+
+	binds = append(binds,
+		k.Quit,
+		k.Help,
+	)
+
+	return binds
+}
+
+// FullHelp implements [help.KeyMap].
+func (m *UI) FullHelp() [][]key.Binding {
+	var binds [][]key.Binding
+	k := &m.keyMap
+	help := k.Help
+	help.SetHelp("ctrl+g", "less")
+	hasAttachments := len(m.attachments.List()) > 0
+	hasSession := m.hasSession()
+	commands := k.Commands
+	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
+		commands.SetHelp("/ or ctrl+p", "commands")
+	}
+
+	switch m.state {
+	case uiInitialize:
+		binds = append(binds,
+			[]key.Binding{
+				k.Quit,
+			})
+	case uiChat:
+		// Show cancel binding if agent is busy.
+		if m.isAgentBusy() {
+			cancelBinding := k.Chat.Cancel
+			if m.isCanceling {
+				cancelBinding.SetHelp("esc", "press again to cancel")
+			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
+				cancelBinding.SetHelp("esc", "clear queue")
+			}
+			binds = append(binds, []key.Binding{cancelBinding})
+		}
+
+		mainBinds := []key.Binding{}
+		tab := k.Tab
+		if m.focus == uiFocusEditor {
+			tab.SetHelp("tab", "focus chat")
+		} else {
+			tab.SetHelp("tab", "focus editor")
+		}
+
+		mainBinds = append(mainBinds,
+			tab,
+			commands,
+			k.Models,
+			k.Sessions,
+		)
+		if hasSession {
+			mainBinds = append(mainBinds, k.Chat.NewSession)
+		}
+
+		binds = append(binds, mainBinds)
+
+		switch m.focus {
+		case uiFocusEditor:
+			binds = append(binds,
+				[]key.Binding{
+					k.Editor.Newline,
+					k.Editor.AddImage,
+					k.Editor.MentionFile,
+					k.Editor.OpenEditor,
+				},
+			)
+			if hasAttachments {
+				binds = append(binds,
+					[]key.Binding{
+						k.Editor.AttachmentDeleteMode,
+						k.Editor.DeleteAllAttachments,
+						k.Editor.Escape,
+					},
+				)
+			}
+		case uiFocusMain:
+			binds = append(binds,
+				[]key.Binding{
+					k.Chat.UpDown,
+					k.Chat.UpDownOneItem,
+					k.Chat.PageUp,
+					k.Chat.PageDown,
+				},
+				[]key.Binding{
+					k.Chat.HalfPageUp,
+					k.Chat.HalfPageDown,
+					k.Chat.Home,
+					k.Chat.End,
+				},
+				[]key.Binding{
+					k.Chat.Copy,
+					k.Chat.ClearHighlight,
+				},
+			)
+			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
+				binds = append(binds, []key.Binding{k.Chat.PillLeft})
+			}
+		}
+	default:
+		if m.session == nil {
+			// no session selected
+			binds = append(binds,
+				[]key.Binding{
+					commands,
+					k.Models,
+					k.Sessions,
+				},
+				[]key.Binding{
+					k.Editor.Newline,
+					k.Editor.AddImage,
+					k.Editor.MentionFile,
+					k.Editor.OpenEditor,
+				},
+			)
+			if hasAttachments {
+				binds = append(binds,
+					[]key.Binding{
+						k.Editor.AttachmentDeleteMode,
+						k.Editor.DeleteAllAttachments,
+						k.Editor.Escape,
+					},
+				)
+			}
+			binds = append(binds,
+				[]key.Binding{
+					help,
+				},
+			)
+		}
+	}
+
+	binds = append(binds,
+		[]key.Binding{
+			help,
+			k.Quit,
+		},
+	)
+
+	return binds
+}
+
+// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
+func (m *UI) toggleCompactMode() tea.Cmd {
+	m.forceCompactMode = !m.forceCompactMode
+
+	err := m.com.Config().SetCompactMode(m.forceCompactMode)
+	if err != nil {
+		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) {
+	if m.state == uiChat {
+		if m.forceCompactMode {
+			m.isCompact = true
+			return
+		}
+		if newWidth < compactModeWidthBreakpoint || newHeight < 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()
+}
+
+// updateSize updates the sizes of UI components based on the current layout.
+func (m *UI) updateSize() {
+	// Set status width
+	m.status.SetWidth(m.layout.status.Dx())
+
+	m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
+	m.textarea.SetWidth(m.layout.editor.Dx())
+	m.textarea.SetHeight(m.layout.editor.Dy())
+	m.renderPills()
+
+	// Handle different app states
+	switch m.state {
+	case uiConfigure, uiInitialize, uiLanding:
+		m.renderHeader(false, m.layout.header.Dx())
+
+	case uiChat:
+		if m.isCompact {
+			m.renderHeader(true, m.layout.header.Dx())
+		} else {
+			m.renderSidebarLogo(m.layout.sidebar.Dx())
+		}
+	}
+}
+
+// generateLayout calculates the layout rectangles for all UI components based
+// on the current UI state and terminal dimensions.
+func (m *UI) generateLayout(w, h int) layout {
+	// The screen area we're working with
+	area := image.Rect(0, 0, w, h)
+
+	// The help height
+	helpHeight := 1
+	// The editor height
+	editorHeight := 5
+	// The sidebar width
+	sidebarWidth := 30
+	// The header height
+	const landingHeaderHeight = 4
+
+	var helpKeyMap help.KeyMap = m
+	if m.status.ShowingAll() {
+		for _, row := range helpKeyMap.FullHelp() {
+			helpHeight = max(helpHeight, len(row))
+		}
+	}
+
+	// Add app margins
+	appRect, helpRect := uv.SplitVertical(area, uv.Fixed(area.Dy()-helpHeight))
+	appRect.Min.Y += 1
+	appRect.Max.Y -= 1
+	helpRect.Min.Y -= 1
+	appRect.Min.X += 1
+	appRect.Max.X -= 1
+
+	if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
+		// extra padding on left and right for these states
+		appRect.Min.X += 1
+		appRect.Max.X -= 1
+	}
+
+	layout := layout{
+		area:   area,
+		status: helpRect,
+	}
+
+	// Handle different app states
+	switch m.state {
+	case uiConfigure, uiInitialize:
+		// Layout
+		//
+		// header
+		// ------
+		// main
+		// ------
+		// help
+
+		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
+		layout.header = headerRect
+		layout.main = mainRect
+
+	case uiLanding:
+		// Layout
+		//
+		// header
+		// ------
+		// main
+		// ------
+		// editor
+		// ------
+		// help
+		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
+		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
+		// Remove extra padding from editor (but keep it for header and main)
+		editorRect.Min.X -= 1
+		editorRect.Max.X += 1
+		layout.header = headerRect
+		layout.main = mainRect
+		layout.editor = editorRect
+
+	case uiChat:
+		if m.isCompact {
+			// Layout
+			//
+			// compact-header
+			// ------
+			// main
+			// ------
+			// editor
+			// ------
+			// help
+			const compactHeaderHeight = 1
+			headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(compactHeaderHeight))
+			detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
+			sessionDetailsArea, _ := uv.SplitVertical(appRect, uv.Fixed(detailsHeight))
+			layout.sessionDetails = sessionDetailsArea
+			layout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
+			// Add one line gap between header and main content
+			mainRect.Min.Y += 1
+			mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
+			mainRect.Max.X -= 1 // Add padding right
+			layout.header = headerRect
+			pillsHeight := m.pillsAreaHeight()
+			if pillsHeight > 0 {
+				pillsHeight = min(pillsHeight, mainRect.Dy())
+				chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
+				layout.main = chatRect
+				layout.pills = pillsRect
+			} else {
+				layout.main = mainRect
+			}
+			// Add bottom margin to main
+			layout.main.Max.Y -= 1
+			layout.editor = editorRect
+		} else {
+			// Layout
+			//
+			// ------|---
+			// main  |
+			// ------| side
+			// editor|
+			// ----------
+			// help
+
+			mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
+			// Add padding left
+			sideRect.Min.X += 1
+			mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
+			mainRect.Max.X -= 1 // Add padding right
+			layout.sidebar = sideRect
+			pillsHeight := m.pillsAreaHeight()
+			if pillsHeight > 0 {
+				pillsHeight = min(pillsHeight, mainRect.Dy())
+				chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
+				layout.main = chatRect
+				layout.pills = pillsRect
+			} else {
+				layout.main = mainRect
+			}
+			// Add bottom margin to main
+			layout.main.Max.Y -= 1
+			layout.editor = editorRect
+		}
+	}
+
+	if !layout.editor.Empty() {
+		// Add editor margins 1 top and bottom
+		layout.editor.Min.Y += 1
+		layout.editor.Max.Y -= 1
+	}
+
+	return layout
+}
+
+// layout defines the positioning of UI elements.
+type layout struct {
+	// area is the overall available area.
+	area uv.Rectangle
+
+	// header is the header shown in special cases
+	// e.x when the sidebar is collapsed
+	// or when in the landing page
+	// or in init/config
+	header uv.Rectangle
+
+	// main is the area for the main pane. (e.x chat, configure, landing)
+	main uv.Rectangle
+
+	// pills is the area for the pills panel.
+	pills uv.Rectangle
+
+	// editor is the area for the editor pane.
+	editor uv.Rectangle
+
+	// sidebar is the area for the sidebar.
+	sidebar uv.Rectangle
+
+	// status is the area for the status view.
+	status uv.Rectangle
+
+	// session details is the area for the session details overlay in compact mode.
+	sessionDetails uv.Rectangle
+}
+
+func (m *UI) openEditor(value string) tea.Cmd {
+	tmpfile, err := os.CreateTemp("", "msg_*.md")
+	if err != nil {
+		return uiutil.ReportError(err)
+	}
+	defer tmpfile.Close() //nolint:errcheck
+	if _, err := tmpfile.WriteString(value); err != nil {
+		return uiutil.ReportError(err)
+	}
+	cmd, err := editor.Command(
+		"crush",
+		tmpfile.Name(),
+		editor.AtPosition(
+			m.textarea.Line()+1,
+			m.textarea.Column()+1,
+		),
+	)
+	if err != nil {
+		return uiutil.ReportError(err)
+	}
+	return tea.ExecProcess(cmd, func(err error) tea.Msg {
+		if err != nil {
+			return uiutil.ReportError(err)
+		}
+		content, err := os.ReadFile(tmpfile.Name())
+		if err != nil {
+			return uiutil.ReportError(err)
+		}
+		if len(content) == 0 {
+			return uiutil.ReportWarn("Message is empty")
+		}
+		os.Remove(tmpfile.Name())
+		return openEditorMsg{
+			Text: strings.TrimSpace(string(content)),
+		}
+	})
+}
+
+// setEditorPrompt configures the textarea prompt function based on whether
+// yolo mode is enabled.
+func (m *UI) setEditorPrompt(yolo bool) {
+	if yolo {
+		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
+		return
+	}
+	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
+}
+
+// normalPromptFunc returns the normal editor prompt style ("  > " on first
+// line, "::: " on subsequent lines).
+func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
+	t := m.com.Styles
+	if info.LineNumber == 0 {
+		if info.Focused {
+			return "  > "
+		}
+		return "::: "
+	}
+	if info.Focused {
+		return t.EditorPromptNormalFocused.Render()
+	}
+	return t.EditorPromptNormalBlurred.Render()
+}
+
+// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
+// and colored dots.
+func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
+	t := m.com.Styles
+	if info.LineNumber == 0 {
+		if info.Focused {
+			return t.EditorPromptYoloIconFocused.Render()
+		} else {
+			return t.EditorPromptYoloIconBlurred.Render()
+		}
+	}
+	if info.Focused {
+		return t.EditorPromptYoloDotsFocused.Render()
+	}
+	return t.EditorPromptYoloDotsBlurred.Render()
+}
+
+// closeCompletions closes the completions popup and resets state.
+func (m *UI) closeCompletions() {
+	m.completionsOpen = false
+	m.completionsQuery = ""
+	m.completionsStartIndex = 0
+	m.completions.Close()
+}
+
+// insertFileCompletion inserts the selected file path into the textarea,
+// replacing the @query, and adds the file as an attachment.
+func (m *UI) insertFileCompletion(path string) tea.Cmd {
+	value := m.textarea.Value()
+	word := m.textareaWord()
+
+	// Find the @ and query to replace.
+	if m.completionsStartIndex > len(value) {
+		return nil
+	}
+
+	// Build the new value: everything before @, the path, everything after query.
+	endIdx := min(m.completionsStartIndex+len(word), len(value))
+
+	newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
+	m.textarea.SetValue(newValue)
+	m.textarea.MoveToEnd()
+	m.textarea.InsertRune(' ')
+
+	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
+			}
+		}
+
+		// 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,
+			FileName: filepath.Base(path),
+			MimeType: mimeOf(content),
+			Content:  content,
+		}
+	}
+}
+
+// completionsPosition returns the X and Y position for the completions popup.
+func (m *UI) completionsPosition() image.Point {
+	cur := m.textarea.Cursor()
+	if cur == nil {
+		return image.Point{
+			X: m.layout.editor.Min.X,
+			Y: m.layout.editor.Min.Y,
+		}
+	}
+	return image.Point{
+		X: cur.X + m.layout.editor.Min.X,
+		Y: m.layout.editor.Min.Y + cur.Y,
+	}
+}
+
+// textareaWord returns the current word at the cursor position.
+func (m *UI) textareaWord() string {
+	return m.textarea.Word()
+}
+
+// isWhitespace returns true if the byte is a whitespace character.
+func isWhitespace(b byte) bool {
+	return b == ' ' || b == '\t' || b == '\n' || b == '\r'
+}
+
+// isAgentBusy returns true if the agent coordinator exists and is currently
+// busy processing a request.
+func (m *UI) isAgentBusy() bool {
+	return m.com.App != nil &&
+		m.com.App.AgentCoordinator != nil &&
+		m.com.App.AgentCoordinator.IsBusy()
+}
+
+// hasSession returns true if there is an active session with a valid ID.
+func (m *UI) hasSession() bool {
+	return m.session != nil && m.session.ID != ""
+}
+
+// mimeOf detects the MIME type of the given content.
+func mimeOf(content []byte) string {
+	mimeBufferSize := min(512, len(content))
+	return http.DetectContentType(content[:mimeBufferSize])
+}
+
+var readyPlaceholders = [...]string{
+	"Ready!",
+	"Ready...",
+	"Ready?",
+	"Ready for instructions",
+}
+
+var workingPlaceholders = [...]string{
+	"Working!",
+	"Working...",
+	"Brrrrr...",
+	"Prrrrrrrr...",
+	"Processing...",
+	"Thinking...",
+}
+
+// randomizePlaceholders selects random placeholder text for the textarea's
+// ready and working states.
+func (m *UI) randomizePlaceholders() {
+	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
+	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
+}
+
+// renderEditorView renders the editor view with attachments if any.
+func (m *UI) renderEditorView(width int) string {
+	if len(m.attachments.List()) == 0 {
+		return m.textarea.View()
+	}
+	return lipgloss.JoinVertical(
+		lipgloss.Top,
+		m.attachments.Render(width),
+		m.textarea.View(),
+	)
+}
+
+// renderHeader renders and caches the header logo at the specified width.
+func (m *UI) renderHeader(compact bool, width int) {
+	if compact && m.session != nil && m.com.App != nil {
+		m.header = renderCompactHeader(m.com, m.session, m.com.App.LSPClients, m.detailsOpen, width)
+	} else {
+		m.header = renderLogo(m.com.Styles, compact, width)
+	}
+}
+
+// renderSidebarLogo renders and caches the sidebar logo at the specified
+// width.
+func (m *UI) renderSidebarLogo(width int) {
+	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
+}
+
+// sendMessage sends a message with the given content and attachments.
+func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
+	if m.com.App.AgentCoordinator == nil {
+		return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
+	}
+
+	var cmds []tea.Cmd
+	if !m.hasSession() {
+		newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
+		if err != nil {
+			return uiutil.ReportError(err)
+		}
+		m.state = uiChat
+		if m.forceCompactMode {
+			m.isCompact = true
+		}
+		if newSession.ID != "" {
+			m.session = &newSession
+			cmds = append(cmds, m.loadSession(newSession.ID))
+		}
+	}
+
+	// Capture session ID to avoid race with main goroutine updating m.session.
+	sessionID := m.session.ID
+	cmds = append(cmds, func() tea.Msg {
+		_, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
+		if err != nil {
+			isCancelErr := errors.Is(err, context.Canceled)
+			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
+			if isCancelErr || isPermissionErr {
+				return nil
+			}
+			return uiutil.InfoMsg{
+				Type: uiutil.InfoTypeError,
+				Msg:  err.Error(),
+			}
+		}
+		return nil
+	})
+	return tea.Batch(cmds...)
+}
+
+const cancelTimerDuration = 2 * time.Second
+
+// cancelTimerCmd creates a command that expires the cancel timer.
+func cancelTimerCmd() tea.Cmd {
+	return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
+		return cancelTimerExpiredMsg{}
+	})
+}
+
+// cancelAgent handles the cancel key press. The first press sets isCanceling to true
+// and starts a timer. The second press (before the timer expires) actually
+// cancels the agent.
+func (m *UI) cancelAgent() tea.Cmd {
+	if !m.hasSession() {
+		return nil
+	}
+
+	coordinator := m.com.App.AgentCoordinator
+	if coordinator == nil {
+		return nil
+	}
+
+	if m.isCanceling {
+		// Second escape press - actually cancel the agent.
+		m.isCanceling = false
+		coordinator.Cancel(m.session.ID)
+		// Stop the spinning todo indicator.
+		m.todoIsSpinning = false
+		m.renderPills()
+		return nil
+	}
+
+	// Check if there are queued prompts - if so, clear the queue.
+	if coordinator.QueuedPrompts(m.session.ID) > 0 {
+		coordinator.ClearQueue(m.session.ID)
+		return nil
+	}
+
+	// First escape press - set canceling state and start timer.
+	m.isCanceling = true
+	return cancelTimerCmd()
+}
+
+// openDialog opens a dialog by its ID.
+func (m *UI) openDialog(id string) tea.Cmd {
+	var cmds []tea.Cmd
+	switch id {
+	case dialog.SessionsID:
+		if cmd := m.openSessionsDialog(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case dialog.ModelsID:
+		if cmd := m.openModelsDialog(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case dialog.CommandsID:
+		if cmd := m.openCommandsDialog(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case dialog.ReasoningID:
+		if cmd := m.openReasoningDialog(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case dialog.QuitID:
+		if cmd := m.openQuitDialog(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	default:
+		// Unknown dialog
+		break
+	}
+	return tea.Batch(cmds...)
+}
+
+// openQuitDialog opens the quit confirmation dialog.
+func (m *UI) openQuitDialog() tea.Cmd {
+	if m.dialog.ContainsDialog(dialog.QuitID) {
+		// Bring to front
+		m.dialog.BringToFront(dialog.QuitID)
+		return nil
+	}
+
+	quitDialog := dialog.NewQuit(m.com)
+	m.dialog.OpenDialog(quitDialog)
+	return nil
+}
+
+// openModelsDialog opens the models dialog.
+func (m *UI) openModelsDialog() tea.Cmd {
+	if m.dialog.ContainsDialog(dialog.ModelsID) {
+		// Bring to front
+		m.dialog.BringToFront(dialog.ModelsID)
+		return nil
+	}
+
+	modelsDialog, err := dialog.NewModels(m.com)
+	if err != nil {
+		return uiutil.ReportError(err)
+	}
+
+	m.dialog.OpenDialog(modelsDialog)
+
+	return nil
+}
+
+// openCommandsDialog opens the commands dialog.
+func (m *UI) openCommandsDialog() tea.Cmd {
+	if m.dialog.ContainsDialog(dialog.CommandsID) {
+		// Bring to front
+		m.dialog.BringToFront(dialog.CommandsID)
+		return nil
+	}
+
+	sessionID := ""
+	if m.session != nil {
+		sessionID = m.session.ID
+	}
+
+	commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts)
+	if err != nil {
+		return uiutil.ReportError(err)
+	}
+
+	m.dialog.OpenDialog(commands)
+
+	return nil
+}
+
+// openReasoningDialog opens the reasoning effort dialog.
+func (m *UI) openReasoningDialog() tea.Cmd {
+	if m.dialog.ContainsDialog(dialog.ReasoningID) {
+		m.dialog.BringToFront(dialog.ReasoningID)
+		return nil
+	}
+
+	reasoningDialog, err := dialog.NewReasoning(m.com)
+	if err != nil {
+		return uiutil.ReportError(err)
+	}
+
+	m.dialog.OpenDialog(reasoningDialog)
+	return nil
+}
+
+// openSessionsDialog opens the sessions dialog. If the dialog is already open,
+// it brings it to the front. Otherwise, it will list all the sessions and open
+// the dialog.
+func (m *UI) openSessionsDialog() tea.Cmd {
+	if m.dialog.ContainsDialog(dialog.SessionsID) {
+		// Bring to front
+		m.dialog.BringToFront(dialog.SessionsID)
+		return nil
+	}
+
+	selectedSessionID := ""
+	if m.session != nil {
+		selectedSessionID = m.session.ID
+	}
+
+	dialog, err := dialog.NewSessions(m.com, selectedSessionID)
+	if err != nil {
+		return uiutil.ReportError(err)
+	}
+
+	m.dialog.OpenDialog(dialog)
+	return nil
+}
+
+// openFilesDialog opens the file picker dialog.
+func (m *UI) openFilesDialog() tea.Cmd {
+	if m.dialog.ContainsDialog(dialog.FilePickerID) {
+		// Bring to front
+		m.dialog.BringToFront(dialog.FilePickerID)
+		return nil
+	}
+
+	filePicker, cmd := dialog.NewFilePicker(m.com)
+	filePicker.SetImageCapabilities(&m.imgCaps)
+	m.dialog.OpenDialog(filePicker)
+
+	return cmd
+}
+
+// openPermissionsDialog opens the permissions dialog for a permission request.
+func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
+	// Close any existing permissions dialog first.
+	m.dialog.CloseDialog(dialog.PermissionsID)
+
+	// Get diff mode from config.
+	var opts []dialog.PermissionsOption
+	if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
+		opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
+	}
+
+	permDialog := dialog.NewPermissions(m.com, perm, opts...)
+	m.dialog.OpenDialog(permDialog)
+	return nil
+}
+
+// handlePermissionNotification updates tool items when permission state changes.
+func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
+	toolItem := m.chat.MessageItem(notification.ToolCallID)
+	if toolItem == nil {
+		return
+	}
+
+	if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
+		if notification.Granted {
+			permItem.SetStatus(chat.ToolStatusRunning)
+		} else {
+			permItem.SetStatus(chat.ToolStatusAwaitingPermission)
+		}
+	}
+}
+
+// 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() {
+	if !m.hasSession() {
+		return
+	}
+
+	m.session = nil
+	m.sessionFiles = nil
+	m.state = uiLanding
+	m.focus = uiFocusEditor
+	m.textarea.Focus()
+	m.chat.Blur()
+	m.chat.ClearMessages()
+	m.pillsExpanded = false
+	m.promptQueue = 0
+	m.pillsView = ""
+}
+
+// handlePasteMsg handles a paste message.
+func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
+	if m.dialog.HasDialogs() {
+		return m.handleDialogMsg(msg)
+	}
+
+	if m.focus != uiFocusEditor {
+		return nil
+	}
+
+	if strings.Count(msg.Content, "\n") > pasteLinesThreshold {
+		return func() tea.Msg {
+			content := []byte(msg.Content)
+			if int64(len(content)) > common.MaxAttachmentSize {
+				return uiutil.ReportWarn("Paste is too big (>5mb)")
+			}
+			name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
+			mimeBufferSize := min(512, len(content))
+			mimeType := http.DetectContentType(content[:mimeBufferSize])
+			return message.Attachment{
+				FileName: name,
+				FilePath: name,
+				MimeType: mimeType,
+				Content:  content,
+			}
+		}
+	}
+
+	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
+	}
+
+	// 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
+		}
+	}
+	if !isAllowedType {
+		m.textarea, cmd = m.textarea.Update(msg)
+		return cmd
+	}
+
+	return func() tea.Msg {
+		fileInfo, err := os.Stat(path)
+		if err != nil {
+			return uiutil.ReportError(err)
+		}
+		if fileInfo.Size() > common.MaxAttachmentSize {
+			return uiutil.ReportWarn("File is too big (>5mb)")
+		}
+
+		content, err := os.ReadFile(path)
+		if err != nil {
+			return uiutil.ReportError(err)
+		}
+
+		mimeBufferSize := min(512, len(content))
+		mimeType := http.DetectContentType(content[:mimeBufferSize])
+		fileName := filepath.Base(path)
+		return message.Attachment{
+			FilePath: path,
+			FileName: fileName,
+			MimeType: mimeType,
+			Content:  content,
+		}
+	}
+}
+
+var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
+
+func (m *UI) pasteIdx() int {
+	result := 0
+	for _, at := range m.attachments.List() {
+		found := pasteRE.FindStringSubmatch(at.FileName)
+		if len(found) == 0 {
+			continue
+		}
+		idx, err := strconv.Atoi(found[1])
+		if err == nil {
+			result = max(result, idx)
+		}
+	}
+	return result + 1
+}
+
+// drawSessionDetails draws the session details in compact mode.
+func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
+	if m.session == nil {
+		return
+	}
+
+	s := m.com.Styles
+
+	width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
+	height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
+
+	title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
+	blocks := []string{
+		title,
+		"",
+		m.modelInfo(width),
+		"",
+	}
+
+	detailsHeader := lipgloss.JoinVertical(
+		lipgloss.Left,
+		blocks...,
+	)
+
+	version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
+
+	remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
+
+	const maxSectionWidth = 50
+	sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
+	maxItemsPerSection := remainingHeight - 3       // Account for section title and spacing
+
+	lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
+	mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
+	filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false)
+	sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
+	uv.NewStyledString(
+		s.CompactDetails.View.
+			Width(area.Dx()).
+			Render(
+				lipgloss.JoinVertical(
+					lipgloss.Left,
+					detailsHeader,
+					sections,
+					version,
+				),
+			),
+	).Draw(scr, area)
+}
+
+func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
+	load := func() tea.Msg {
+		prompt, err := commands.GetMCPPrompt(clientID, promptID, arguments)
+		if err != nil {
+			// TODO: make this better
+			return uiutil.ReportError(err)()
+		}
+
+		if prompt == "" {
+			return nil
+		}
+		return sendMessageMsg{
+			Content: prompt,
+		}
+	}
+
+	var cmds []tea.Cmd
+	if cmd := m.dialog.StartLoading(); cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+	cmds = append(cmds, load, func() tea.Msg {
+		return closeDialogMsg{}
+	})
+
+	return tea.Sequence(cmds...)
+}
+
+func (m *UI) copyChatHighlight() tea.Cmd {
+	text := m.chat.HighlightContent()
+	return tea.Sequence(
+		tea.SetClipboard(text),
+		func() tea.Msg {
+			_ = clipboard.WriteAll(text)
+			return nil
+		},
+		func() tea.Msg {
+			m.chat.ClearMouse()
+			return nil
+		},
+		uiutil.ReportInfo("Selected text copied to clipboard"),
+	)
+}
+
+// renderLogo renders the Crush logo with the given styles and dimensions.
+func renderLogo(t *styles.Styles, compact bool, width int) string {
+	return logo.Render(version.Version, compact, logo.Opts{
+		FieldColor:   t.LogoFieldColor,
+		TitleColorA:  t.LogoTitleColorA,
+		TitleColorB:  t.LogoTitleColorB,
+		CharmColor:   t.LogoCharmColor,
+		VersionColor: t.LogoVersionColor,
+		Width:        width,
+	})
+}

+ 117 - 0
internal/ui/styles/grad.go

@@ -0,0 +1,117 @@
+package styles
+
+import (
+	"fmt"
+	"image/color"
+	"strings"
+
+	"github.com/lucasb-eyer/go-colorful"
+	"github.com/rivo/uniseg"
+)
+
+// ForegroundGrad returns a slice of strings representing the input string
+// rendered with a horizontal gradient foreground from color1 to color2. Each
+// string in the returned slice corresponds to a grapheme cluster in the input
+// string. If bold is true, the rendered strings will be bolded.
+func ForegroundGrad(t *Styles, input string, bold bool, color1, color2 color.Color) []string {
+	if input == "" {
+		return []string{""}
+	}
+	if len(input) == 1 {
+		style := t.Base.Foreground(color1)
+		if bold {
+			style.Bold(true)
+		}
+		return []string{style.Render(input)}
+	}
+	var clusters []string
+	gr := uniseg.NewGraphemes(input)
+	for gr.Next() {
+		clusters = append(clusters, string(gr.Runes()))
+	}
+
+	ramp := blendColors(len(clusters), color1, color2)
+	for i, c := range ramp {
+		style := t.Base.Foreground(c)
+		if bold {
+			style.Bold(true)
+		}
+		clusters[i] = style.Render(clusters[i])
+	}
+	return clusters
+}
+
+// ApplyForegroundGrad renders a given string with a horizontal gradient
+// foreground.
+func ApplyForegroundGrad(t *Styles, input string, color1, color2 color.Color) string {
+	if input == "" {
+		return ""
+	}
+	var o strings.Builder
+	clusters := ForegroundGrad(t, input, false, color1, color2)
+	for _, c := range clusters {
+		fmt.Fprint(&o, c)
+	}
+	return o.String()
+}
+
+// ApplyBoldForegroundGrad renders a given string with a horizontal gradient
+// foreground.
+func ApplyBoldForegroundGrad(t *Styles, input string, color1, color2 color.Color) string {
+	if input == "" {
+		return ""
+	}
+	var o strings.Builder
+	clusters := ForegroundGrad(t, input, true, color1, color2)
+	for _, c := range clusters {
+		fmt.Fprint(&o, c)
+	}
+	return o.String()
+}
+
+// blendColors returns a slice of colors blended between the given keys.
+// Blending is done in Hcl to stay in gamut.
+func blendColors(size int, stops ...color.Color) []color.Color {
+	if len(stops) < 2 {
+		return nil
+	}
+
+	stopsPrime := make([]colorful.Color, len(stops))
+	for i, k := range stops {
+		stopsPrime[i], _ = colorful.MakeColor(k)
+	}
+
+	numSegments := len(stopsPrime) - 1
+	blended := make([]color.Color, 0, size)
+
+	// Calculate how many colors each segment should have.
+	segmentSizes := make([]int, numSegments)
+	baseSize := size / numSegments
+	remainder := size % numSegments
+
+	// Distribute the remainder across segments.
+	for i := range numSegments {
+		segmentSizes[i] = baseSize
+		if i < remainder {
+			segmentSizes[i]++
+		}
+	}
+
+	// Generate colors for each segment.
+	for i := range numSegments {
+		c1 := stopsPrime[i]
+		c2 := stopsPrime[i+1]
+		segmentSize := segmentSizes[i]
+
+		for j := range segmentSize {
+			var t float64
+			if segmentSize > 1 {
+				t = float64(j) / float64(segmentSize-1)
+			}
+			c := c1.BlendHcl(c2, t)
+			blended = append(blended, c)
+		}
+	}
+
+	return blended
+}

+ 1344 - 0
internal/ui/styles/styles.go

@@ -0,0 +1,1344 @@
+package styles
+
+import (
+	"image/color"
+
+	"charm.land/bubbles/v2/filepicker"
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/textarea"
+	"charm.land/bubbles/v2/textinput"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/glamour/v2/ansi"
+	"charm.land/lipgloss/v2"
+	"github.com/alecthomas/chroma/v2"
+	"github.com/charmbracelet/crush/internal/tui/exp/diffview"
+	"github.com/charmbracelet/x/exp/charmtone"
+)
+
+const (
+	CheckIcon   string = "✓"
+	ErrorIcon   string = "×"
+	WarningIcon string = "⚠"
+	InfoIcon    string = "ⓘ"
+	HintIcon    string = "∵"
+	SpinnerIcon string = "⋯"
+	LoadingIcon string = "⟳"
+	ModelIcon   string = "◇"
+
+	ArrowRightIcon string = "→"
+
+	ToolPending string = "●"
+	ToolSuccess string = "✓"
+	ToolError   string = "×"
+
+	RadioOn  string = "◉"
+	RadioOff string = "○"
+
+	BorderThin  string = "│"
+	BorderThick string = "▌"
+
+	SectionSeparator string = "─"
+
+	TodoCompletedIcon  string = "✓"
+	TodoPendingIcon    string = "•"
+	TodoInProgressIcon string = "→"
+
+	ImageIcon string = "■"
+	TextIcon  string = "≡"
+
+	ScrollbarThumb string = "┃"
+	ScrollbarTrack string = "│"
+)
+
+const (
+	defaultMargin     = 2
+	defaultListIndent = 2
+)
+
+type Styles struct {
+	WindowTooSmall lipgloss.Style
+
+	// Reusable text styles
+	Base      lipgloss.Style
+	Muted     lipgloss.Style
+	HalfMuted lipgloss.Style
+	Subtle    lipgloss.Style
+
+	// Tags
+	TagBase  lipgloss.Style
+	TagError lipgloss.Style
+	TagInfo  lipgloss.Style
+
+	// Header
+	Header struct {
+		Charm        lipgloss.Style // Style for "Charm™" label
+		Diagonals    lipgloss.Style // Style for diagonal separators (╱)
+		Percentage   lipgloss.Style // Style for context percentage
+		Keystroke    lipgloss.Style // Style for keystroke hints (e.g., "ctrl+d")
+		KeystrokeTip lipgloss.Style // Style for keystroke action text (e.g., "open", "close")
+		WorkingDir   lipgloss.Style // Style for current working directory
+		Separator    lipgloss.Style // Style for separator dots (•)
+	}
+
+	CompactDetails struct {
+		View    lipgloss.Style
+		Version lipgloss.Style
+		Title   lipgloss.Style
+	}
+
+	// Panels
+	PanelMuted lipgloss.Style
+	PanelBase  lipgloss.Style
+
+	// Line numbers for code blocks
+	LineNumber lipgloss.Style
+
+	// Message borders
+	FocusedMessageBorder lipgloss.Border
+
+	// Tool calls
+	ToolCallPending   lipgloss.Style
+	ToolCallError     lipgloss.Style
+	ToolCallSuccess   lipgloss.Style
+	ToolCallCancelled lipgloss.Style
+	EarlyStateMessage lipgloss.Style
+
+	// Text selection
+	TextSelection lipgloss.Style
+
+	// LSP and MCP status indicators
+	ItemOfflineIcon lipgloss.Style
+	ItemBusyIcon    lipgloss.Style
+	ItemErrorIcon   lipgloss.Style
+	ItemOnlineIcon  lipgloss.Style
+
+	// Markdown & Chroma
+	Markdown      ansi.StyleConfig
+	PlainMarkdown ansi.StyleConfig
+
+	// Inputs
+	TextInput textinput.Styles
+	TextArea  textarea.Styles
+
+	// Help
+	Help help.Styles
+
+	// Diff
+	Diff diffview.Style
+
+	// FilePicker
+	FilePicker filepicker.Styles
+
+	// Buttons
+	ButtonFocus lipgloss.Style
+	ButtonBlur  lipgloss.Style
+
+	// Borders
+	BorderFocus lipgloss.Style
+	BorderBlur  lipgloss.Style
+
+	// Editor
+	EditorPromptNormalFocused   lipgloss.Style
+	EditorPromptNormalBlurred   lipgloss.Style
+	EditorPromptYoloIconFocused lipgloss.Style
+	EditorPromptYoloIconBlurred lipgloss.Style
+	EditorPromptYoloDotsFocused lipgloss.Style
+	EditorPromptYoloDotsBlurred lipgloss.Style
+
+	// Radio
+	RadioOn  lipgloss.Style
+	RadioOff lipgloss.Style
+
+	// Background
+	Background color.Color
+
+	// Logo
+	LogoFieldColor   color.Color
+	LogoTitleColorA  color.Color
+	LogoTitleColorB  color.Color
+	LogoCharmColor   color.Color
+	LogoVersionColor color.Color
+
+	// Colors - semantic colors for tool rendering.
+	Primary       color.Color
+	Secondary     color.Color
+	Tertiary      color.Color
+	BgBase        color.Color
+	BgBaseLighter color.Color
+	BgSubtle      color.Color
+	BgOverlay     color.Color
+	FgBase        color.Color
+	FgMuted       color.Color
+	FgHalfMuted   color.Color
+	FgSubtle      color.Color
+	Border        color.Color
+	BorderColor   color.Color // Border focus color
+	Error         color.Color
+	Warning       color.Color
+	Info          color.Color
+	White         color.Color
+	BlueLight     color.Color
+	Blue          color.Color
+	BlueDark      color.Color
+	GreenLight    color.Color
+	Green         color.Color
+	GreenDark     color.Color
+	Red           color.Color
+	RedDark       color.Color
+	Yellow        color.Color
+
+	// Section Title
+	Section struct {
+		Title lipgloss.Style
+		Line  lipgloss.Style
+	}
+
+	// Initialize
+	Initialize struct {
+		Header  lipgloss.Style
+		Content lipgloss.Style
+		Accent  lipgloss.Style
+	}
+
+	// LSP
+	LSP struct {
+		ErrorDiagnostic   lipgloss.Style
+		WarningDiagnostic lipgloss.Style
+		HintDiagnostic    lipgloss.Style
+		InfoDiagnostic    lipgloss.Style
+	}
+
+	// Files
+	Files struct {
+		Path      lipgloss.Style
+		Additions lipgloss.Style
+		Deletions lipgloss.Style
+	}
+
+	// Chat
+	Chat struct {
+		// Message item styles
+		Message struct {
+			UserBlurred      lipgloss.Style
+			UserFocused      lipgloss.Style
+			AssistantBlurred lipgloss.Style
+			AssistantFocused lipgloss.Style
+			NoContent        lipgloss.Style
+			Thinking         lipgloss.Style
+			ErrorTag         lipgloss.Style
+			ErrorTitle       lipgloss.Style
+			ErrorDetails     lipgloss.Style
+			ToolCallFocused  lipgloss.Style
+			ToolCallCompact  lipgloss.Style
+			ToolCallBlurred  lipgloss.Style
+			SectionHeader    lipgloss.Style
+
+			// Thinking section styles
+			ThinkingBox            lipgloss.Style // Background for thinking content
+			ThinkingTruncationHint lipgloss.Style // "… (N lines hidden)" hint
+			ThinkingFooterTitle    lipgloss.Style // "Thought for" text
+			ThinkingFooterDuration lipgloss.Style // Duration value
+			AssistantInfoIcon      lipgloss.Style
+			AssistantInfoModel     lipgloss.Style
+			AssistantInfoProvider  lipgloss.Style
+			AssistantInfoDuration  lipgloss.Style
+		}
+	}
+
+	// Tool - styles for tool call rendering
+	Tool struct {
+		// Icon styles with tool status
+		IconPending   lipgloss.Style // Pending operation icon
+		IconSuccess   lipgloss.Style // Successful operation icon
+		IconError     lipgloss.Style // Error operation icon
+		IconCancelled lipgloss.Style // Cancelled operation icon
+
+		// Tool name styles
+		NameNormal lipgloss.Style // Normal tool name
+		NameNested lipgloss.Style // Nested tool name
+
+		// Parameter list styles
+		ParamMain lipgloss.Style // Main parameter
+		ParamKey  lipgloss.Style // Parameter keys
+
+		// Content rendering styles
+		ContentLine           lipgloss.Style // Individual content line with background and width
+		ContentTruncation     lipgloss.Style // Truncation message "… (N lines)"
+		ContentCodeLine       lipgloss.Style // Code line with background and width
+		ContentCodeTruncation lipgloss.Style // Code truncation message with bgBase
+		ContentCodeBg         color.Color    // Background color for syntax highlighting
+		Body                  lipgloss.Style // Body content padding (PaddingLeft(2))
+
+		// Deprecated - kept for backward compatibility
+		ContentBg         lipgloss.Style // Content background
+		ContentText       lipgloss.Style // Content text
+		ContentLineNumber lipgloss.Style // Line numbers in code
+
+		// State message styles
+		StateWaiting   lipgloss.Style // "Waiting for tool response..."
+		StateCancelled lipgloss.Style // "Canceled."
+
+		// Error styles
+		ErrorTag     lipgloss.Style // ERROR tag
+		ErrorMessage lipgloss.Style // Error message text
+
+		// Diff styles
+		DiffTruncation lipgloss.Style // Diff truncation message with padding
+
+		// Multi-edit note styles
+		NoteTag     lipgloss.Style // NOTE tag (yellow background)
+		NoteMessage lipgloss.Style // Note message text
+
+		// Job header styles (for bash jobs)
+		JobIconPending lipgloss.Style // Pending job icon (green dark)
+		JobIconError   lipgloss.Style // Error job icon (red dark)
+		JobIconSuccess lipgloss.Style // Success job icon (green)
+		JobToolName    lipgloss.Style // Job tool name "Bash" (blue)
+		JobAction      lipgloss.Style // Action text (Start, Output, Kill)
+		JobPID         lipgloss.Style // PID text
+		JobDescription lipgloss.Style // Description text
+
+		// Agent task styles
+		AgentTaskTag lipgloss.Style // Agent task tag (blue background, bold)
+		AgentPrompt  lipgloss.Style // Agent prompt text
+
+		// Agentic fetch styles
+		AgenticFetchPromptTag lipgloss.Style // Agentic fetch prompt tag (green background, bold)
+
+		// Todo styles
+		TodoRatio          lipgloss.Style // Todo ratio (e.g., "2/5")
+		TodoCompletedIcon  lipgloss.Style // Completed todo icon
+		TodoInProgressIcon lipgloss.Style // In-progress todo icon
+		TodoPendingIcon    lipgloss.Style // Pending todo icon
+
+		// MCP tools
+		MCPName     lipgloss.Style // The mcp name
+		MCPToolName lipgloss.Style // The mcp tool name
+		MCPArrow    lipgloss.Style // The mcp arrow icon
+	}
+
+	// Dialog styles
+	Dialog struct {
+		Title       lipgloss.Style
+		TitleText   lipgloss.Style
+		TitleError  lipgloss.Style
+		TitleAccent lipgloss.Style
+		// View is the main content area style.
+		View          lipgloss.Style
+		PrimaryText   lipgloss.Style
+		SecondaryText lipgloss.Style
+		// HelpView is the line that contains the help.
+		HelpView lipgloss.Style
+		Help     struct {
+			Ellipsis       lipgloss.Style
+			ShortKey       lipgloss.Style
+			ShortDesc      lipgloss.Style
+			ShortSeparator lipgloss.Style
+			FullKey        lipgloss.Style
+			FullDesc       lipgloss.Style
+			FullSeparator  lipgloss.Style
+		}
+		NormalItem   lipgloss.Style
+		SelectedItem lipgloss.Style
+		InputPrompt  lipgloss.Style
+
+		List lipgloss.Style
+
+		Spinner lipgloss.Style
+
+		// ContentPanel is used for content blocks with subtle background.
+		ContentPanel lipgloss.Style
+
+		// Scrollbar styles for scrollable content.
+		ScrollbarThumb lipgloss.Style
+		ScrollbarTrack lipgloss.Style
+
+		// Arguments
+		Arguments struct {
+			Content                  lipgloss.Style
+			Description              lipgloss.Style
+			InputLabelBlurred        lipgloss.Style
+			InputLabelFocused        lipgloss.Style
+			InputRequiredMarkBlurred lipgloss.Style
+			InputRequiredMarkFocused lipgloss.Style
+		}
+
+		Commands struct{}
+
+		ImagePreview lipgloss.Style
+	}
+
+	// Status bar and help
+	Status struct {
+		Help lipgloss.Style
+
+		ErrorIndicator   lipgloss.Style
+		WarnIndicator    lipgloss.Style
+		InfoIndicator    lipgloss.Style
+		UpdateIndicator  lipgloss.Style
+		SuccessIndicator lipgloss.Style
+
+		ErrorMessage   lipgloss.Style
+		WarnMessage    lipgloss.Style
+		InfoMessage    lipgloss.Style
+		UpdateMessage  lipgloss.Style
+		SuccessMessage lipgloss.Style
+	}
+
+	// Completions popup styles
+	Completions struct {
+		Normal  lipgloss.Style
+		Focused lipgloss.Style
+		Match   lipgloss.Style
+	}
+
+	// Attachments styles
+	Attachments struct {
+		Normal   lipgloss.Style
+		Image    lipgloss.Style
+		Text     lipgloss.Style
+		Deleting lipgloss.Style
+	}
+
+	// Pills styles for todo/queue pills
+	Pills struct {
+		Base            lipgloss.Style // Base pill style with padding
+		Focused         lipgloss.Style // Focused pill with visible border
+		Blurred         lipgloss.Style // Blurred pill with hidden border
+		QueueItemPrefix lipgloss.Style // Prefix for queue list items
+		HelpKey         lipgloss.Style // Keystroke hint style
+		HelpText        lipgloss.Style // Help action text style
+		Area            lipgloss.Style // Pills area container
+		TodoSpinner     lipgloss.Style // Todo spinner style
+	}
+}
+
+// ChromaTheme converts the current markdown chroma styles to a chroma
+// StyleEntries map.
+func (s *Styles) ChromaTheme() chroma.StyleEntries {
+	rules := s.Markdown.CodeBlock
+
+	return chroma.StyleEntries{
+		chroma.Text:                chromaStyle(rules.Chroma.Text),
+		chroma.Error:               chromaStyle(rules.Chroma.Error),
+		chroma.Comment:             chromaStyle(rules.Chroma.Comment),
+		chroma.CommentPreproc:      chromaStyle(rules.Chroma.CommentPreproc),
+		chroma.Keyword:             chromaStyle(rules.Chroma.Keyword),
+		chroma.KeywordReserved:     chromaStyle(rules.Chroma.KeywordReserved),
+		chroma.KeywordNamespace:    chromaStyle(rules.Chroma.KeywordNamespace),
+		chroma.KeywordType:         chromaStyle(rules.Chroma.KeywordType),
+		chroma.Operator:            chromaStyle(rules.Chroma.Operator),
+		chroma.Punctuation:         chromaStyle(rules.Chroma.Punctuation),
+		chroma.Name:                chromaStyle(rules.Chroma.Name),
+		chroma.NameBuiltin:         chromaStyle(rules.Chroma.NameBuiltin),
+		chroma.NameTag:             chromaStyle(rules.Chroma.NameTag),
+		chroma.NameAttribute:       chromaStyle(rules.Chroma.NameAttribute),
+		chroma.NameClass:           chromaStyle(rules.Chroma.NameClass),
+		chroma.NameConstant:        chromaStyle(rules.Chroma.NameConstant),
+		chroma.NameDecorator:       chromaStyle(rules.Chroma.NameDecorator),
+		chroma.NameException:       chromaStyle(rules.Chroma.NameException),
+		chroma.NameFunction:        chromaStyle(rules.Chroma.NameFunction),
+		chroma.NameOther:           chromaStyle(rules.Chroma.NameOther),
+		chroma.Literal:             chromaStyle(rules.Chroma.Literal),
+		chroma.LiteralNumber:       chromaStyle(rules.Chroma.LiteralNumber),
+		chroma.LiteralDate:         chromaStyle(rules.Chroma.LiteralDate),
+		chroma.LiteralString:       chromaStyle(rules.Chroma.LiteralString),
+		chroma.LiteralStringEscape: chromaStyle(rules.Chroma.LiteralStringEscape),
+		chroma.GenericDeleted:      chromaStyle(rules.Chroma.GenericDeleted),
+		chroma.GenericEmph:         chromaStyle(rules.Chroma.GenericEmph),
+		chroma.GenericInserted:     chromaStyle(rules.Chroma.GenericInserted),
+		chroma.GenericStrong:       chromaStyle(rules.Chroma.GenericStrong),
+		chroma.GenericSubheading:   chromaStyle(rules.Chroma.GenericSubheading),
+		chroma.Background:          chromaStyle(rules.Chroma.Background),
+	}
+}
+
+// DialogHelpStyles returns the styles for dialog help.
+func (s *Styles) DialogHelpStyles() help.Styles {
+	return help.Styles(s.Dialog.Help)
+}
+
+// DefaultStyles returns the default styles for the UI.
+func DefaultStyles() Styles {
+	var (
+		primary   = charmtone.Charple
+		secondary = charmtone.Dolly
+		tertiary  = charmtone.Bok
+		// accent    = charmtone.Zest
+
+		// Backgrounds
+		bgBase        = charmtone.Pepper
+		bgBaseLighter = charmtone.BBQ
+		bgSubtle      = charmtone.Charcoal
+		bgOverlay     = charmtone.Iron
+
+		// Foregrounds
+		fgBase      = charmtone.Ash
+		fgMuted     = charmtone.Squid
+		fgHalfMuted = charmtone.Smoke
+		fgSubtle    = charmtone.Oyster
+		// fgSelected  = charmtone.Salt
+
+		// Borders
+		border      = charmtone.Charcoal
+		borderFocus = charmtone.Charple
+
+		// Status
+		error   = charmtone.Sriracha
+		warning = charmtone.Zest
+		info    = charmtone.Malibu
+
+		// Colors
+		white = charmtone.Butter
+
+		blueLight = charmtone.Sardine
+		blue      = charmtone.Malibu
+		blueDark  = charmtone.Damson
+
+		// yellow = charmtone.Mustard
+		yellow = charmtone.Mustard
+		// citron = charmtone.Citron
+
+		greenLight = charmtone.Bok
+		green      = charmtone.Julep
+		greenDark  = charmtone.Guac
+		// greenLight = charmtone.Bok
+
+		red     = charmtone.Coral
+		redDark = charmtone.Sriracha
+		// redLight = charmtone.Salmon
+		// cherry   = charmtone.Cherry
+	)
+
+	normalBorder := lipgloss.NormalBorder()
+
+	base := lipgloss.NewStyle().Foreground(fgBase)
+
+	s := Styles{}
+
+	s.Background = bgBase
+
+	// Populate color fields
+	s.Primary = primary
+	s.Secondary = secondary
+	s.Tertiary = tertiary
+	s.BgBase = bgBase
+	s.BgBaseLighter = bgBaseLighter
+	s.BgSubtle = bgSubtle
+	s.BgOverlay = bgOverlay
+	s.FgBase = fgBase
+	s.FgMuted = fgMuted
+	s.FgHalfMuted = fgHalfMuted
+	s.FgSubtle = fgSubtle
+	s.Border = border
+	s.BorderColor = borderFocus
+	s.Error = error
+	s.Warning = warning
+	s.Info = info
+	s.White = white
+	s.BlueLight = blueLight
+	s.Blue = blue
+	s.BlueDark = blueDark
+	s.GreenLight = greenLight
+	s.Green = green
+	s.GreenDark = greenDark
+	s.Red = red
+	s.RedDark = redDark
+	s.Yellow = yellow
+
+	s.TextInput = textinput.Styles{
+		Focused: textinput.StyleState{
+			Text:        base,
+			Placeholder: base.Foreground(fgSubtle),
+			Prompt:      base.Foreground(tertiary),
+			Suggestion:  base.Foreground(fgSubtle),
+		},
+		Blurred: textinput.StyleState{
+			Text:        base.Foreground(fgMuted),
+			Placeholder: base.Foreground(fgSubtle),
+			Prompt:      base.Foreground(fgMuted),
+			Suggestion:  base.Foreground(fgSubtle),
+		},
+		Cursor: textinput.CursorStyle{
+			Color: secondary,
+			Shape: tea.CursorBlock,
+			Blink: true,
+		},
+	}
+
+	s.TextArea = textarea.Styles{
+		Focused: textarea.StyleState{
+			Base:             base,
+			Text:             base,
+			LineNumber:       base.Foreground(fgSubtle),
+			CursorLine:       base,
+			CursorLineNumber: base.Foreground(fgSubtle),
+			Placeholder:      base.Foreground(fgSubtle),
+			Prompt:           base.Foreground(tertiary),
+		},
+		Blurred: textarea.StyleState{
+			Base:             base,
+			Text:             base.Foreground(fgMuted),
+			LineNumber:       base.Foreground(fgMuted),
+			CursorLine:       base,
+			CursorLineNumber: base.Foreground(fgMuted),
+			Placeholder:      base.Foreground(fgSubtle),
+			Prompt:           base.Foreground(fgMuted),
+		},
+		Cursor: textarea.CursorStyle{
+			Color: secondary,
+			Shape: tea.CursorBlock,
+			Blink: true,
+		},
+	}
+
+	s.Markdown = ansi.StyleConfig{
+		Document: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				// BlockPrefix: "\n",
+				// BlockSuffix: "\n",
+				Color: stringPtr(charmtone.Smoke.Hex()),
+			},
+			// Margin: uintPtr(defaultMargin),
+		},
+		BlockQuote: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{},
+			Indent:         uintPtr(1),
+			IndentToken:    stringPtr("│ "),
+		},
+		List: ansi.StyleList{
+			LevelIndent: defaultListIndent,
+		},
+		Heading: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				BlockSuffix: "\n",
+				Color:       stringPtr(charmtone.Malibu.Hex()),
+				Bold:        boolPtr(true),
+			},
+		},
+		H1: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          " ",
+				Suffix:          " ",
+				Color:           stringPtr(charmtone.Zest.Hex()),
+				BackgroundColor: stringPtr(charmtone.Charple.Hex()),
+				Bold:            boolPtr(true),
+			},
+		},
+		H2: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix: "## ",
+			},
+		},
+		H3: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix: "### ",
+			},
+		},
+		H4: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix: "#### ",
+			},
+		},
+		H5: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix: "##### ",
+			},
+		},
+		H6: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix: "###### ",
+				Color:  stringPtr(charmtone.Guac.Hex()),
+				Bold:   boolPtr(false),
+			},
+		},
+		Strikethrough: ansi.StylePrimitive{
+			CrossedOut: boolPtr(true),
+		},
+		Emph: ansi.StylePrimitive{
+			Italic: boolPtr(true),
+		},
+		Strong: ansi.StylePrimitive{
+			Bold: boolPtr(true),
+		},
+		HorizontalRule: ansi.StylePrimitive{
+			Color:  stringPtr(charmtone.Charcoal.Hex()),
+			Format: "\n--------\n",
+		},
+		Item: ansi.StylePrimitive{
+			BlockPrefix: "• ",
+		},
+		Enumeration: ansi.StylePrimitive{
+			BlockPrefix: ". ",
+		},
+		Task: ansi.StyleTask{
+			StylePrimitive: ansi.StylePrimitive{},
+			Ticked:         "[✓] ",
+			Unticked:       "[ ] ",
+		},
+		Link: ansi.StylePrimitive{
+			Color:     stringPtr(charmtone.Zinc.Hex()),
+			Underline: boolPtr(true),
+		},
+		LinkText: ansi.StylePrimitive{
+			Color: stringPtr(charmtone.Guac.Hex()),
+			Bold:  boolPtr(true),
+		},
+		Image: ansi.StylePrimitive{
+			Color:     stringPtr(charmtone.Cheeky.Hex()),
+			Underline: boolPtr(true),
+		},
+		ImageText: ansi.StylePrimitive{
+			Color:  stringPtr(charmtone.Squid.Hex()),
+			Format: "Image: {{.text}} →",
+		},
+		Code: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          " ",
+				Suffix:          " ",
+				Color:           stringPtr(charmtone.Coral.Hex()),
+				BackgroundColor: stringPtr(charmtone.Charcoal.Hex()),
+			},
+		},
+		CodeBlock: ansi.StyleCodeBlock{
+			StyleBlock: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Charcoal.Hex()),
+				},
+				Margin: uintPtr(defaultMargin),
+			},
+			Chroma: &ansi.Chroma{
+				Text: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Smoke.Hex()),
+				},
+				Error: ansi.StylePrimitive{
+					Color:           stringPtr(charmtone.Butter.Hex()),
+					BackgroundColor: stringPtr(charmtone.Sriracha.Hex()),
+				},
+				Comment: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Oyster.Hex()),
+				},
+				CommentPreproc: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Bengal.Hex()),
+				},
+				Keyword: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Malibu.Hex()),
+				},
+				KeywordReserved: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Pony.Hex()),
+				},
+				KeywordNamespace: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Pony.Hex()),
+				},
+				KeywordType: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Guppy.Hex()),
+				},
+				Operator: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Salmon.Hex()),
+				},
+				Punctuation: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Zest.Hex()),
+				},
+				Name: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Smoke.Hex()),
+				},
+				NameBuiltin: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Cheeky.Hex()),
+				},
+				NameTag: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Mauve.Hex()),
+				},
+				NameAttribute: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Hazy.Hex()),
+				},
+				NameClass: ansi.StylePrimitive{
+					Color:     stringPtr(charmtone.Salt.Hex()),
+					Underline: boolPtr(true),
+					Bold:      boolPtr(true),
+				},
+				NameDecorator: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Citron.Hex()),
+				},
+				NameFunction: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Guac.Hex()),
+				},
+				LiteralNumber: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Julep.Hex()),
+				},
+				LiteralString: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Cumin.Hex()),
+				},
+				LiteralStringEscape: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Bok.Hex()),
+				},
+				GenericDeleted: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Coral.Hex()),
+				},
+				GenericEmph: ansi.StylePrimitive{
+					Italic: boolPtr(true),
+				},
+				GenericInserted: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Guac.Hex()),
+				},
+				GenericStrong: ansi.StylePrimitive{
+					Bold: boolPtr(true),
+				},
+				GenericSubheading: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Squid.Hex()),
+				},
+				Background: ansi.StylePrimitive{
+					BackgroundColor: stringPtr(charmtone.Charcoal.Hex()),
+				},
+			},
+		},
+		Table: ansi.StyleTable{
+			StyleBlock: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{},
+			},
+		},
+		DefinitionDescription: ansi.StylePrimitive{
+			BlockPrefix: "\n ",
+		},
+	}
+
+	// PlainMarkdown style - muted colors on subtle background for thinking content.
+	plainBg := stringPtr(bgBaseLighter.Hex())
+	plainFg := stringPtr(fgMuted.Hex())
+	s.PlainMarkdown = ansi.StyleConfig{
+		Document: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		BlockQuote: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+			Indent:      uintPtr(1),
+			IndentToken: stringPtr("│ "),
+		},
+		List: ansi.StyleList{
+			LevelIndent: defaultListIndent,
+		},
+		Heading: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				BlockSuffix:     "\n",
+				Bold:            boolPtr(true),
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		H1: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          " ",
+				Suffix:          " ",
+				Bold:            boolPtr(true),
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		H2: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          "## ",
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		H3: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          "### ",
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		H4: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          "#### ",
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		H5: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          "##### ",
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		H6: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          "###### ",
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		Strikethrough: ansi.StylePrimitive{
+			CrossedOut:      boolPtr(true),
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		Emph: ansi.StylePrimitive{
+			Italic:          boolPtr(true),
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		Strong: ansi.StylePrimitive{
+			Bold:            boolPtr(true),
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		HorizontalRule: ansi.StylePrimitive{
+			Format:          "\n--------\n",
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		Item: ansi.StylePrimitive{
+			BlockPrefix:     "• ",
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		Enumeration: ansi.StylePrimitive{
+			BlockPrefix:     ". ",
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		Task: ansi.StyleTask{
+			StylePrimitive: ansi.StylePrimitive{
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+			Ticked:   "[✓] ",
+			Unticked: "[ ] ",
+		},
+		Link: ansi.StylePrimitive{
+			Underline:       boolPtr(true),
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		LinkText: ansi.StylePrimitive{
+			Bold:            boolPtr(true),
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		Image: ansi.StylePrimitive{
+			Underline:       boolPtr(true),
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		ImageText: ansi.StylePrimitive{
+			Format:          "Image: {{.text}} →",
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		Code: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          " ",
+				Suffix:          " ",
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		CodeBlock: ansi.StyleCodeBlock{
+			StyleBlock: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{
+					Color:           plainFg,
+					BackgroundColor: plainBg,
+				},
+				Margin: uintPtr(defaultMargin),
+			},
+		},
+		Table: ansi.StyleTable{
+			StyleBlock: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{
+					Color:           plainFg,
+					BackgroundColor: plainBg,
+				},
+			},
+		},
+		DefinitionDescription: ansi.StylePrimitive{
+			BlockPrefix:     "\n ",
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+	}
+
+	s.Help = help.Styles{
+		ShortKey:       base.Foreground(fgMuted),
+		ShortDesc:      base.Foreground(fgSubtle),
+		ShortSeparator: base.Foreground(border),
+		Ellipsis:       base.Foreground(border),
+		FullKey:        base.Foreground(fgMuted),
+		FullDesc:       base.Foreground(fgSubtle),
+		FullSeparator:  base.Foreground(border),
+	}
+
+	s.Diff = diffview.Style{
+		DividerLine: diffview.LineStyle{
+			LineNumber: lipgloss.NewStyle().
+				Foreground(fgHalfMuted).
+				Background(bgBaseLighter),
+			Code: lipgloss.NewStyle().
+				Foreground(fgHalfMuted).
+				Background(bgBaseLighter),
+		},
+		MissingLine: diffview.LineStyle{
+			LineNumber: lipgloss.NewStyle().
+				Background(bgBaseLighter),
+			Code: lipgloss.NewStyle().
+				Background(bgBaseLighter),
+		},
+		EqualLine: diffview.LineStyle{
+			LineNumber: lipgloss.NewStyle().
+				Foreground(fgMuted).
+				Background(bgBase),
+			Code: lipgloss.NewStyle().
+				Foreground(fgMuted).
+				Background(bgBase),
+		},
+		InsertLine: diffview.LineStyle{
+			LineNumber: lipgloss.NewStyle().
+				Foreground(lipgloss.Color("#629657")).
+				Background(lipgloss.Color("#2b322a")),
+			Symbol: lipgloss.NewStyle().
+				Foreground(lipgloss.Color("#629657")).
+				Background(lipgloss.Color("#323931")),
+			Code: lipgloss.NewStyle().
+				Background(lipgloss.Color("#323931")),
+		},
+		DeleteLine: diffview.LineStyle{
+			LineNumber: lipgloss.NewStyle().
+				Foreground(lipgloss.Color("#a45c59")).
+				Background(lipgloss.Color("#312929")),
+			Symbol: lipgloss.NewStyle().
+				Foreground(lipgloss.Color("#a45c59")).
+				Background(lipgloss.Color("#383030")),
+			Code: lipgloss.NewStyle().
+				Background(lipgloss.Color("#383030")),
+		},
+	}
+
+	s.FilePicker = filepicker.Styles{
+		DisabledCursor:   base.Foreground(fgMuted),
+		Cursor:           base.Foreground(fgBase),
+		Symlink:          base.Foreground(fgSubtle),
+		Directory:        base.Foreground(primary),
+		File:             base.Foreground(fgBase),
+		DisabledFile:     base.Foreground(fgMuted),
+		DisabledSelected: base.Background(bgOverlay).Foreground(fgMuted),
+		Permission:       base.Foreground(fgMuted),
+		Selected:         base.Background(primary).Foreground(fgBase),
+		FileSize:         base.Foreground(fgMuted),
+		EmptyDirectory:   base.Foreground(fgMuted).PaddingLeft(2).SetString("Empty directory"),
+	}
+
+	// borders
+	s.FocusedMessageBorder = lipgloss.Border{Left: BorderThick}
+
+	// text presets
+	s.Base = lipgloss.NewStyle().Foreground(fgBase)
+	s.Muted = lipgloss.NewStyle().Foreground(fgMuted)
+	s.HalfMuted = lipgloss.NewStyle().Foreground(fgHalfMuted)
+	s.Subtle = lipgloss.NewStyle().Foreground(fgSubtle)
+
+	s.WindowTooSmall = s.Muted
+
+	// tag presets
+	s.TagBase = lipgloss.NewStyle().Padding(0, 1).Foreground(white)
+	s.TagError = s.TagBase.Background(redDark)
+	s.TagInfo = s.TagBase.Background(blueLight)
+
+	// Compact header styles
+	s.Header.Charm = base.Foreground(secondary)
+	s.Header.Diagonals = base.Foreground(primary)
+	s.Header.Percentage = s.Muted
+	s.Header.Keystroke = s.Muted
+	s.Header.KeystrokeTip = s.Subtle
+	s.Header.WorkingDir = s.Muted
+	s.Header.Separator = s.Subtle
+
+	s.CompactDetails.Title = s.Base
+	s.CompactDetails.View = s.Base.Padding(0, 1, 1, 1).Border(lipgloss.RoundedBorder()).BorderForeground(borderFocus)
+	s.CompactDetails.Version = s.Muted
+
+	// panels
+	s.PanelMuted = s.Muted.Background(bgBaseLighter)
+	s.PanelBase = lipgloss.NewStyle().Background(bgBase)
+
+	// code line number
+	s.LineNumber = lipgloss.NewStyle().Foreground(fgMuted).Background(bgBase).PaddingRight(1).PaddingLeft(1)
+
+	// Tool calls
+	s.ToolCallPending = lipgloss.NewStyle().Foreground(greenDark).SetString(ToolPending)
+	s.ToolCallError = lipgloss.NewStyle().Foreground(redDark).SetString(ToolError)
+	s.ToolCallSuccess = lipgloss.NewStyle().Foreground(green).SetString(ToolSuccess)
+	// Cancelled uses muted tone but same glyph as pending
+	s.ToolCallCancelled = s.Muted.SetString(ToolPending)
+	s.EarlyStateMessage = s.Subtle.PaddingLeft(2)
+
+	// Tool rendering styles
+	s.Tool.IconPending = base.Foreground(greenDark).SetString(ToolPending)
+	s.Tool.IconSuccess = base.Foreground(green).SetString(ToolSuccess)
+	s.Tool.IconError = base.Foreground(redDark).SetString(ToolError)
+	s.Tool.IconCancelled = s.Muted.SetString(ToolPending)
+
+	s.Tool.NameNormal = base.Foreground(blue)
+	s.Tool.NameNested = base.Foreground(fgHalfMuted)
+
+	s.Tool.ParamMain = s.Subtle
+	s.Tool.ParamKey = s.Subtle
+
+	// 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.ContentCodeTruncation = s.Muted.Background(bgBase).PaddingLeft(2)
+	s.Tool.ContentCodeBg = bgBase
+	s.Tool.Body = base.PaddingLeft(2)
+
+	// Deprecated - kept for backward compatibility
+	s.Tool.ContentBg = s.Muted.Background(bgBaseLighter)
+	s.Tool.ContentText = s.Muted
+	s.Tool.ContentLineNumber = base.Foreground(fgMuted).Background(bgBase).PaddingRight(1).PaddingLeft(1)
+
+	s.Tool.StateWaiting = base.Foreground(fgSubtle)
+	s.Tool.StateCancelled = base.Foreground(fgSubtle)
+
+	s.Tool.ErrorTag = base.Padding(0, 1).Background(red).Foreground(white)
+	s.Tool.ErrorMessage = base.Foreground(fgHalfMuted)
+
+	// Diff and multi-edit styles
+	s.Tool.DiffTruncation = s.Muted.Background(bgBaseLighter).PaddingLeft(2)
+	s.Tool.NoteTag = base.Padding(0, 1).Background(info).Foreground(white)
+	s.Tool.NoteMessage = base.Foreground(fgHalfMuted)
+
+	// Job header styles
+	s.Tool.JobIconPending = base.Foreground(greenDark)
+	s.Tool.JobIconError = base.Foreground(redDark)
+	s.Tool.JobIconSuccess = base.Foreground(green)
+	s.Tool.JobToolName = base.Foreground(blue)
+	s.Tool.JobAction = base.Foreground(blueDark)
+	s.Tool.JobPID = s.Muted
+	s.Tool.JobDescription = s.Subtle
+
+	// Agent task styles
+	s.Tool.AgentTaskTag = base.Bold(true).Padding(0, 1).MarginLeft(2).Background(blueLight).Foreground(white)
+	s.Tool.AgentPrompt = s.Muted
+
+	// Agentic fetch styles
+	s.Tool.AgenticFetchPromptTag = base.Bold(true).Padding(0, 1).MarginLeft(2).Background(green).Foreground(border)
+
+	// Todo styles
+	s.Tool.TodoRatio = base.Foreground(blueDark)
+	s.Tool.TodoCompletedIcon = base.Foreground(green)
+	s.Tool.TodoInProgressIcon = base.Foreground(greenDark)
+	s.Tool.TodoPendingIcon = base.Foreground(fgMuted)
+
+	// MCP styles
+	s.Tool.MCPName = base.Foreground(blue)
+	s.Tool.MCPToolName = base.Foreground(blueDark)
+	s.Tool.MCPArrow = base.Foreground(blue).SetString(ArrowRightIcon)
+
+	// Buttons
+	s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary)
+	s.ButtonBlur = s.Base.Background(bgSubtle)
+
+	// Borders
+	s.BorderFocus = lipgloss.NewStyle().BorderForeground(borderFocus).Border(lipgloss.RoundedBorder()).Padding(1, 2)
+
+	// Editor
+	s.EditorPromptNormalFocused = lipgloss.NewStyle().Foreground(greenDark).SetString("::: ")
+	s.EditorPromptNormalBlurred = s.EditorPromptNormalFocused.Foreground(fgMuted)
+	s.EditorPromptYoloIconFocused = lipgloss.NewStyle().MarginRight(1).Foreground(charmtone.Oyster).Background(charmtone.Citron).Bold(true).SetString(" ! ")
+	s.EditorPromptYoloIconBlurred = s.EditorPromptYoloIconFocused.Foreground(charmtone.Pepper).Background(charmtone.Squid)
+	s.EditorPromptYoloDotsFocused = lipgloss.NewStyle().MarginRight(1).Foreground(charmtone.Zest).SetString(":::")
+	s.EditorPromptYoloDotsBlurred = s.EditorPromptYoloDotsFocused.Foreground(charmtone.Squid)
+
+	s.RadioOn = s.HalfMuted.SetString(RadioOn)
+	s.RadioOff = s.HalfMuted.SetString(RadioOff)
+
+	// Logo colors
+	s.LogoFieldColor = primary
+	s.LogoTitleColorA = secondary
+	s.LogoTitleColorB = primary
+	s.LogoCharmColor = secondary
+	s.LogoVersionColor = primary
+
+	// Section
+	s.Section.Title = s.Subtle
+	s.Section.Line = s.Base.Foreground(charmtone.Charcoal)
+
+	// Initialize
+	s.Initialize.Header = s.Base
+	s.Initialize.Content = s.Muted
+	s.Initialize.Accent = s.Base.Foreground(greenDark)
+
+	// LSP and MCP status.
+	s.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("●")
+	s.ItemBusyIcon = s.ItemOfflineIcon.Foreground(charmtone.Citron)
+	s.ItemErrorIcon = s.ItemOfflineIcon.Foreground(charmtone.Coral)
+	s.ItemOnlineIcon = s.ItemOfflineIcon.Foreground(charmtone.Guac)
+
+	// LSP
+	s.LSP.ErrorDiagnostic = s.Base.Foreground(redDark)
+	s.LSP.WarningDiagnostic = s.Base.Foreground(warning)
+	s.LSP.HintDiagnostic = s.Base.Foreground(fgHalfMuted)
+	s.LSP.InfoDiagnostic = s.Base.Foreground(info)
+
+	// Files
+	s.Files.Path = s.Muted
+	s.Files.Additions = s.Base.Foreground(greenDark)
+	s.Files.Deletions = s.Base.Foreground(redDark)
+
+	// Chat
+	messageFocussedBorder := lipgloss.Border{
+		Left: "▌",
+	}
+
+	s.Chat.Message.NoContent = lipgloss.NewStyle().Foreground(fgBase)
+	s.Chat.Message.UserBlurred = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true).
+		BorderForeground(primary).BorderStyle(normalBorder)
+	s.Chat.Message.UserFocused = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true).
+		BorderForeground(primary).BorderStyle(messageFocussedBorder)
+	s.Chat.Message.AssistantBlurred = s.Chat.Message.NoContent.PaddingLeft(2)
+	s.Chat.Message.AssistantFocused = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true).
+		BorderForeground(greenDark).BorderStyle(messageFocussedBorder)
+	s.Chat.Message.Thinking = lipgloss.NewStyle().MaxHeight(10)
+	s.Chat.Message.ErrorTag = lipgloss.NewStyle().Padding(0, 1).
+		Background(red).Foreground(white)
+	s.Chat.Message.ErrorTitle = lipgloss.NewStyle().Foreground(fgHalfMuted)
+	s.Chat.Message.ErrorDetails = lipgloss.NewStyle().Foreground(fgSubtle)
+
+	// Message item styles
+	s.Chat.Message.ToolCallFocused = s.Muted.PaddingLeft(1).
+		BorderStyle(messageFocussedBorder).
+		BorderLeft(true).
+		BorderForeground(greenDark)
+	s.Chat.Message.ToolCallBlurred = s.Muted.PaddingLeft(2)
+	// No padding or border for compact tool calls within messages
+	s.Chat.Message.ToolCallCompact = s.Muted
+	s.Chat.Message.SectionHeader = s.Base.PaddingLeft(2)
+	s.Chat.Message.AssistantInfoIcon = s.Subtle
+	s.Chat.Message.AssistantInfoModel = s.Muted
+	s.Chat.Message.AssistantInfoProvider = s.Subtle
+	s.Chat.Message.AssistantInfoDuration = s.Subtle
+
+	// Thinking section styles
+	s.Chat.Message.ThinkingBox = s.Subtle.Background(bgBaseLighter)
+	s.Chat.Message.ThinkingTruncationHint = s.Muted
+	s.Chat.Message.ThinkingFooterTitle = s.Muted
+	s.Chat.Message.ThinkingFooterDuration = s.Subtle
+
+	// Text selection.
+	s.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple)
+
+	// Dialog styles
+	s.Dialog.Title = base.Padding(0, 1).Foreground(primary)
+	s.Dialog.TitleText = base.Foreground(primary)
+	s.Dialog.TitleError = base.Foreground(red)
+	s.Dialog.TitleAccent = base.Foreground(green).Bold(true)
+	s.Dialog.View = base.Border(lipgloss.RoundedBorder()).BorderForeground(borderFocus)
+	s.Dialog.PrimaryText = base.Padding(0, 1).Foreground(primary)
+	s.Dialog.SecondaryText = base.Padding(0, 1).Foreground(fgSubtle)
+	s.Dialog.HelpView = base.Padding(0, 1).AlignHorizontal(lipgloss.Left)
+	s.Dialog.Help.ShortKey = base.Foreground(fgMuted)
+	s.Dialog.Help.ShortDesc = base.Foreground(fgSubtle)
+	s.Dialog.Help.ShortSeparator = base.Foreground(border)
+	s.Dialog.Help.Ellipsis = base.Foreground(border)
+	s.Dialog.Help.FullKey = base.Foreground(fgMuted)
+	s.Dialog.Help.FullDesc = base.Foreground(fgSubtle)
+	s.Dialog.Help.FullSeparator = base.Foreground(border)
+	s.Dialog.NormalItem = base.Padding(0, 1).Foreground(fgBase)
+	s.Dialog.SelectedItem = base.Padding(0, 1).Background(primary).Foreground(fgBase)
+	s.Dialog.InputPrompt = base.Margin(1, 1)
+
+	s.Dialog.List = base.Margin(0, 0, 1, 0)
+	s.Dialog.ContentPanel = base.Background(bgSubtle).Foreground(fgBase).Padding(1, 2)
+	s.Dialog.Spinner = base.Foreground(secondary)
+	s.Dialog.ScrollbarThumb = base.Foreground(secondary)
+	s.Dialog.ScrollbarTrack = base.Foreground(border)
+
+	s.Dialog.ImagePreview = lipgloss.NewStyle().Padding(0, 1).Foreground(fgSubtle)
+
+	s.Dialog.Arguments.Content = base.Padding(1)
+	s.Dialog.Arguments.Description = base.MarginBottom(1).MaxHeight(3)
+	s.Dialog.Arguments.InputLabelBlurred = base.Foreground(fgMuted)
+	s.Dialog.Arguments.InputLabelFocused = base.Bold(true)
+	s.Dialog.Arguments.InputRequiredMarkBlurred = base.Foreground(fgMuted).SetString("*")
+	s.Dialog.Arguments.InputRequiredMarkFocused = base.Foreground(primary).Bold(true).SetString("*")
+
+	s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
+	s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")
+	s.Status.InfoIndicator = s.Status.SuccessIndicator
+	s.Status.UpdateIndicator = s.Status.SuccessIndicator.SetString("HEY!")
+	s.Status.WarnIndicator = s.Status.SuccessIndicator.Foreground(bgOverlay).Background(yellow).SetString("WARNING")
+	s.Status.ErrorIndicator = s.Status.SuccessIndicator.Foreground(bgBase).Background(red).SetString("ERROR")
+	s.Status.SuccessMessage = base.Foreground(bgSubtle).Background(greenDark).Padding(0, 1)
+	s.Status.InfoMessage = s.Status.SuccessMessage
+	s.Status.UpdateMessage = s.Status.SuccessMessage
+	s.Status.WarnMessage = s.Status.SuccessMessage.Foreground(bgOverlay).Background(warning)
+	s.Status.ErrorMessage = s.Status.SuccessMessage.Foreground(white).Background(redDark)
+
+	// Completions styles
+	s.Completions.Normal = base.Background(bgSubtle).Foreground(fgBase)
+	s.Completions.Focused = base.Background(primary).Foreground(white)
+	s.Completions.Match = base.Underline(true)
+
+	// Attachments styles
+	attachmentIconStyle := base.Foreground(bgSubtle).Background(green).Padding(0, 1)
+	s.Attachments.Image = attachmentIconStyle.SetString(ImageIcon)
+	s.Attachments.Text = attachmentIconStyle.SetString(TextIcon)
+	s.Attachments.Normal = base.Padding(0, 1).MarginRight(1).Background(fgMuted).Foreground(fgBase)
+	s.Attachments.Deleting = base.Padding(0, 1).Bold(true).Background(red).Foreground(fgBase)
+
+	// Pills styles
+	s.Pills.Base = base.Padding(0, 1)
+	s.Pills.Focused = base.Padding(0, 1).BorderStyle(lipgloss.RoundedBorder()).BorderForeground(bgOverlay)
+	s.Pills.Blurred = base.Padding(0, 1).BorderStyle(lipgloss.HiddenBorder())
+	s.Pills.QueueItemPrefix = s.Muted.SetString("  •")
+	s.Pills.HelpKey = s.Muted
+	s.Pills.HelpText = s.Subtle
+	s.Pills.Area = base
+	s.Pills.TodoSpinner = base.Foreground(greenDark)
+
+	return s
+}
+
+// Helper functions for style pointers
+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
+
+	if style.Color != nil {
+		s = *style.Color
+	}
+	if style.BackgroundColor != nil {
+		if s != "" {
+			s += " "
+		}
+		s += "bg:" + *style.BackgroundColor
+	}
+	if style.Italic != nil && *style.Italic {
+		if s != "" {
+			s += " "
+		}
+		s += "italic"
+	}
+	if style.Bold != nil && *style.Bold {
+		if s != "" {
+			s += " "
+		}
+		s += "bold"
+	}
+	if style.Underline != nil && *style.Underline {
+		if s != "" {
+			s += " "
+		}
+		s += "underline"
+	}
+
+	return s
+}

+ 1 - 0
internal/uicmd/uicmd.go

@@ -1,6 +1,7 @@
 // Package uicmd provides functionality to load and handle custom commands
 // from markdown files and MCP prompts.
 // TODO: Move this into internal/ui after refactoring.
+// TODO: DELETE when we delete the old tui
 package uicmd
 
 import (

Some files were not shown because too many files changed in this diff