Merge branch 'charmbracelet:main' into esc_permission

bbrodriges created

Change summary

.github/cla-signatures.json                                                       |  48 
.github/dependabot.yml                                                            |   4 
.github/workflows/build.yml                                                       |   2 
.goreleaser.yml                                                                   |   6 
CRUSH.md                                                                          |   2 
README.md                                                                         | 115 
go.mod                                                                            |  19 
go.sum                                                                            |  44 
internal/app/app.go                                                               |   2 
internal/app/lsp.go                                                               |  12 
internal/app/lsp_events.go                                                        | 102 
internal/llm/agent/agent.go                                                       |  68 
internal/llm/agent/mcp-tools.go                                                   | 149 
internal/llm/provider/openai.go                                                   | 138 
internal/llm/tools/tools.go                                                       |  75 
internal/lsp/client.go                                                            |  19 
internal/lsp/handlers.go                                                          |  14 
internal/tui/components/chat/messages/messages.go                                 |   4 
internal/tui/components/chat/sidebar/sidebar.go                                   | 487 
internal/tui/components/chat/splash/splash.go                                     |  80 
internal/tui/components/core/core.go                                              |  39 
internal/tui/components/core/status_test.go                                       |   3 
internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden |   2 
internal/tui/components/core/testdata/TestStatus/Default.golden                   |   2 
internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden          |   2 
internal/tui/components/core/testdata/TestStatus/LongDescription.golden           |   2 
internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden               |   2 
internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden           |   2 
internal/tui/components/core/testdata/TestStatus/WithColors.golden                |   2 
internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden            |   2 
internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden          |   2 
internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden         |   2 
internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden         |   2 
internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden         |   2 
internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden         |   2 
internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden         |   2 
internal/tui/components/dialogs/commands/commands.go                              |   6 
internal/tui/components/dialogs/models/list.go                                    |  15 
internal/tui/components/dialogs/models/models.go                                  |  19 
internal/tui/components/files/files.go                                            | 143 
internal/tui/components/lsp/lsp.go                                                | 159 
internal/tui/components/mcp/mcp.go                                                | 126 
internal/tui/exp/list/list.go                                                     |  11 
internal/tui/styles/crush.go                                                      |  11 
internal/tui/styles/theme.go                                                      |   6 
45 files changed, 1,264 insertions(+), 692 deletions(-)

Detailed changes

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

@@ -199,6 +199,54 @@
       "created_at": "2025-08-03T04:07:16Z",
       "repoId": 987670088,
       "pullRequestNo": 519
+    },
+    {
+      "name": "jooray",
+      "id": 1028688,
+      "comment_id": 3148713433,
+      "created_at": "2025-08-03T21:35:15Z",
+      "repoId": 987670088,
+      "pullRequestNo": 527
+    },
+    {
+      "name": "Ed4ward",
+      "id": 153800328,
+      "comment_id": 3150375016,
+      "created_at": "2025-08-04T12:16:16Z",
+      "repoId": 987670088,
+      "pullRequestNo": 539
+    },
+    {
+      "name": "ngnhng",
+      "id": 51743767,
+      "comment_id": 3150846779,
+      "created_at": "2025-08-04T14:01:30Z",
+      "repoId": 987670088,
+      "pullRequestNo": 546
+    },
+    {
+      "name": "zloeber",
+      "id": 4702624,
+      "comment_id": 3152513500,
+      "created_at": "2025-08-04T21:55:42Z",
+      "repoId": 987670088,
+      "pullRequestNo": 564
+    },
+    {
+      "name": "nelsenm2",
+      "id": 197524521,
+      "comment_id": 3152872109,
+      "created_at": "2025-08-05T00:24:50Z",
+      "repoId": 987670088,
+      "pullRequestNo": 569
+    },
+    {
+      "name": "mohseenrm",
+      "id": 10768371,
+      "comment_id": 3153159347,
+      "created_at": "2025-08-05T03:39:12Z",
+      "repoId": 987670088,
+      "pullRequestNo": 574
     }
   ]
 }

.github/dependabot.yml πŸ”—

@@ -9,7 +9,7 @@ updates:
       time: "05:00"
       timezone: "America/New_York"
     labels:
-      - "dependencies"
+      - "area: dependencies"
     commit-message:
       prefix: "chore"
       include: "scope"
@@ -22,7 +22,7 @@ updates:
       time: "05:00"
       timezone: "America/New_York"
     labels:
-      - "dependencies"
+      - "area: dependencies"
     commit-message:
       prefix: "chore"
       include: "scope"

.github/workflows/build.yml πŸ”—

@@ -23,7 +23,9 @@ jobs:
         with:
           github-token: "${{ secrets.GITHUB_TOKEN }}"
       - run: |
+          echo "Approving..."
           gh pr review --approve "$PR_URL"
+          echo "Merging..."
           gh pr merge --squash --auto "$PR_URL"
         env:
           PR_URL: ${{github.event.pull_request.html_url}}

.goreleaser.yml πŸ”—

@@ -166,6 +166,12 @@ brews:
       fish_completion.install "completions/{{ .ProjectName }}.fish"
       man1.install "manpages/{{ .ProjectName }}.1.gz"
 
+scoops:
+  - repository:
+      owner: charmbracelet
+      name: scoop-bucket
+      token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
+
 npms:
   - name: "@charmland/crush"
     repository: "git+https://github.com/charmbracelet/crush.git"

CRUSH.md πŸ”—

@@ -4,6 +4,8 @@
 
 - **Build**: `go build .` or `go run .`
 - **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`)
+- **Update Golden Files**: `go test ./... -update` (regenerates .golden files when test output changes)
+  - Update specific package: `go test ./internal/tui/components/core -update` (in this case, we're updating "core")
 - **Lint**: `task lint-fix`
 - **Format**: `task fmt` (gofumpt -w .)
 - **Dev**: `task dev` (runs with profiling enabled)

README.md πŸ”—

@@ -39,7 +39,7 @@ nix run github:numtide/nix-ai-tools#crush
 
 <details>
 <summary><strong>Nix (NUR)</strong></summary>
-    
+
 Crush is available via [NUR](https://github.com/nix-community/NUR) in `nur.repos.charmbracelet.crush`.
 
 You can also try out Crush via `nix-shell`:
@@ -138,19 +138,29 @@ Crush runs great with no configuration. That said, if you do need or want to
 customize Crush, configuration can be added either local to the project itself,
 or globally, with the following priority:
 
-1. `./.crush.json`
-2. `./crush.json`
-3. `$HOME/.config/crush/crush.json`
+1. `.crush.json`
+2. `crush.json`
+3. `$HOME/.config/crush/crush.json` (Windows: `%USERPROFILE%\AppData\Local\crush\crush.json`)
 
 Configuration itself is stored as a JSON object:
 
 ```json
 {
-   "this-setting": { }
-   "that-setting": { }
+   "this-setting": {"this": "that"},
+   "that-setting": ["ceci", "cela"]
 }
 ```
 
+As an additional note, Crush also stores ephemeral data, such as application state, in one additional location:
+
+```bash
+# Unix
+$HOME/.local/shared/crush/crush.json
+
+# Windows
+%LOCALAPPDATA%\crush\crush.json
+```
+
 ### LSPs
 
 Crush can use LSPs for additional context to help inform its decisions, just
@@ -221,10 +231,10 @@ control but don't want Crush to consider when providing context.
 The `.crushignore` file uses the same syntax as `.gitignore` and can be placed
 in the root of your project or in subdirectories.
 
-### Whitelisting Tools
+### Allowing Tools
 
 By default, Crush will ask you for permission before running tool calls. If
-you'd like, you can whitelist tools to be executed without prompting you for
+you'd like, you can allow tools to be executed without prompting you for
 permissions. Use this with care.
 
 ```json
@@ -245,6 +255,53 @@ permissions. Use this with care.
 You can also skip all permission prompts entirely by running Crush with the
 `--yolo` flag. Be very, very careful with this feature.
 
+### Local Models
+
+Local models can also be configured via OpenAI-compatible API. Here are two common examples:
+
+#### Ollama
+
+```json
+{
+  "providers": {
+    "ollama": {
+      "name": "Ollama",
+      "base_url": "http://localhost:11434/v1/",
+      "type": "openai",
+      "models": [
+        {
+          "name": "Qwen 3 30B",
+          "id": "qwen3:30b",
+          "context_window": 256000,
+          "default_max_tokens": 20000
+        }
+      ]
+    }
+}
+```
+
+#### LM Studio
+
+```json
+{
+  "providers": {
+    "lmstudio": {
+      "name": "LM Studio",
+      "base_url": "http://localhost:1234/v1/",
+      "type": "openai",
+      "models": [
+        {
+          "name": "Qwen 3 30B",
+          "id": "qwen/qwen3-30b-a3b-2507",
+          "context_window": 256000,
+          "default_max_tokens": 20000
+        }
+      ]
+    }
+  }
+}
+```
+
 ### Custom Providers
 
 Crush supports custom provider configurations for both OpenAI-compatible and
@@ -314,6 +371,48 @@ Custom Anthropic-compatible providers follow this format:
 }
 ```
 
+### Amazon Bedrock
+
+Crush currently supports running Anthropic models through Bedrock, with caching disabled.
+
+* A Bedrock provider will appear once you have AWS configured, i.e. `aws configure`
+* Crush also expects the `AWS_REGION` or `AWS_DEFAULT_REGION` to be set
+* To use a specific AWS profile set `AWS_PROFILE` in your environment, i.e. `AWS_PROFILE=myprofile crush`
+
+### Vertex AI Platform
+
+Vertex AI will appear in the list of available providers when `VERTEXAI_PROJECT` and `VERTEXAI_LOCATION` are set. You will also need to be authenticated:
+
+```bash
+gcloud auth application-default login
+```
+
+To add specific models to the configuration, configure as such:
+
+```json
+{
+  "$schema": "https://charm.land/crush.json",
+  "providers": {
+    "vertexai": {
+      "models": [
+        {
+          "id": "claude-sonnet-4@20250514",
+          "name": "VertexAI Sonnet 4",
+          "cost_per_1m_in": 3,
+          "cost_per_1m_out": 15,
+          "cost_per_1m_in_cached": 3.75,
+          "cost_per_1m_out_cached": 0.3,
+          "context_window": 200000,
+          "default_max_tokens": 50000,
+          "can_reason": true,
+          "supports_attachments": true
+        }
+      ]
+    }
+  }
+}
+```
+
 ## Logging
 
 Sometimes you need to look at logs. Luckily, Crush logs all sorts of

go.mod πŸ”—

@@ -6,7 +6,7 @@ require (
 	github.com/JohannesKaufmann/html-to-markdown v1.6.0
 	github.com/MakeNowJust/heredoc v1.0.0
 	github.com/PuerkitoBio/goquery v1.9.2
-	github.com/alecthomas/chroma/v2 v2.15.0
+	github.com/alecthomas/chroma/v2 v2.20.0
 	github.com/anthropics/anthropic-sdk-go v1.6.2
 	github.com/atotto/clipboard v0.1.4
 	github.com/aymanbagabas/go-udiff v0.3.1
@@ -14,7 +14,7 @@ require (
 	github.com/charlievieth/fastwalk v1.0.11
 	github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
 	github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d
-	github.com/charmbracelet/catwalk v0.3.5
+	github.com/charmbracelet/catwalk v0.4.6
 	github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
 	github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
 	github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250721205738-ea66aa652ee0
@@ -27,7 +27,7 @@ require (
 	github.com/google/uuid v1.6.0
 	github.com/invopop/jsonschema v0.13.0
 	github.com/joho/godotenv v1.5.1
-	github.com/mark3labs/mcp-go v0.34.0
+	github.com/mark3labs/mcp-go v0.37.0
 	github.com/muesli/termenv v0.16.0
 	github.com/ncruces/go-sqlite3 v0.25.0
 	github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
@@ -51,12 +51,19 @@ require (
 require (
 	cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
 	github.com/bahlo/generic-list-go v0.2.0 // indirect
+	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/buger/jsonparser v1.1.1 // indirect
+	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/mailru/easyjson v0.7.7 // indirect
+	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+	github.com/prometheus/client_golang v1.23.0 // indirect
+	github.com/prometheus/client_model v0.6.2 // indirect
+	github.com/prometheus/common v0.65.0 // indirect
+	github.com/prometheus/procfs v0.16.1 // indirect
 	github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
 	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
 	golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
-	golang.org/x/oauth2 v0.25.0 // indirect
+	golang.org/x/oauth2 v0.30.0 // indirect
 	golang.org/x/time v0.8.0 // indirect
 	google.golang.org/api v0.211.0 // indirect
 )
@@ -93,7 +100,7 @@ require (
 	github.com/charmbracelet/x/windows v0.2.1 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/disintegration/gift v1.1.2 // indirect
-	github.com/dlclark/regexp2 v1.11.4 // indirect
+	github.com/dlclark/regexp2 v1.11.5 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect
 	github.com/go-logfmt/logfmt v0.6.0 // indirect
@@ -144,7 +151,7 @@ require (
 	golang.org/x/sync v0.16.0 // indirect
 	golang.org/x/sys v0.34.0
 	golang.org/x/term v0.32.0 // indirect
-	golang.org/x/text v0.25.0
+	golang.org/x/text v0.27.0
 	google.golang.org/genai v1.3.0
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
 	google.golang.org/grpc v1.71.0 // indirect

go.sum πŸ”—

@@ -22,10 +22,10 @@ github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4
 github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
-github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
-github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
-github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
-github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
+github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
+github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
+github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
+github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
 github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
 github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
 github.com/anthropics/anthropic-sdk-go v1.6.2 h1:oORA212y0/zAxe7OPvdgIbflnn/x5PGk5uwjF60GqXM=
@@ -68,18 +68,24 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
 github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
 github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
 github.com/bmatcuk/doublestar/v4 v4.9.0 h1:DBvuZxjdKkRP/dr4GVV4w2fnmrk5Hxc90T51LZjv0JA=
 github.com/bmatcuk/doublestar/v4 v4.9.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
 github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
 github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8=
 github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5 h1:GTcMIfDQJKyNKS+xVt7GkNIwz+tBuQtIuiP50WpzNgs=
 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d h1:YMXLZHSo8DjytVY/b5dK8LDuyQsVUmBK3ydQMpu2Ui4=
 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d/go.mod h1:XIQ1qQfRph6Z5o2EikCydjumo0oDInQySRHuPATzbZc=
-github.com/charmbracelet/catwalk v0.3.5 h1:ChMvA5ooTNZhDKFagmGNQgIZvZp8XjpdaJ+cDmhgCgA=
-github.com/charmbracelet/catwalk v0.3.5/go.mod h1:gUUCqqZ8bk4D7ZzGTu3I77k7cC2x4exRuJBN1H2u2pc=
+github.com/charmbracelet/catwalk v0.4.5 h1:Kv3PadDe8IF8gpcYTfAJdCee5Bv4HufvtNT61FXtq5g=
+github.com/charmbracelet/catwalk v0.4.5/go.mod h1:WnKgNPmQHuMyk7GtwAQwl+ezHusfH40IvzML2qwUGwc=
+github.com/charmbracelet/catwalk v0.4.6 h1:Y0JDq5V4agK8oO3lKC/hhInrYXePGwZPNo8I1Lk08jc=
+github.com/charmbracelet/catwalk v0.4.6/go.mod h1:WnKgNPmQHuMyk7GtwAQwl+ezHusfH40IvzML2qwUGwc=
 github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
 github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 h1:+Cz+VfxD5DO+JT1LlswXWhre0HYLj6l2HW8HVGfMuC0=
@@ -118,8 +124,8 @@ 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/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
-github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -180,8 +186,8 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69
 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
-github.com/mark3labs/mcp-go v0.34.0 h1:eWy7WBGvhk6EyAAyVzivTCprE52iXJwNtvHV6Cv3bR0=
-github.com/mark3labs/mcp-go v0.34.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
+github.com/mark3labs/mcp-go v0.37.0 h1:BywvZLPRT6Zx6mMG/MJfxLSZQkTGIcJSEGKsvr4DsoQ=
+github.com/mark3labs/mcp-go v0.37.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
@@ -202,6 +208,8 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
 github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
 github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
 github.com/ncruces/go-sqlite3 v0.25.0 h1:trugKUs98Zwy9KwRr/EUxZHL92LYt7UqcKqAfpGpK+I=
 github.com/ncruces/go-sqlite3 v0.25.0/go.mod h1:n6Z7036yFilJx04yV0mi5JWaF66rUmXn1It9Ux8dx68=
 github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
@@ -221,6 +229,14 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJzYU=
 github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k=
+github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
+github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
+github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
+github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
+github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
 github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c h1:kmzxiX+OB0knCo1V0dkEkdPelzCdAzCURCfmFArn2/A=
 github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -332,8 +348,8 @@ golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
 golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
 golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
 golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
-golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
-golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
+golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
+golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -371,8 +387,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
-golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
+golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
+golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
 golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
 golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

internal/app/app.go πŸ”—

@@ -207,6 +207,8 @@ func (app *App) setupEvents() {
 	setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
 	setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events)
 	setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
+	setupSubscriber(ctx, app.serviceEventsWG, "mcp", agent.SubscribeMCPEvents, app.events)
+	setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events)
 	cleanupFunc := func() {
 		cancel()
 		app.serviceEventsWG.Wait()

internal/app/lsp.go πŸ”—

@@ -22,13 +22,20 @@ func (app *App) initLSPClients(ctx context.Context) {
 func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) {
 	slog.Info("Creating LSP client", "name", name, "command", command, "args", args)
 
+	// Update state to starting
+	updateLSPState(name, lsp.StateStarting, nil, nil, 0)
+
 	// Create LSP client.
-	lspClient, err := lsp.NewClient(ctx, command, args...)
+	lspClient, err := lsp.NewClient(ctx, name, command, args...)
 	if err != nil {
 		slog.Error("Failed to create LSP client for", name, err)
+		updateLSPState(name, lsp.StateError, err, nil, 0)
 		return
 	}
 
+	// Set diagnostics callback
+	lspClient.SetDiagnosticsCallback(updateLSPDiagnostics)
+
 	// Increase initialization timeout as some servers take more time to start.
 	initCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
 	defer cancel()
@@ -37,6 +44,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
 	_, err = lspClient.InitializeLSPClient(initCtx, app.config.WorkingDir())
 	if err != nil {
 		slog.Error("Initialize failed", "name", name, "error", err)
+		updateLSPState(name, lsp.StateError, err, lspClient, 0)
 		lspClient.Close()
 		return
 	}
@@ -47,10 +55,12 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
 		// Server never reached a ready state, but let's continue anyway, as
 		// some functionality might still work.
 		lspClient.SetServerState(lsp.StateError)
+		updateLSPState(name, lsp.StateError, err, lspClient, 0)
 	} else {
 		// Server reached a ready state scuccessfully.
 		slog.Info("LSP server is ready", "name", name)
 		lspClient.SetServerState(lsp.StateReady)
+		updateLSPState(name, lsp.StateReady, nil, lspClient, 0)
 	}
 
 	slog.Info("LSP client initialized", "name", name)

internal/app/lsp_events.go πŸ”—

@@ -0,0 +1,102 @@
+package app
+
+import (
+	"context"
+	"time"
+
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/pubsub"
+)
+
+// LSPEventType represents the type of LSP event
+type LSPEventType string
+
+const (
+	LSPEventStateChanged       LSPEventType = "state_changed"
+	LSPEventDiagnosticsChanged LSPEventType = "diagnostics_changed"
+)
+
+// LSPEvent represents an event in the LSP system
+type LSPEvent struct {
+	Type            LSPEventType
+	Name            string
+	State           lsp.ServerState
+	Error           error
+	DiagnosticCount int
+}
+
+// LSPClientInfo holds information about an LSP client's state
+type LSPClientInfo struct {
+	Name            string
+	State           lsp.ServerState
+	Error           error
+	Client          *lsp.Client
+	DiagnosticCount int
+	ConnectedAt     time.Time
+}
+
+var (
+	lspStates = csync.NewMap[string, LSPClientInfo]()
+	lspBroker = pubsub.NewBroker[LSPEvent]()
+)
+
+// SubscribeLSPEvents returns a channel for LSP events
+func SubscribeLSPEvents(ctx context.Context) <-chan pubsub.Event[LSPEvent] {
+	return lspBroker.Subscribe(ctx)
+}
+
+// GetLSPStates returns the current state of all LSP clients
+func GetLSPStates() map[string]LSPClientInfo {
+	states := make(map[string]LSPClientInfo)
+	for name, info := range lspStates.Seq2() {
+		states[name] = info
+	}
+	return states
+}
+
+// GetLSPState returns the state of a specific LSP client
+func GetLSPState(name string) (LSPClientInfo, bool) {
+	return lspStates.Get(name)
+}
+
+// updateLSPState updates the state of an LSP client and publishes an event
+func updateLSPState(name string, state lsp.ServerState, err error, client *lsp.Client, diagnosticCount int) {
+	info := LSPClientInfo{
+		Name:            name,
+		State:           state,
+		Error:           err,
+		Client:          client,
+		DiagnosticCount: diagnosticCount,
+	}
+	if state == lsp.StateReady {
+		info.ConnectedAt = time.Now()
+	}
+	lspStates.Set(name, info)
+
+	// Publish state change event
+	lspBroker.Publish(pubsub.UpdatedEvent, LSPEvent{
+		Type:            LSPEventStateChanged,
+		Name:            name,
+		State:           state,
+		Error:           err,
+		DiagnosticCount: diagnosticCount,
+	})
+}
+
+// updateLSPDiagnostics updates the diagnostic count for an LSP client and publishes an event
+func updateLSPDiagnostics(name string, diagnosticCount int) {
+	if info, exists := lspStates.Get(name); exists {
+		info.DiagnosticCount = diagnosticCount
+		lspStates.Set(name, info)
+
+		// Publish diagnostics change event
+		lspBroker.Publish(pubsub.UpdatedEvent, LSPEvent{
+			Type:            LSPEventDiagnosticsChanged,
+			Name:            name,
+			State:           info.State,
+			Error:           info.Error,
+			DiagnosticCount: diagnosticCount,
+		})
+	}
+}

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

@@ -159,11 +159,12 @@ func NewAgent(
 	if err != nil {
 		return nil, err
 	}
+
 	summarizeOpts := []provider.ProviderClientOption{
-		provider.WithModel(config.SelectedModelTypeSmall),
-		provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, smallModelProviderCfg.ID)),
+		provider.WithModel(config.SelectedModelTypeLarge),
+		provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, providerCfg.ID)),
 	}
-	summarizeProvider, err := provider.NewProvider(*smallModelProviderCfg, summarizeOpts...)
+	summarizeProvider, err := provider.NewProvider(*providerCfg, summarizeOpts...)
 	if err != nil {
 		return nil, err
 	}
@@ -224,7 +225,7 @@ func NewAgent(
 		sessions:            sessions,
 		titleProvider:       titleProvider,
 		summarizeProvider:   summarizeProvider,
-		summarizeProviderID: string(smallModelProviderCfg.ID),
+		summarizeProviderID: string(providerCfg.ID),
 		activeRequests:      csync.NewMap[string, context.CancelFunc](),
 		tools:               csync.NewLazySlice(toolFn),
 	}, nil
@@ -904,54 +905,59 @@ func (a *agent) UpdateModel() error {
 		a.providerID = string(currentProviderCfg.ID)
 	}
 
-	// Check if small model provider has changed (affects title and summarize providers)
+	// Check if providers have changed for title (small) and summarize (large)
 	smallModelCfg := cfg.Models[config.SelectedModelTypeSmall]
 	var smallModelProviderCfg config.ProviderConfig
-
 	for p := range cfg.Providers.Seq() {
 		if p.ID == smallModelCfg.Provider {
 			smallModelProviderCfg = p
 			break
 		}
 	}
-
 	if smallModelProviderCfg.ID == "" {
 		return fmt.Errorf("provider %s not found in config", smallModelCfg.Provider)
 	}
 
-	// Check if summarize provider has changed
-	if string(smallModelProviderCfg.ID) != a.summarizeProviderID {
-		smallModel := cfg.GetModelByType(config.SelectedModelTypeSmall)
-		if smallModel == nil {
-			return fmt.Errorf("model %s not found in provider %s", smallModelCfg.Model, smallModelProviderCfg.ID)
+	largeModelCfg := cfg.Models[config.SelectedModelTypeLarge]
+	var largeModelProviderCfg config.ProviderConfig
+	for p := range cfg.Providers.Seq() {
+		if p.ID == largeModelCfg.Provider {
+			largeModelProviderCfg = p
+			break
 		}
+	}
+	if largeModelProviderCfg.ID == "" {
+		return fmt.Errorf("provider %s not found in config", largeModelCfg.Provider)
+	}
 
-		// Recreate title provider
-		titleOpts := []provider.ProviderClientOption{
-			provider.WithModel(config.SelectedModelTypeSmall),
-			provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptTitle, smallModelProviderCfg.ID)),
-			// We want the title to be short, so we limit the max tokens
-			provider.WithMaxTokens(40),
-		}
-		newTitleProvider, err := provider.NewProvider(smallModelProviderCfg, titleOpts...)
-		if err != nil {
-			return fmt.Errorf("failed to create new title provider: %w", err)
-		}
+	// Recreate title provider
+	titleOpts := []provider.ProviderClientOption{
+		provider.WithModel(config.SelectedModelTypeSmall),
+		provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptTitle, smallModelProviderCfg.ID)),
+		provider.WithMaxTokens(40),
+	}
+	newTitleProvider, err := provider.NewProvider(smallModelProviderCfg, titleOpts...)
+	if err != nil {
+		return fmt.Errorf("failed to create new title provider: %w", err)
+	}
+	a.titleProvider = newTitleProvider
 
-		// Recreate summarize provider
+	// Recreate summarize provider if provider changed (now large model)
+	if string(largeModelProviderCfg.ID) != a.summarizeProviderID {
+		largeModel := cfg.GetModelByType(config.SelectedModelTypeLarge)
+		if largeModel == nil {
+			return fmt.Errorf("model %s not found in provider %s", largeModelCfg.Model, largeModelProviderCfg.ID)
+		}
 		summarizeOpts := []provider.ProviderClientOption{
-			provider.WithModel(config.SelectedModelTypeSmall),
-			provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, smallModelProviderCfg.ID)),
+			provider.WithModel(config.SelectedModelTypeLarge),
+			provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, largeModelProviderCfg.ID)),
 		}
-		newSummarizeProvider, err := provider.NewProvider(smallModelProviderCfg, summarizeOpts...)
+		newSummarizeProvider, err := provider.NewProvider(largeModelProviderCfg, summarizeOpts...)
 		if err != nil {
 			return fmt.Errorf("failed to create new summarize provider: %w", err)
 		}
-
-		// Update the providers and provider ID
-		a.titleProvider = newTitleProvider
 		a.summarizeProvider = newSummarizeProvider
-		a.summarizeProviderID = string(smallModelProviderCfg.ID)
+		a.summarizeProviderID = string(largeModelProviderCfg.ID)
 	}
 
 	return nil

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

@@ -7,23 +7,76 @@ import (
 	"log/slog"
 	"slices"
 	"sync"
+	"time"
 
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/llm/tools"
-	"github.com/charmbracelet/crush/internal/version"
-
 	"github.com/charmbracelet/crush/internal/permission"
-
+	"github.com/charmbracelet/crush/internal/pubsub"
+	"github.com/charmbracelet/crush/internal/version"
 	"github.com/mark3labs/mcp-go/client"
 	"github.com/mark3labs/mcp-go/client/transport"
 	"github.com/mark3labs/mcp-go/mcp"
 )
 
+// MCPState represents the current state of an MCP client
+type MCPState int
+
+const (
+	MCPStateDisabled MCPState = iota
+	MCPStateStarting
+	MCPStateConnected
+	MCPStateError
+)
+
+func (s MCPState) String() string {
+	switch s {
+	case MCPStateDisabled:
+		return "disabled"
+	case MCPStateStarting:
+		return "starting"
+	case MCPStateConnected:
+		return "connected"
+	case MCPStateError:
+		return "error"
+	default:
+		return "unknown"
+	}
+}
+
+// MCPEventType represents the type of MCP event
+type MCPEventType string
+
+const (
+	MCPEventStateChanged MCPEventType = "state_changed"
+)
+
+// MCPEvent represents an event in the MCP system
+type MCPEvent struct {
+	Type      MCPEventType
+	Name      string
+	State     MCPState
+	Error     error
+	ToolCount int
+}
+
+// MCPClientInfo holds information about an MCP client's state
+type MCPClientInfo struct {
+	Name        string
+	State       MCPState
+	Error       error
+	Client      *client.Client
+	ToolCount   int
+	ConnectedAt time.Time
+}
+
 var (
 	mcpToolsOnce sync.Once
 	mcpTools     []tools.BaseTool
 	mcpClients   = csync.NewMap[string, *client.Client]()
+	mcpStates    = csync.NewMap[string, MCPClientInfo]()
+	mcpBroker    = pubsub.NewBroker[MCPEvent]()
 )
 
 type McpTool struct {
@@ -109,6 +162,7 @@ func getTools(ctx context.Context, name string, permissions permission.Service,
 	result, err := c.ListTools(ctx, mcp.ListToolsRequest{})
 	if err != nil {
 		slog.Error("error listing tools", "error", err)
+		updateMCPState(name, MCPStateError, err, nil, 0)
 		c.Close()
 		mcpClients.Del(name)
 		return nil
@@ -125,11 +179,55 @@ func getTools(ctx context.Context, name string, permissions permission.Service,
 	return mcpTools
 }
 
+// SubscribeMCPEvents returns a channel for MCP events
+func SubscribeMCPEvents(ctx context.Context) <-chan pubsub.Event[MCPEvent] {
+	return mcpBroker.Subscribe(ctx)
+}
+
+// GetMCPStates returns the current state of all MCP clients
+func GetMCPStates() map[string]MCPClientInfo {
+	states := make(map[string]MCPClientInfo)
+	for name, info := range mcpStates.Seq2() {
+		states[name] = info
+	}
+	return states
+}
+
+// GetMCPState returns the state of a specific MCP client
+func GetMCPState(name string) (MCPClientInfo, bool) {
+	return mcpStates.Get(name)
+}
+
+// updateMCPState updates the state of an MCP client and publishes an event
+func updateMCPState(name string, state MCPState, err error, client *client.Client, toolCount int) {
+	info := MCPClientInfo{
+		Name:      name,
+		State:     state,
+		Error:     err,
+		Client:    client,
+		ToolCount: toolCount,
+	}
+	if state == MCPStateConnected {
+		info.ConnectedAt = time.Now()
+	}
+	mcpStates.Set(name, info)
+
+	// Publish state change event
+	mcpBroker.Publish(pubsub.UpdatedEvent, MCPEvent{
+		Type:      MCPEventStateChanged,
+		Name:      name,
+		State:     state,
+		Error:     err,
+		ToolCount: toolCount,
+	})
+}
+
 // CloseMCPClients closes all MCP clients. This should be called during application shutdown.
 func CloseMCPClients() {
 	for c := range mcpClients.Seq() {
 		_ = c.Close()
 	}
+	mcpBroker.Shutdown()
 }
 
 var mcpInitRequest = mcp.InitializeRequest{
@@ -145,25 +243,51 @@ var mcpInitRequest = mcp.InitializeRequest{
 func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) []tools.BaseTool {
 	var wg sync.WaitGroup
 	result := csync.NewSlice[tools.BaseTool]()
+
+	// Initialize states for all configured MCPs
 	for name, m := range cfg.MCP {
 		if m.Disabled {
+			updateMCPState(name, MCPStateDisabled, nil, nil, 0)
 			slog.Debug("skipping disabled mcp", "name", name)
 			continue
 		}
+
+		// Set initial starting state
+		updateMCPState(name, MCPStateStarting, nil, nil, 0)
+
 		wg.Add(1)
 		go func(name string, m config.MCPConfig) {
-			defer wg.Done()
+			defer func() {
+				wg.Done()
+				if r := recover(); r != nil {
+					var err error
+					switch v := r.(type) {
+					case error:
+						err = v
+					case string:
+						err = fmt.Errorf("panic: %s", v)
+					default:
+						err = fmt.Errorf("panic: %v", v)
+					}
+					updateMCPState(name, MCPStateError, err, nil, 0)
+					slog.Error("panic in mcp client initialization", "error", err, "name", name)
+				}
+			}()
+
 			c, err := createMcpClient(m)
 			if err != nil {
+				updateMCPState(name, MCPStateError, err, nil, 0)
 				slog.Error("error creating mcp client", "error", err, "name", name)
 				return
 			}
 			if err := c.Start(ctx); err != nil {
+				updateMCPState(name, MCPStateError, err, nil, 0)
 				slog.Error("error starting mcp client", "error", err, "name", name)
 				_ = c.Close()
 				return
 			}
 			if _, err := c.Initialize(ctx, mcpInitRequest); err != nil {
+				updateMCPState(name, MCPStateError, err, nil, 0)
 				slog.Error("error initializing mcp client", "error", err, "name", name)
 				_ = c.Close()
 				return
@@ -172,7 +296,9 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con
 			slog.Info("Initialized mcp client", "name", name)
 			mcpClients.Set(name, c)
 
-			result.Append(getTools(ctx, name, permissions, c, cfg.WorkingDir())...)
+			tools := getTools(ctx, name, permissions, c, cfg.WorkingDir())
+			updateMCPState(name, MCPStateConnected, nil, c, len(tools))
+			result.Append(tools...)
 		}(name, m)
 	}
 	wg.Wait()
@@ -182,22 +308,31 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con
 func createMcpClient(m config.MCPConfig) (*client.Client, error) {
 	switch m.Type {
 	case config.MCPStdio:
-		return client.NewStdioMCPClient(
+		return client.NewStdioMCPClientWithOptions(
 			m.Command,
 			m.ResolvedEnv(),
-			m.Args...,
+			m.Args,
+			transport.WithCommandLogger(mcpLogger{}),
 		)
 	case config.MCPHttp:
 		return client.NewStreamableHttpClient(
 			m.URL,
 			transport.WithHTTPHeaders(m.ResolvedHeaders()),
+			transport.WithHTTPLogger(mcpLogger{}),
 		)
 	case config.MCPSse:
 		return client.NewSSEMCPClient(
 			m.URL,
 			client.WithHeaders(m.ResolvedHeaders()),
+			transport.WithSSELogger(mcpLogger{}),
 		)
 	default:
 		return nil, fmt.Errorf("unsupported mcp type: %s", m.Type)
 	}
 }
+
+// for MCP's clients.
+type mcpLogger struct{}
+
+func (l mcpLogger) Errorf(format string, v ...any) { slog.Error(fmt.Sprintf(format, v...)) }
+func (l mcpLogger) Infof(format string, v ...any)  { slog.Info(fmt.Sprintf(format, v...)) }

internal/llm/provider/openai.go πŸ”—

@@ -2,10 +2,12 @@ package provider
 
 import (
 	"context"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
 	"log/slog"
+	"slices"
 	"strings"
 	"time"
 
@@ -14,6 +16,7 @@ import (
 	"github.com/charmbracelet/crush/internal/llm/tools"
 	"github.com/charmbracelet/crush/internal/log"
 	"github.com/charmbracelet/crush/internal/message"
+	"github.com/google/uuid"
 	"github.com/openai/openai-go"
 	"github.com/openai/openai-go/option"
 	"github.com/openai/openai-go/packages/param"
@@ -70,8 +73,9 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
 		systemMessage = o.providerOptions.systemPromptPrefix + "\n" + systemMessage
 	}
 
-	systemTextBlock := openai.ChatCompletionContentPartTextParam{Text: systemMessage}
+	system := openai.SystemMessage(systemMessage)
 	if isAnthropicModel && !o.providerOptions.disableCache {
+		systemTextBlock := openai.ChatCompletionContentPartTextParam{Text: systemMessage}
 		systemTextBlock.SetExtraFields(
 			map[string]any{
 				"cache_control": map[string]string{
@@ -79,10 +83,10 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
 				},
 			},
 		)
+		var content []openai.ChatCompletionContentPartTextParam
+		content = append(content, systemTextBlock)
+		system = openai.SystemMessage(content)
 	}
-	var content []openai.ChatCompletionContentPartTextParam
-	content = append(content, systemTextBlock)
-	system := openai.SystemMessage(content)
 	openaiMessages = append(openaiMessages, system)
 
 	for i, msg := range messages {
@@ -93,9 +97,12 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
 		switch msg.Role {
 		case message.User:
 			var content []openai.ChatCompletionContentPartUnionParam
+
 			textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
 			content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
+			hasBinaryContent := false
 			for _, binaryContent := range msg.BinaryContent() {
+				hasBinaryContent = true
 				imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(catwalk.InferenceProviderOpenAI)}
 				imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
 
@@ -108,8 +115,11 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
 					},
 				})
 			}
-
-			openaiMessages = append(openaiMessages, openai.UserMessage(content))
+			if hasBinaryContent || (isAnthropicModel && !o.providerOptions.disableCache) {
+				openaiMessages = append(openaiMessages, openai.UserMessage(content))
+			} else {
+				openaiMessages = append(openaiMessages, openai.UserMessage(msg.Content().String()))
+			}
 
 		case message.Assistant:
 			assistantMsg := openai.ChatCompletionAssistantMessageParam{
@@ -134,13 +144,15 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
 						},
 					},
 				}
+				if !isAnthropicModel {
+					assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
+						OfString: param.NewOpt(msg.Content().String()),
+					}
+				}
 			}
 
 			if len(msg.ToolCalls()) > 0 {
 				hasContent = true
-				assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
-					OfString: param.NewOpt(msg.Content().String()),
-				}
 				assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(msg.ToolCalls()))
 				for i, call := range msg.ToolCalls() {
 					assistantMsg.ToolCalls[i] = openai.ChatCompletionMessageToolCallParam{
@@ -329,21 +341,26 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
 			acc := openai.ChatCompletionAccumulator{}
 			currentContent := ""
 			toolCalls := make([]message.ToolCall, 0)
-
-			var currentToolCallID string
-			var currentToolCall openai.ChatCompletionMessageToolCall
 			var msgToolCalls []openai.ChatCompletionMessageToolCall
-			currentToolIndex := 0
 			for openaiStream.Next() {
 				chunk := openaiStream.Current()
 				// Kujtim: this is an issue with openrouter qwen, its sending -1 for the tool index
 				if len(chunk.Choices) > 0 && len(chunk.Choices[0].Delta.ToolCalls) > 0 && chunk.Choices[0].Delta.ToolCalls[0].Index == -1 {
-					chunk.Choices[0].Delta.ToolCalls[0].Index = int64(currentToolIndex)
-					currentToolIndex++
+					chunk.Choices[0].Delta.ToolCalls[0].Index = 0
 				}
 				acc.AddChunk(chunk)
-				// This fixes multiple tool calls for some providers
-				for _, choice := range chunk.Choices {
+				for i, choice := range chunk.Choices {
+					reasoning, ok := choice.Delta.JSON.ExtraFields["reasoning"]
+					if ok && reasoning.Raw() != "" {
+						reasoningStr := ""
+						json.Unmarshal([]byte(reasoning.Raw()), &reasoningStr)
+						if reasoningStr != "" {
+							eventChan <- ProviderEvent{
+								Type:     EventThinkingDelta,
+								Thinking: reasoningStr,
+							}
+						}
+					}
 					if choice.Delta.Content != "" {
 						eventChan <- ProviderEvent{
 							Type:    EventContentDelta,
@@ -352,63 +369,50 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
 						currentContent += choice.Delta.Content
 					} else if len(choice.Delta.ToolCalls) > 0 {
 						toolCall := choice.Delta.ToolCalls[0]
-						// Detect tool use start
-						if currentToolCallID == "" {
-							if toolCall.ID != "" {
-								currentToolCallID = toolCall.ID
-								eventChan <- ProviderEvent{
-									Type: EventToolUseStart,
-									ToolCall: &message.ToolCall{
-										ID:       toolCall.ID,
-										Name:     toolCall.Function.Name,
-										Finished: false,
-									},
+						newToolCall := false
+						if len(msgToolCalls)-1 >= int(toolCall.Index) { // tool call exists
+							existingToolCall := msgToolCalls[toolCall.Index]
+							if toolCall.ID != "" && toolCall.ID != existingToolCall.ID {
+								found := false
+								// try to find the tool based on the ID
+								for i, tool := range msgToolCalls {
+									if tool.ID == toolCall.ID {
+										msgToolCalls[i].Function.Arguments += toolCall.Function.Arguments
+										found = true
+									}
 								}
-								currentToolCall = openai.ChatCompletionMessageToolCall{
-									ID:   toolCall.ID,
-									Type: "function",
-									Function: openai.ChatCompletionMessageToolCallFunction{
-										Name:      toolCall.Function.Name,
-										Arguments: toolCall.Function.Arguments,
-									},
+								if !found {
+									newToolCall = true
 								}
-							}
-						} else {
-							// Delta tool use
-							if toolCall.ID == "" || toolCall.ID == currentToolCallID {
-								currentToolCall.Function.Arguments += toolCall.Function.Arguments
 							} else {
-								// Detect new tool use
-								if toolCall.ID != currentToolCallID {
-									msgToolCalls = append(msgToolCalls, currentToolCall)
-									currentToolCallID = toolCall.ID
-									eventChan <- ProviderEvent{
-										Type: EventToolUseStart,
-										ToolCall: &message.ToolCall{
-											ID:       toolCall.ID,
-											Name:     toolCall.Function.Name,
-											Finished: false,
-										},
-									}
-									currentToolCall = openai.ChatCompletionMessageToolCall{
-										ID:   toolCall.ID,
-										Type: "function",
-										Function: openai.ChatCompletionMessageToolCallFunction{
-											Name:      toolCall.Function.Name,
-											Arguments: toolCall.Function.Arguments,
-										},
-									}
-								}
+								msgToolCalls[toolCall.Index].Function.Arguments += toolCall.Function.Arguments
 							}
+						} else {
+							newToolCall = true
 						}
-					}
-					// Kujtim: some models send finish stop even for tool calls
-					if choice.FinishReason == "tool_calls" || (choice.FinishReason == "stop" && currentToolCallID != "") {
-						msgToolCalls = append(msgToolCalls, currentToolCall)
-						if len(acc.Choices) > 0 {
-							acc.Choices[0].Message.ToolCalls = msgToolCalls
+						if newToolCall { // new tool call
+							if toolCall.ID == "" {
+								toolCall.ID = uuid.NewString()
+							}
+							eventChan <- ProviderEvent{
+								Type: EventToolUseStart,
+								ToolCall: &message.ToolCall{
+									ID:       toolCall.ID,
+									Name:     toolCall.Function.Name,
+									Finished: false,
+								},
+							}
+							msgToolCalls = append(msgToolCalls, openai.ChatCompletionMessageToolCall{
+								ID:   toolCall.ID,
+								Type: "function",
+								Function: openai.ChatCompletionMessageToolCallFunction{
+									Name:      toolCall.Function.Name,
+									Arguments: toolCall.Function.Arguments,
+								},
+							})
 						}
 					}
+					acc.Choices[i].Message.ToolCalls = slices.Clone(msgToolCalls)
 				}
 			}
 

internal/llm/tools/tools.go πŸ”—

@@ -3,6 +3,8 @@ package tools
 import (
 	"context"
 	"encoding/json"
+	"fmt"
+	"strings"
 )
 
 type ToolInfo struct {
@@ -25,6 +27,10 @@ const (
 
 	SessionIDContextKey sessionIDContextKey = "session_id"
 	MessageIDContextKey messageIDContextKey = "message_id"
+
+	maxResponseWidth  = 3000
+	maxResponseHeight = 5000
+	maxResponseChars  = 50000
 )
 
 type ToolResponse struct {
@@ -37,10 +43,77 @@ type ToolResponse struct {
 func NewTextResponse(content string) ToolResponse {
 	return ToolResponse{
 		Type:    ToolResponseTypeText,
-		Content: content,
+		Content: truncateContent(content),
 	}
 }
 
+func truncateContent(content string) string {
+	if len(content) <= maxResponseChars {
+		return truncateWidthAndHeight(content)
+	}
+
+	truncated := content[:maxResponseChars]
+
+	if lastNewline := strings.LastIndex(truncated, "\n"); lastNewline > maxResponseChars/2 {
+		truncated = truncated[:lastNewline]
+	}
+
+	truncated += "\n\n... [Content truncated due to length] ..."
+
+	return truncateWidthAndHeight(truncated)
+}
+
+func truncateWidthAndHeight(content string) string {
+	lines := strings.Split(content, "\n")
+
+	heightTruncated := false
+	if len(lines) > maxResponseHeight {
+		keepLines := maxResponseHeight - 3
+		firstHalf := keepLines / 2
+		secondHalf := keepLines - firstHalf
+
+		truncatedLines := make([]string, 0, maxResponseHeight)
+		truncatedLines = append(truncatedLines, lines[:firstHalf]...)
+		truncatedLines = append(truncatedLines, "")
+		truncatedLines = append(truncatedLines, fmt.Sprintf("... [%d lines truncated] ...", len(lines)-keepLines))
+		truncatedLines = append(truncatedLines, "")
+		truncatedLines = append(truncatedLines, lines[len(lines)-secondHalf:]...)
+
+		lines = truncatedLines
+		heightTruncated = true
+	}
+
+	widthTruncated := false
+	for i, line := range lines {
+		if len(line) > maxResponseWidth {
+			if maxResponseWidth > 20 {
+				keepChars := maxResponseWidth - 10
+				firstHalf := keepChars / 2
+				secondHalf := keepChars - firstHalf
+				lines[i] = line[:firstHalf] + " ... " + line[len(line)-secondHalf:]
+			} else {
+				lines[i] = line[:maxResponseWidth]
+			}
+			widthTruncated = true
+		}
+	}
+
+	result := strings.Join(lines, "\n")
+
+	if heightTruncated || widthTruncated {
+		notices := make([]string, 0, 2)
+		if heightTruncated {
+			notices = append(notices, "height")
+		}
+		if widthTruncated {
+			notices = append(notices, "width")
+		}
+		result += fmt.Sprintf("\n\n[Note: Content truncated by %s to fit response limits]", strings.Join(notices, " and "))
+	}
+
+	return result
+}
+
 func WithResponseMetadata(response ToolResponse, metadata any) ToolResponse {
 	if metadata != nil {
 		metadataBytes, err := json.Marshal(metadata)

internal/lsp/client.go πŸ”—

@@ -26,6 +26,12 @@ type Client struct {
 	stdout *bufio.Reader
 	stderr io.ReadCloser
 
+	// Client name for identification
+	name string
+
+	// Diagnostic change callback
+	onDiagnosticsChanged func(name string, count int)
+
 	// Request ID counter
 	nextID atomic.Int32
 
@@ -53,7 +59,7 @@ type Client struct {
 	serverState atomic.Value
 }
 
-func NewClient(ctx context.Context, command string, args ...string) (*Client, error) {
+func NewClient(ctx context.Context, name, command string, args ...string) (*Client, error) {
 	cmd := exec.CommandContext(ctx, command, args...)
 	// Copy env
 	cmd.Env = os.Environ()
@@ -75,6 +81,7 @@ func NewClient(ctx context.Context, command string, args ...string) (*Client, er
 
 	client := &Client{
 		Cmd:                   cmd,
+		name:                  name,
 		stdin:                 stdin,
 		stdout:                bufio.NewReader(stdout),
 		stderr:                stderr,
@@ -284,6 +291,16 @@ func (c *Client) SetServerState(state ServerState) {
 	c.serverState.Store(state)
 }
 
+// GetName returns the name of the LSP client
+func (c *Client) GetName() string {
+	return c.name
+}
+
+// SetDiagnosticsCallback sets the callback function for diagnostic changes
+func (c *Client) SetDiagnosticsCallback(callback func(name string, count int)) {
+	c.onDiagnosticsChanged = callback
+}
+
 // WaitForServerReady waits for the server to be ready by polling the server
 // with a simple request until it responds successfully or times out
 func (c *Client) WaitForServerReady(ctx context.Context) error {

internal/lsp/handlers.go πŸ”—

@@ -103,7 +103,17 @@ func HandleDiagnostics(client *Client, params json.RawMessage) {
 	}
 
 	client.diagnosticsMu.Lock()
-	defer client.diagnosticsMu.Unlock()
-
 	client.diagnostics[diagParams.URI] = diagParams.Diagnostics
+
+	// Calculate total diagnostic count
+	totalCount := 0
+	for _, diagnostics := range client.diagnostics {
+		totalCount += len(diagnostics)
+	}
+	client.diagnosticsMu.Unlock()
+
+	// Trigger callback if set
+	if client.onDiagnosticsChanged != nil {
+		client.onDiagnosticsChanged(client.name, totalCount)
+	}
 }

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

@@ -274,11 +274,13 @@ func (m *messageCmp) renderThinkingContent() string {
 	if reasoningContent.StartedAt > 0 {
 		duration := m.message.ThinkingDuration()
 		if reasoningContent.FinishedAt > 0 {
+			if duration.String() == "0s" {
+				return ""
+			}
 			m.anim.SetLabel("")
 			opts := core.StatusOpts{
 				Title:       "Thought for",
 				Description: duration.String(),
-				NoIcon:      true,
 			}
 			return t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1))
 		} else if finishReason != nil && finishReason.Reason == message.FinishReasonCanceled {

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

@@ -5,7 +5,6 @@ import (
 	"fmt"
 	"os"
 	"slices"
-	"sort"
 	"strings"
 
 	tea "github.com/charmbracelet/bubbletea/v2"
@@ -13,21 +12,21 @@ import (
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/diff"
-	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/lsp"
-	"github.com/charmbracelet/crush/internal/lsp/protocol"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
+	"github.com/charmbracelet/crush/internal/tui/components/files"
 	"github.com/charmbracelet/crush/internal/tui/components/logo"
+	lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
+	"github.com/charmbracelet/crush/internal/tui/components/mcp"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/crush/internal/version"
 	"github.com/charmbracelet/lipgloss/v2"
-	"github.com/charmbracelet/x/ansi"
 	"golang.org/x/text/cases"
 	"golang.org/x/text/language"
 )
@@ -382,459 +381,125 @@ func (m *sidebarCmp) renderSectionsHorizontal() string {
 
 // filesBlockCompact renders the files block with limited width and height for horizontal layout
 func (m *sidebarCmp) filesBlockCompact(maxWidth int) string {
-	t := styles.CurrentTheme()
-
-	section := t.S().Subtle.Render("Modified Files")
-
-	files := slices.Collect(m.files.Seq())
-
-	if len(files) == 0 {
-		content := lipgloss.JoinVertical(
-			lipgloss.Left,
-			section,
-			"",
-			t.S().Base.Foreground(t.Border).Render("None"),
-		)
-		return lipgloss.NewStyle().Width(maxWidth).Render(content)
+	// Convert map to slice and handle type conversion
+	sessionFiles := slices.Collect(m.files.Seq())
+	fileSlice := make([]files.SessionFile, len(sessionFiles))
+	for i, sf := range sessionFiles {
+		fileSlice[i] = files.SessionFile{
+			History: files.FileHistory{
+				InitialVersion: sf.History.initialVersion,
+				LatestVersion:  sf.History.latestVersion,
+			},
+			FilePath:  sf.FilePath,
+			Additions: sf.Additions,
+			Deletions: sf.Deletions,
+		}
 	}
 
-	fileList := []string{section, ""}
-	sort.Slice(files, func(i, j int) bool {
-		return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
-	})
-
-	// Limit items for horizontal layout - use less space
-	maxItems := min(5, len(files))
+	// Limit items for horizontal layout
+	maxItems := min(5, len(fileSlice))
 	availableHeight := m.height - 8 // Reserve space for header and other content
 	if availableHeight > 0 {
 		maxItems = min(maxItems, availableHeight)
 	}
 
-	filesShown := 0
-	for _, file := range files {
-		if file.Additions == 0 && file.Deletions == 0 {
-			continue
-		}
-		if filesShown >= maxItems {
-			break
-		}
-
-		var statusParts []string
-		if file.Additions > 0 {
-			statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
-		}
-		if file.Deletions > 0 {
-			statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
-		}
-
-		extraContent := strings.Join(statusParts, " ")
-		cwd := config.Get().WorkingDir() + string(os.PathSeparator)
-		filePath := file.FilePath
-		filePath = strings.TrimPrefix(filePath, cwd)
-		filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
-		filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…")
-
-		fileList = append(fileList,
-			core.Status(
-				core.StatusOpts{
-					IconColor:    t.FgMuted,
-					NoIcon:       true,
-					Title:        filePath,
-					ExtraContent: extraContent,
-				},
-				maxWidth,
-			),
-		)
-		filesShown++
-	}
-
-	// Add "..." indicator if there are more files
-	totalFilesWithChanges := 0
-	for _, file := range files {
-		if file.Additions > 0 || file.Deletions > 0 {
-			totalFilesWithChanges++
-		}
-	}
-	if totalFilesWithChanges > maxItems {
-		fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("…"))
-	}
-
-	content := lipgloss.JoinVertical(lipgloss.Left, fileList...)
-	return lipgloss.NewStyle().Width(maxWidth).Render(content)
+	return files.RenderFileBlock(fileSlice, files.RenderOptions{
+		MaxWidth:    maxWidth,
+		MaxItems:    maxItems,
+		ShowSection: true,
+		SectionName: "Modified Files",
+	}, true)
 }
 
 // lspBlockCompact renders the LSP block with limited width and height for horizontal layout
 func (m *sidebarCmp) lspBlockCompact(maxWidth int) string {
-	t := styles.CurrentTheme()
-
-	section := t.S().Subtle.Render("LSPs")
-
-	lspList := []string{section, ""}
-
-	lsp := config.Get().LSP.Sorted()
-	if len(lsp) == 0 {
-		content := lipgloss.JoinVertical(
-			lipgloss.Left,
-			section,
-			"",
-			t.S().Base.Foreground(t.Border).Render("None"),
-		)
-		return lipgloss.NewStyle().Width(maxWidth).Render(content)
-	}
-
 	// Limit items for horizontal layout
-	maxItems := min(5, len(lsp))
+	lspConfigs := config.Get().LSP.Sorted()
+	maxItems := min(5, len(lspConfigs))
 	availableHeight := m.height - 8
 	if availableHeight > 0 {
 		maxItems = min(maxItems, availableHeight)
 	}
 
-	for i, l := range lsp {
-		if i >= maxItems {
-			break
-		}
-
-		iconColor := t.Success
-		if l.LSP.Disabled {
-			iconColor = t.FgMuted
-		}
-
-		lspErrs := map[protocol.DiagnosticSeverity]int{
-			protocol.SeverityError:       0,
-			protocol.SeverityWarning:     0,
-			protocol.SeverityHint:        0,
-			protocol.SeverityInformation: 0,
-		}
-		if client, ok := m.lspClients[l.Name]; ok {
-			for _, diagnostics := range client.GetDiagnostics() {
-				for _, diagnostic := range diagnostics {
-					if severity, ok := lspErrs[diagnostic.Severity]; ok {
-						lspErrs[diagnostic.Severity] = severity + 1
-					}
-				}
-			}
-		}
-
-		errs := []string{}
-		if lspErrs[protocol.SeverityError] > 0 {
-			errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
-		}
-		if lspErrs[protocol.SeverityWarning] > 0 {
-			errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
-		}
-		if lspErrs[protocol.SeverityHint] > 0 {
-			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
-		}
-		if lspErrs[protocol.SeverityInformation] > 0 {
-			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
-		}
-
-		lspList = append(lspList,
-			core.Status(
-				core.StatusOpts{
-					IconColor:    iconColor,
-					Title:        l.Name,
-					Description:  l.LSP.Command,
-					ExtraContent: strings.Join(errs, " "),
-				},
-				maxWidth,
-			),
-		)
-	}
-
-	// Add "..." indicator if there are more LSPs
-	if len(lsp) > maxItems {
-		lspList = append(lspList, t.S().Base.Foreground(t.FgMuted).Render("…"))
-	}
-
-	content := lipgloss.JoinVertical(lipgloss.Left, lspList...)
-	return lipgloss.NewStyle().Width(maxWidth).Render(content)
+	return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
+		MaxWidth:    maxWidth,
+		MaxItems:    maxItems,
+		ShowSection: true,
+		SectionName: "LSPs",
+	}, true)
 }
 
 // mcpBlockCompact renders the MCP block with limited width and height for horizontal layout
 func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
-	t := styles.CurrentTheme()
-
-	section := t.S().Subtle.Render("MCPs")
-
-	mcpList := []string{section, ""}
-
-	mcps := config.Get().MCP.Sorted()
-	if len(mcps) == 0 {
-		content := lipgloss.JoinVertical(
-			lipgloss.Left,
-			section,
-			"",
-			t.S().Base.Foreground(t.Border).Render("None"),
-		)
-		return lipgloss.NewStyle().Width(maxWidth).Render(content)
-	}
-
 	// Limit items for horizontal layout
-	maxItems := min(5, len(mcps))
+	maxItems := min(5, len(config.Get().MCP.Sorted()))
 	availableHeight := m.height - 8
 	if availableHeight > 0 {
 		maxItems = min(maxItems, availableHeight)
 	}
 
-	for i, l := range mcps {
-		if i >= maxItems {
-			break
-		}
-
-		iconColor := t.Success
-		if l.MCP.Disabled {
-			iconColor = t.FgMuted
-		}
-
-		mcpList = append(mcpList,
-			core.Status(
-				core.StatusOpts{
-					IconColor:   iconColor,
-					Title:       l.Name,
-					Description: l.MCP.Command,
-				},
-				maxWidth,
-			),
-		)
-	}
-
-	// Add "..." indicator if there are more MCPs
-	if len(mcps) > maxItems {
-		mcpList = append(mcpList, t.S().Base.Foreground(t.FgMuted).Render("…"))
-	}
-
-	content := lipgloss.JoinVertical(lipgloss.Left, mcpList...)
-	return lipgloss.NewStyle().Width(maxWidth).Render(content)
+	return mcp.RenderMCPBlock(mcp.RenderOptions{
+		MaxWidth:    maxWidth,
+		MaxItems:    maxItems,
+		ShowSection: true,
+		SectionName: "MCPs",
+	}, true)
 }
 
 func (m *sidebarCmp) filesBlock() string {
-	t := styles.CurrentTheme()
-
-	section := t.S().Subtle.Render(
-		core.Section("Modified Files", m.getMaxWidth()),
-	)
-
-	files := slices.Collect(m.files.Seq())
-	if len(files) == 0 {
-		return lipgloss.JoinVertical(
-			lipgloss.Left,
-			section,
-			"",
-			t.S().Base.Foreground(t.Border).Render("None"),
-		)
+	// Convert map to slice and handle type conversion
+	sessionFiles := slices.Collect(m.files.Seq())
+	fileSlice := make([]files.SessionFile, len(sessionFiles))
+	for i, sf := range sessionFiles {
+		fileSlice[i] = files.SessionFile{
+			History: files.FileHistory{
+				InitialVersion: sf.History.initialVersion,
+				LatestVersion:  sf.History.latestVersion,
+			},
+			FilePath:  sf.FilePath,
+			Additions: sf.Additions,
+			Deletions: sf.Deletions,
+		}
 	}
 
-	fileList := []string{section, ""}
-	// order files by the latest version's created time
-	sort.Slice(files, func(i, j int) bool {
-		return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
-	})
-
 	// Limit the number of files shown
 	maxFiles, _, _ := m.getDynamicLimits()
-	maxFiles = min(len(files), maxFiles)
-	filesShown := 0
-
-	for _, file := range files {
-		if file.Additions == 0 && file.Deletions == 0 {
-			continue // skip files with no changes
-		}
-		if filesShown >= maxFiles {
-			break
-		}
-
-		var statusParts []string
-		if file.Additions > 0 {
-			statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
-		}
-		if file.Deletions > 0 {
-			statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
-		}
-
-		extraContent := strings.Join(statusParts, " ")
-		cwd := config.Get().WorkingDir() + string(os.PathSeparator)
-		filePath := file.FilePath
-		filePath = strings.TrimPrefix(filePath, cwd)
-		filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
-		filePath = ansi.Truncate(filePath, m.getMaxWidth()-lipgloss.Width(extraContent)-2, "…")
-		fileList = append(fileList,
-			core.Status(
-				core.StatusOpts{
-					IconColor:    t.FgMuted,
-					NoIcon:       true,
-					Title:        filePath,
-					ExtraContent: extraContent,
-				},
-				m.getMaxWidth(),
-			),
-		)
-		filesShown++
-	}
-
-	// Add indicator if there are more files
-	totalFilesWithChanges := 0
-	for _, file := range files {
-		if file.Additions > 0 || file.Deletions > 0 {
-			totalFilesWithChanges++
-		}
-	}
-	if totalFilesWithChanges > maxFiles {
-		remaining := totalFilesWithChanges - maxFiles
-		fileList = append(fileList,
-			t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
-		)
-	}
-
-	return lipgloss.JoinVertical(
-		lipgloss.Left,
-		fileList...,
-	)
+	maxFiles = min(len(fileSlice), maxFiles)
+
+	return files.RenderFileBlock(fileSlice, files.RenderOptions{
+		MaxWidth:    m.getMaxWidth(),
+		MaxItems:    maxFiles,
+		ShowSection: true,
+		SectionName: core.Section("Modified Files", m.getMaxWidth()),
+	}, true)
 }
 
 func (m *sidebarCmp) lspBlock() string {
-	t := styles.CurrentTheme()
-
-	section := t.S().Subtle.Render(
-		core.Section("LSPs", m.getMaxWidth()),
-	)
-
-	lspList := []string{section, ""}
-
-	lsp := config.Get().LSP.Sorted()
-	if len(lsp) == 0 {
-		return lipgloss.JoinVertical(
-			lipgloss.Left,
-			section,
-			"",
-			t.S().Base.Foreground(t.Border).Render("None"),
-		)
-	}
-
 	// Limit the number of LSPs shown
 	_, maxLSPs, _ := m.getDynamicLimits()
-	maxLSPs = min(len(lsp), maxLSPs)
-	for i, l := range lsp {
-		if i >= maxLSPs {
-			break
-		}
-
-		iconColor := t.Success
-		if l.LSP.Disabled {
-			iconColor = t.FgMuted
-		}
-		lspErrs := map[protocol.DiagnosticSeverity]int{
-			protocol.SeverityError:       0,
-			protocol.SeverityWarning:     0,
-			protocol.SeverityHint:        0,
-			protocol.SeverityInformation: 0,
-		}
-		if client, ok := m.lspClients[l.Name]; ok {
-			for _, diagnostics := range client.GetDiagnostics() {
-				for _, diagnostic := range diagnostics {
-					if severity, ok := lspErrs[diagnostic.Severity]; ok {
-						lspErrs[diagnostic.Severity] = severity + 1
-					}
-				}
-			}
-		}
-
-		errs := []string{}
-		if lspErrs[protocol.SeverityError] > 0 {
-			errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
-		}
-		if lspErrs[protocol.SeverityWarning] > 0 {
-			errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
-		}
-		if lspErrs[protocol.SeverityHint] > 0 {
-			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
-		}
-		if lspErrs[protocol.SeverityInformation] > 0 {
-			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
-		}
-
-		lspList = append(lspList,
-			core.Status(
-				core.StatusOpts{
-					IconColor:    iconColor,
-					Title:        l.Name,
-					Description:  l.LSP.Command,
-					ExtraContent: strings.Join(errs, " "),
-				},
-				m.getMaxWidth(),
-			),
-		)
-	}
-
-	// Add indicator if there are more LSPs
-	if len(lsp) > maxLSPs {
-		remaining := len(lsp) - maxLSPs
-		lspList = append(lspList,
-			t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
-		)
-	}
-
-	return lipgloss.JoinVertical(
-		lipgloss.Left,
-		lspList...,
-	)
+	lspConfigs := config.Get().LSP.Sorted()
+	maxLSPs = min(len(lspConfigs), maxLSPs)
+
+	return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
+		MaxWidth:    m.getMaxWidth(),
+		MaxItems:    maxLSPs,
+		ShowSection: true,
+		SectionName: core.Section("LSPs", m.getMaxWidth()),
+	}, true)
 }
 
 func (m *sidebarCmp) mcpBlock() string {
-	t := styles.CurrentTheme()
-
-	section := t.S().Subtle.Render(
-		core.Section("MCPs", m.getMaxWidth()),
-	)
-
-	mcpList := []string{section, ""}
-
-	mcps := config.Get().MCP.Sorted()
-	if len(mcps) == 0 {
-		return lipgloss.JoinVertical(
-			lipgloss.Left,
-			section,
-			"",
-			t.S().Base.Foreground(t.Border).Render("None"),
-		)
-	}
-
 	// Limit the number of MCPs shown
 	_, _, maxMCPs := m.getDynamicLimits()
+	mcps := config.Get().MCP.Sorted()
 	maxMCPs = min(len(mcps), maxMCPs)
-	for i, l := range mcps {
-		if i >= maxMCPs {
-			break
-		}
-
-		iconColor := t.Success
-		if l.MCP.Disabled {
-			iconColor = t.FgMuted
-		}
-		mcpList = append(mcpList,
-			core.Status(
-				core.StatusOpts{
-					IconColor:   iconColor,
-					Title:       l.Name,
-					Description: l.MCP.Command,
-				},
-				m.getMaxWidth(),
-			),
-		)
-	}
-
-	// Add indicator if there are more MCPs
-	if len(mcps) > maxMCPs {
-		remaining := len(mcps) - maxMCPs
-		mcpList = append(mcpList,
-			t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
-		)
-	}
 
-	return lipgloss.JoinVertical(
-		lipgloss.Left,
-		mcpList...,
-	)
+	return mcp.RenderMCPBlock(mcp.RenderOptions{
+		MaxWidth:    m.getMaxWidth(),
+		MaxItems:    maxMCPs,
+		ShowSection: true,
+		SectionName: core.Section("MCPs", m.getMaxWidth()),
+	}, true)
 }
 
 func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {

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

@@ -3,7 +3,6 @@ package splash
 import (
 	"fmt"
 	"os"
-	"slices"
 	"strings"
 	"time"
 
@@ -18,6 +17,8 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
 	"github.com/charmbracelet/crush/internal/tui/components/logo"
+	lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
+	"github.com/charmbracelet/crush/internal/tui/components/mcp"
 	"github.com/charmbracelet/crush/internal/tui/exp/list"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
@@ -101,27 +102,6 @@ func New() Splash {
 
 func (s *splashCmp) SetOnboarding(onboarding bool) {
 	s.isOnboarding = onboarding
-	if onboarding {
-		providers, err := config.Providers()
-		if err != nil {
-			return
-		}
-		filteredProviders := []catwalk.Provider{}
-		simpleProviders := []string{
-			"anthropic",
-			"openai",
-			"gemini",
-			"xai",
-			"groq",
-			"openrouter",
-		}
-		for _, p := range providers {
-			if slices.Contains(simpleProviders, string(p.ID)) {
-				filteredProviders = append(filteredProviders, p)
-			}
-		}
-		s.modelList.SetProviders(filteredProviders)
-	}
 }
 
 func (s *splashCmp) SetProjectInit(needsInit bool) {
@@ -655,7 +635,7 @@ func (s *splashCmp) Bindings() []key.Binding {
 }
 
 func (s *splashCmp) getMaxInfoWidth() int {
-	return min(s.width-2, 40) // 2 for left padding
+	return min(s.width-2, 90) // 2 for left padding
 }
 
 func (s *splashCmp) cwd() string {
@@ -670,29 +650,10 @@ func (s *splashCmp) cwd() string {
 }
 
 func LSPList(maxWidth int) []string {
-	t := styles.CurrentTheme()
-	lspList := []string{}
-	lsp := config.Get().LSP.Sorted()
-	if len(lsp) == 0 {
-		return []string{t.S().Base.Foreground(t.Border).Render("None")}
-	}
-	for _, l := range lsp {
-		iconColor := t.Success
-		if l.LSP.Disabled {
-			iconColor = t.FgMuted
-		}
-		lspList = append(lspList,
-			core.Status(
-				core.StatusOpts{
-					IconColor:   iconColor,
-					Title:       l.Name,
-					Description: l.LSP.Command,
-				},
-				maxWidth,
-			),
-		)
-	}
-	return lspList
+	return lspcomponent.RenderLSPList(nil, lspcomponent.RenderOptions{
+		MaxWidth:    maxWidth,
+		ShowSection: false,
+	})
 }
 
 func (s *splashCmp) lspBlock() string {
@@ -709,29 +670,10 @@ func (s *splashCmp) lspBlock() string {
 }
 
 func MCPList(maxWidth int) []string {
-	t := styles.CurrentTheme()
-	mcpList := []string{}
-	mcps := config.Get().MCP.Sorted()
-	if len(mcps) == 0 {
-		return []string{t.S().Base.Foreground(t.Border).Render("None")}
-	}
-	for _, l := range mcps {
-		iconColor := t.Success
-		if l.MCP.Disabled {
-			iconColor = t.FgMuted
-		}
-		mcpList = append(mcpList,
-			core.Status(
-				core.StatusOpts{
-					IconColor:   iconColor,
-					Title:       l.Name,
-					Description: l.MCP.Command,
-				},
-				maxWidth,
-			),
-		)
-	}
-	return mcpList
+	return mcp.RenderMCPList(mcp.RenderOptions{
+		MaxWidth:    maxWidth,
+		ShowSection: false,
+	})
 }
 
 func (s *splashCmp) mcpBlock() string {

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

@@ -82,41 +82,30 @@ func Title(title string, width int) string {
 }
 
 type StatusOpts struct {
-	Icon             string
-	IconColor        color.Color
-	NoIcon           bool // If true, no icon will be displayed
+	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
+	ExtraContent     string // additional content to append after the description
 }
 
-func Status(ops StatusOpts, width int) string {
+func Status(opts StatusOpts, width int) string {
 	t := styles.CurrentTheme()
-	icon := "●"
-	iconColor := t.Success
-	if ops.Icon != "" {
-		icon = ops.Icon
-	} else if ops.NoIcon {
-		icon = ""
-	}
-	if ops.IconColor != nil {
-		iconColor = ops.IconColor
-	}
-	title := ops.Title
+	icon := opts.Icon
+	title := opts.Title
 	titleColor := t.FgMuted
-	if ops.TitleColor != nil {
-		titleColor = ops.TitleColor
+	if opts.TitleColor != nil {
+		titleColor = opts.TitleColor
 	}
-	description := ops.Description
+	description := opts.Description
 	descriptionColor := t.FgSubtle
-	if ops.DescriptionColor != nil {
-		descriptionColor = ops.DescriptionColor
+	if opts.DescriptionColor != nil {
+		descriptionColor = opts.DescriptionColor
 	}
 	title = t.S().Base.Foreground(titleColor).Render(title)
 	if description != "" {
-		extraContentWidth := lipgloss.Width(ops.ExtraContent)
+		extraContentWidth := lipgloss.Width(opts.ExtraContent)
 		if extraContentWidth > 0 {
 			extraContentWidth += 1
 		}
@@ -126,11 +115,11 @@ func Status(ops StatusOpts, width int) string {
 
 	content := []string{}
 	if icon != "" {
-		content = append(content, t.S().Base.Foreground(iconColor).Render(icon))
+		content = append(content, icon)
 	}
 	content = append(content, title, description)
-	if ops.ExtraContent != "" {
-		content = append(content, ops.ExtraContent)
+	if opts.ExtraContent != "" {
+		content = append(content, opts.ExtraContent)
 	}
 
 	return strings.Join(content, " ")

internal/tui/components/core/status_test.go πŸ”—

@@ -37,7 +37,6 @@ func TestStatus(t *testing.T) {
 		{
 			name: "NoIcon",
 			opts: core.StatusOpts{
-				NoIcon:      true,
 				Title:       "Info",
 				Description: "This status has no icon",
 			},
@@ -47,7 +46,6 @@ func TestStatus(t *testing.T) {
 			name: "WithColors",
 			opts: core.StatusOpts{
 				Icon:             "⚠",
-				IconColor:        color.RGBA{255, 165, 0, 255}, // Orange
 				Title:            "Warning",
 				TitleColor:       color.RGBA{255, 255, 0, 255}, // Yellow
 				Description:      "This is a warning message",
@@ -102,7 +100,6 @@ func TestStatus(t *testing.T) {
 			name: "AllFieldsWithExtraContent",
 			opts: core.StatusOpts{
 				Icon:             "πŸš€",
-				IconColor:        color.RGBA{0, 255, 0, 255}, // Green
 				Title:            "Deployment",
 				TitleColor:       color.RGBA{0, 0, 255, 255}, // Blue
 				Description:      "Deploying to production environment",

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

@@ -119,7 +119,10 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.WindowSizeMsg:
 		c.wWidth = msg.Width
 		c.wHeight = msg.Height
-		return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
+		return c, tea.Batch(
+			c.SetCommandType(c.commandType),
+			c.commandList.SetSize(c.listWidth(), c.listHeight()),
+		)
 	case tea.KeyPressMsg:
 		switch {
 		case key.Matches(msg, c.keyMap.Select):
@@ -318,7 +321,6 @@ func (c *commandDialogCmp) defaultCommands() []Command {
 			})
 		}
 	}
-
 	// Only show toggle compact mode command if window width is larger than compact breakpoint (90)
 	if c.wWidth > 120 && c.sessionID != "" {
 		commands = append(commands, Command{

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

@@ -3,6 +3,7 @@ package models
 import (
 	"fmt"
 	"slices"
+	"strings"
 
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
@@ -49,7 +50,15 @@ func (m *ModelListComponent) Init() tea.Cmd {
 	var cmds []tea.Cmd
 	if len(m.providers) == 0 {
 		providers, err := config.Providers()
-		m.providers = providers
+		filteredProviders := []catwalk.Provider{}
+		for _, p := range providers {
+			hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$")
+			if hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure {
+				filteredProviders = append(filteredProviders, p)
+			}
+		}
+
+		m.providers = filteredProviders
 		if err != nil {
 			cmds = append(cmds, util.ReportError(err))
 		}
@@ -242,7 +251,3 @@ func (m *ModelListComponent) GetModelType() int {
 func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
 	m.list.SetInputPlaceholder(placeholder)
 }
-
-func (m *ModelListComponent) SetProviders(providers []catwalk.Provider) {
-	m.providers = providers
-}

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

@@ -2,7 +2,6 @@ package models
 
 import (
 	"fmt"
-	"slices"
 	"time"
 
 	"github.com/charmbracelet/bubbles/v2/help"
@@ -96,24 +95,6 @@ func NewModelDialogCmp() ModelDialog {
 }
 
 func (m *modelDialogCmp) Init() tea.Cmd {
-	providers, err := config.Providers()
-	if err == nil {
-		filteredProviders := []catwalk.Provider{}
-		simpleProviders := []string{
-			"anthropic",
-			"openai",
-			"gemini",
-			"xai",
-			"groq",
-			"openrouter",
-		}
-		for _, p := range providers {
-			if slices.Contains(simpleProviders, string(p.ID)) {
-				filteredProviders = append(filteredProviders, p)
-			}
-		}
-		m.modelList.SetProviders(filteredProviders)
-	}
 	return tea.Batch(m.modelList.Init(), m.apiKeyInput.Init())
 }
 

internal/tui/components/files/files.go πŸ”—

@@ -0,0 +1,143 @@
+package files
+
+import (
+	"fmt"
+	"os"
+	"sort"
+	"strings"
+
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/ansi"
+
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/tui/components/core"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+)
+
+// FileHistory represents a file history with initial and latest versions.
+type FileHistory struct {
+	InitialVersion history.File
+	LatestVersion  history.File
+}
+
+// SessionFile represents a file with its history information.
+type SessionFile struct {
+	History   FileHistory
+	FilePath  string
+	Additions int
+	Deletions int
+}
+
+// RenderOptions contains options for rendering file lists.
+type RenderOptions struct {
+	MaxWidth    int
+	MaxItems    int
+	ShowSection bool
+	SectionName string
+}
+
+// RenderFileList renders a list of file status items with the given options.
+func RenderFileList(fileSlice []SessionFile, opts RenderOptions) []string {
+	t := styles.CurrentTheme()
+	fileList := []string{}
+
+	if opts.ShowSection {
+		sectionName := opts.SectionName
+		if sectionName == "" {
+			sectionName = "Modified Files"
+		}
+		section := t.S().Subtle.Render(sectionName)
+		fileList = append(fileList, section, "")
+	}
+
+	if len(fileSlice) == 0 {
+		fileList = append(fileList, t.S().Base.Foreground(t.Border).Render("None"))
+		return fileList
+	}
+
+	// Sort files by the latest version's created time
+	sort.Slice(fileSlice, func(i, j int) bool {
+		if fileSlice[i].History.LatestVersion.CreatedAt == fileSlice[j].History.LatestVersion.CreatedAt {
+			return strings.Compare(fileSlice[i].FilePath, fileSlice[j].FilePath) < 0
+		}
+		return fileSlice[i].History.LatestVersion.CreatedAt > fileSlice[j].History.LatestVersion.CreatedAt
+	})
+
+	// Determine how many items to show
+	maxItems := len(fileSlice)
+	if opts.MaxItems > 0 {
+		maxItems = min(opts.MaxItems, len(fileSlice))
+	}
+
+	filesShown := 0
+	for _, file := range fileSlice {
+		if file.Additions == 0 && file.Deletions == 0 {
+			continue // skip files with no changes
+		}
+		if filesShown >= maxItems {
+			break
+		}
+
+		var statusParts []string
+		if file.Additions > 0 {
+			statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
+		}
+		if file.Deletions > 0 {
+			statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
+		}
+
+		extraContent := strings.Join(statusParts, " ")
+		cwd := config.Get().WorkingDir() + string(os.PathSeparator)
+		filePath := file.FilePath
+		filePath = strings.TrimPrefix(filePath, cwd)
+		filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
+		filePath = ansi.Truncate(filePath, opts.MaxWidth-lipgloss.Width(extraContent)-2, "…")
+
+		fileList = append(fileList,
+			core.Status(
+				core.StatusOpts{
+					Title:        filePath,
+					ExtraContent: extraContent,
+				},
+				opts.MaxWidth,
+			),
+		)
+		filesShown++
+	}
+
+	return fileList
+}
+
+// RenderFileBlock renders a complete file block with optional truncation indicator.
+func RenderFileBlock(fileSlice []SessionFile, opts RenderOptions, showTruncationIndicator bool) string {
+	t := styles.CurrentTheme()
+	fileList := RenderFileList(fileSlice, opts)
+
+	// Add truncation indicator if needed
+	if showTruncationIndicator && opts.MaxItems > 0 {
+		totalFilesWithChanges := 0
+		for _, file := range fileSlice {
+			if file.Additions > 0 || file.Deletions > 0 {
+				totalFilesWithChanges++
+			}
+		}
+		if totalFilesWithChanges > opts.MaxItems {
+			remaining := totalFilesWithChanges - opts.MaxItems
+			if remaining == 1 {
+				fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("…"))
+			} else {
+				fileList = append(fileList,
+					t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
+				)
+			}
+		}
+	}
+
+	content := lipgloss.JoinVertical(lipgloss.Left, fileList...)
+	if opts.MaxWidth > 0 {
+		return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content)
+	}
+	return content
+}

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

@@ -0,0 +1,159 @@
+package lsp
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/charmbracelet/lipgloss/v2"
+
+	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/lsp/protocol"
+	"github.com/charmbracelet/crush/internal/tui/components/core"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+)
+
+// RenderOptions contains options for rendering LSP lists.
+type RenderOptions struct {
+	MaxWidth    int
+	MaxItems    int
+	ShowSection bool
+	SectionName string
+}
+
+// RenderLSPList renders a list of LSP status items with the given options.
+func RenderLSPList(lspClients map[string]*lsp.Client, opts RenderOptions) []string {
+	t := styles.CurrentTheme()
+	lspList := []string{}
+
+	if opts.ShowSection {
+		sectionName := opts.SectionName
+		if sectionName == "" {
+			sectionName = "LSPs"
+		}
+		section := t.S().Subtle.Render(sectionName)
+		lspList = append(lspList, section, "")
+	}
+
+	lspConfigs := config.Get().LSP.Sorted()
+	if len(lspConfigs) == 0 {
+		lspList = append(lspList, t.S().Base.Foreground(t.Border).Render("None"))
+		return lspList
+	}
+
+	// Get LSP states
+	lspStates := app.GetLSPStates()
+
+	// Determine how many items to show
+	maxItems := len(lspConfigs)
+	if opts.MaxItems > 0 {
+		maxItems = min(opts.MaxItems, len(lspConfigs))
+	}
+
+	for i, l := range lspConfigs {
+		if i >= maxItems {
+			break
+		}
+
+		// Determine icon color and description based on state
+		icon := t.ItemOfflineIcon
+		description := l.LSP.Command
+
+		if l.LSP.Disabled {
+			description = t.S().Subtle.Render("disabled")
+		} else if state, exists := lspStates[l.Name]; exists {
+			switch state.State {
+			case lsp.StateStarting:
+				icon = t.ItemBusyIcon
+				description = t.S().Subtle.Render("starting...")
+			case lsp.StateReady:
+				icon = t.ItemOnlineIcon
+				description = l.LSP.Command
+			case lsp.StateError:
+				icon = t.ItemErrorIcon
+				if state.Error != nil {
+					description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
+				} else {
+					description = t.S().Subtle.Render("error")
+				}
+			}
+		}
+
+		// Calculate diagnostic counts if we have LSP clients
+		var extraContent string
+		if lspClients != nil {
+			lspErrs := map[protocol.DiagnosticSeverity]int{
+				protocol.SeverityError:       0,
+				protocol.SeverityWarning:     0,
+				protocol.SeverityHint:        0,
+				protocol.SeverityInformation: 0,
+			}
+			if client, ok := lspClients[l.Name]; ok {
+				for _, diagnostics := range client.GetDiagnostics() {
+					for _, diagnostic := range diagnostics {
+						if severity, ok := lspErrs[diagnostic.Severity]; ok {
+							lspErrs[diagnostic.Severity] = severity + 1
+						}
+					}
+				}
+			}
+
+			errs := []string{}
+			if lspErrs[protocol.SeverityError] > 0 {
+				errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
+			}
+			if lspErrs[protocol.SeverityWarning] > 0 {
+				errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
+			}
+			if lspErrs[protocol.SeverityHint] > 0 {
+				errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
+			}
+			if lspErrs[protocol.SeverityInformation] > 0 {
+				errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
+			}
+			extraContent = strings.Join(errs, " ")
+		}
+
+		lspList = append(lspList,
+			core.Status(
+				core.StatusOpts{
+					Icon:         icon.String(),
+					Title:        l.Name,
+					Description:  description,
+					ExtraContent: extraContent,
+				},
+				opts.MaxWidth,
+			),
+		)
+	}
+
+	return lspList
+}
+
+// RenderLSPBlock renders a complete LSP block with optional truncation indicator.
+func RenderLSPBlock(lspClients map[string]*lsp.Client, opts RenderOptions, showTruncationIndicator bool) string {
+	t := styles.CurrentTheme()
+	lspList := RenderLSPList(lspClients, opts)
+
+	// Add truncation indicator if needed
+	if showTruncationIndicator && opts.MaxItems > 0 {
+		lspConfigs := config.Get().LSP.Sorted()
+		if len(lspConfigs) > opts.MaxItems {
+			remaining := len(lspConfigs) - opts.MaxItems
+			if remaining == 1 {
+				lspList = append(lspList, t.S().Base.Foreground(t.FgMuted).Render("…"))
+			} else {
+				lspList = append(lspList,
+					t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
+				)
+			}
+		}
+	}
+
+	content := lipgloss.JoinVertical(lipgloss.Left, lspList...)
+	if opts.MaxWidth > 0 {
+		return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content)
+	}
+	return content
+}

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

@@ -0,0 +1,126 @@
+package mcp
+
+import (
+	"fmt"
+
+	"github.com/charmbracelet/lipgloss/v2"
+
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/llm/agent"
+	"github.com/charmbracelet/crush/internal/tui/components/core"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+)
+
+// RenderOptions contains options for rendering MCP lists.
+type RenderOptions struct {
+	MaxWidth    int
+	MaxItems    int
+	ShowSection bool
+	SectionName string
+}
+
+// RenderMCPList renders a list of MCP status items with the given options.
+func RenderMCPList(opts RenderOptions) []string {
+	t := styles.CurrentTheme()
+	mcpList := []string{}
+
+	if opts.ShowSection {
+		sectionName := opts.SectionName
+		if sectionName == "" {
+			sectionName = "MCPs"
+		}
+		section := t.S().Subtle.Render(sectionName)
+		mcpList = append(mcpList, section, "")
+	}
+
+	mcps := config.Get().MCP.Sorted()
+	if len(mcps) == 0 {
+		mcpList = append(mcpList, t.S().Base.Foreground(t.Border).Render("None"))
+		return mcpList
+	}
+
+	// Get MCP states
+	mcpStates := agent.GetMCPStates()
+
+	// Determine how many items to show
+	maxItems := len(mcps)
+	if opts.MaxItems > 0 {
+		maxItems = min(opts.MaxItems, len(mcps))
+	}
+
+	for i, l := range mcps {
+		if i >= maxItems {
+			break
+		}
+
+		// Determine icon and color based on state
+		icon := t.ItemOfflineIcon
+		description := l.MCP.Command
+		extraContent := ""
+
+		if state, exists := mcpStates[l.Name]; exists {
+			switch state.State {
+			case agent.MCPStateDisabled:
+				description = t.S().Subtle.Render("disabled")
+			case agent.MCPStateStarting:
+				icon = t.ItemBusyIcon
+				description = t.S().Subtle.Render("starting...")
+			case agent.MCPStateConnected:
+				icon = t.ItemOnlineIcon
+				if state.ToolCount > 0 {
+					extraContent = t.S().Subtle.Render(fmt.Sprintf("(%d tools)", state.ToolCount))
+				}
+			case agent.MCPStateError:
+				icon = t.ItemErrorIcon
+				if state.Error != nil {
+					description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
+				} else {
+					description = t.S().Subtle.Render("error")
+				}
+			}
+		} else if l.MCP.Disabled {
+			description = t.S().Subtle.Render("disabled")
+		}
+
+		mcpList = append(mcpList,
+			core.Status(
+				core.StatusOpts{
+					Icon:         icon.String(),
+					Title:        l.Name,
+					Description:  description,
+					ExtraContent: extraContent,
+				},
+				opts.MaxWidth,
+			),
+		)
+	}
+
+	return mcpList
+}
+
+// RenderMCPBlock renders a complete MCP block with optional truncation indicator.
+func RenderMCPBlock(opts RenderOptions, showTruncationIndicator bool) string {
+	t := styles.CurrentTheme()
+	mcpList := RenderMCPList(opts)
+
+	// Add truncation indicator if needed
+	if showTruncationIndicator && opts.MaxItems > 0 {
+		mcps := config.Get().MCP.Sorted()
+		if len(mcps) > opts.MaxItems {
+			remaining := len(mcps) - opts.MaxItems
+			if remaining == 1 {
+				mcpList = append(mcpList, t.S().Base.Foreground(t.FgMuted).Render("…"))
+			} else {
+				mcpList = append(mcpList,
+					t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
+				)
+			}
+		}
+	}
+
+	content := lipgloss.JoinVertical(lipgloss.Left, mcpList...)
+	if opts.MaxWidth > 0 {
+		return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content)
+	}
+	return content
+}

internal/tui/exp/list/list.go πŸ”—

@@ -3,6 +3,7 @@ package list
 import (
 	"slices"
 	"strings"
+	"sync"
 
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
@@ -90,6 +91,7 @@ type list[T Item] struct {
 
 	renderedItems *csync.Map[string, renderedItem]
 
+	renderMu sync.Mutex
 	rendered string
 
 	movingByItem bool
@@ -328,7 +330,9 @@ func (l *list[T]) render() tea.Cmd {
 	// we are not rendering the first time
 	if l.rendered != "" {
 		// rerender everything will mostly hit cache
+		l.renderMu.Lock()
 		l.rendered, _ = l.renderIterator(0, false, "")
+		l.renderMu.Unlock()
 		if l.direction == DirectionBackward {
 			l.recalculateItemPositions()
 		}
@@ -338,9 +342,10 @@ func (l *list[T]) render() tea.Cmd {
 		}
 		return focusChangeCmd
 	}
+	l.renderMu.Lock()
 	rendered, finishIndex := l.renderIterator(0, true, "")
 	l.rendered = rendered
-
+	l.renderMu.Unlock()
 	// recalculate for the initial items
 	if l.direction == DirectionBackward {
 		l.recalculateItemPositions()
@@ -348,7 +353,10 @@ func (l *list[T]) render() tea.Cmd {
 	renderCmd := func() tea.Msg {
 		l.offset = 0
 		// render the rest
+
+		l.renderMu.Lock()
 		l.rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
+		l.renderMu.Unlock()
 		// needed for backwards
 		if l.direction == DirectionBackward {
 			l.recalculateItemPositions()
@@ -357,7 +365,6 @@ func (l *list[T]) render() tea.Cmd {
 		if l.focused {
 			l.scrollToSelection()
 		}
-
 		return nil
 	}
 	return tea.Batch(focusChangeCmd, renderCmd)

internal/tui/styles/crush.go πŸ”—

@@ -1,11 +1,12 @@
 package styles
 
 import (
+	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/exp/charmtone"
 )
 
 func NewCrushTheme() *Theme {
-	return &Theme{
+	t := &Theme{
 		Name:   "crush",
 		IsDark: true,
 
@@ -54,4 +55,12 @@ func NewCrushTheme() *Theme {
 		RedLight: charmtone.Salmon,
 		Cherry:   charmtone.Cherry,
 	}
+
+	// LSP and MCP status.
+	t.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("●")
+	t.ItemBusyIcon = t.ItemOfflineIcon.Foreground(charmtone.Citron)
+	t.ItemErrorIcon = t.ItemOfflineIcon.Foreground(charmtone.Coral)
+	t.ItemOnlineIcon = t.ItemOfflineIcon.Foreground(charmtone.Guac)
+
+	return t
 }

internal/tui/styles/theme.go πŸ”—

@@ -74,6 +74,12 @@ type Theme struct {
 	RedLight color.Color
 	Cherry   color.Color
 
+	// LSP and MCP status indicators.
+	ItemOfflineIcon lipgloss.Style
+	ItemBusyIcon    lipgloss.Style
+	ItemErrorIcon   lipgloss.Style
+	ItemOnlineIcon  lipgloss.Style
+
 	styles *Styles
 }