on main help sign in
Merge branch 'charmbracelet:main' into esc_permission
commit05e4c80fdb80296f32e4f2842763c3fc81429ef1 authorbbrodriges <bender.rodriges@gmail.com> dateTue 5 Aug 2025, 17:31 (1 year ago) parent000c8d89a9861c
45 files changed+1264−692
.github/cla-signatures.json+48 .github/dependabot.yml+2−2 .github/workflows/build.yml+2 .goreleaser.yml+6 CRUSH.md+2 README.md+107−8 go.mod+13−6 go.sum+30−14 internal/app/app.go+2 internal/app/lsp.go+11−1 internal/app/lsp_events.go+102 internal/llm/agent/agent.go+37−31 internal/llm/agent/mcp-tools.go+142−7 internal/llm/provider/openai.go+71−67 internal/llm/tools/tools.go+74−1 internal/lsp/client.go+18−1 internal/lsp/handlers.go+12−2 internal/tui/components/chat/messages/messages.go+3−1 internal/tui/components/chat/sidebar/sidebar.go+76−411 internal/tui/components/chat/splash/splash.go+11−69 internal/tui/components/core/core.go+14−25 internal/tui/components/core/status_test.go−3 internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden+1−1 internal/tui/components/core/testdata/TestStatus/Default.golden+1−1 internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden+1−1 internal/tui/components/core/testdata/TestStatus/LongDescription.golden+1−1 internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden+1−1 internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden+1−1 internal/tui/components/core/testdata/TestStatus/WithColors.golden+1−1 internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden+1−1 internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden+1−1 internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden+1−1 internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden+1−1 internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden+1−1 internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden+1−1 internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden+1−1 internal/tui/components/dialogs/commands/commands.go+4−2 internal/tui/components/dialogs/models/list.go+10−5 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+9−2 internal/tui/styles/crush.go+10−1 internal/tui/styles/theme.go+6
.github/cla-signatures.json+48
@@ -199,6 +199,54 @@
199199 "created_at": "2025-08-03T04:07:16Z",
200200 "repoId": 987670088,
201201 "pullRequestNo": 519
202+ },
203+ {
204+ "name": "jooray",
205+ "id": 1028688,
206+ "comment_id": 3148713433,
207+ "created_at": "2025-08-03T21:35:15Z",
208+ "repoId": 987670088,
209+ "pullRequestNo": 527
210+ },
211+ {
212+ "name": "Ed4ward",
213+ "id": 153800328,
214+ "comment_id": 3150375016,
215+ "created_at": "2025-08-04T12:16:16Z",
216+ "repoId": 987670088,
217+ "pullRequestNo": 539
218+ },
219+ {
220+ "name": "ngnhng",
221+ "id": 51743767,
222+ "comment_id": 3150846779,
223+ "created_at": "2025-08-04T14:01:30Z",
224+ "repoId": 987670088,
225+ "pullRequestNo": 546
226+ },
227+ {
228+ "name": "zloeber",
229+ "id": 4702624,
230+ "comment_id": 3152513500,
231+ "created_at": "2025-08-04T21:55:42Z",
232+ "repoId": 987670088,
233+ "pullRequestNo": 564
234+ },
235+ {
236+ "name": "nelsenm2",
237+ "id": 197524521,
238+ "comment_id": 3152872109,
239+ "created_at": "2025-08-05T00:24:50Z",
240+ "repoId": 987670088,
241+ "pullRequestNo": 569
242+ },
243+ {
244+ "name": "mohseenrm",
245+ "id": 10768371,
246+ "comment_id": 3153159347,
247+ "created_at": "2025-08-05T03:39:12Z",
248+ "repoId": 987670088,
249+ "pullRequestNo": 574
202250 }
203251 ]
204252}
.github/dependabot.yml+2−2
@@ -9,7 +9,7 @@ updates:
99 time: "05:00"
1010 timezone: "America/New_York"
1111 labels:
12− - "dependencies"
12+ - "area: dependencies"
1313 commit-message:
1414 prefix: "chore"
1515 include: "scope"
@@ -22,7 +22,7 @@ updates:
2222 time: "05:00"
2323 timezone: "America/New_York"
2424 labels:
25− - "dependencies"
25+ - "area: dependencies"
2626 commit-message:
2727 prefix: "chore"
2828 include: "scope"
.github/workflows/build.yml+2
@@ -23,7 +23,9 @@ jobs:
2323 with:
2424 github-token: "${{ secrets.GITHUB_TOKEN }}"
2525 - run: |
26+ echo "Approving..."
2627 gh pr review --approve "$PR_URL"
28+ echo "Merging..."
2729 gh pr merge --squash --auto "$PR_URL"
2830 env:
2931 PR_URL: ${{github.event.pull_request.html_url}}
.goreleaser.yml+6
@@ -166,6 +166,12 @@ brews:
166166 fish_completion.install "completions/{{ .ProjectName }}.fish"
167167 man1.install "manpages/{{ .ProjectName }}.1.gz"
168168
169+scoops:
170+ - repository:
171+ owner: charmbracelet
172+ name: scoop-bucket
173+ token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
174+
169175npms:
170176 - name: "@charmland/crush"
171177 repository: "git+https://github.com/charmbracelet/crush.git"
CRUSH.md+2
@@ -4,6 +4,8 @@
44
55- **Build**: `go build .` or `go run .`
66- **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`)
7+- **Update Golden Files**: `go test ./... -update` (regenerates .golden files when test output changes)
8+ - Update specific package: `go test ./internal/tui/components/core -update` (in this case, we're updating "core")
79- **Lint**: `task lint-fix`
810- **Format**: `task fmt` (gofumpt -w .)
911- **Dev**: `task dev` (runs with profiling enabled)
README.md+107−8
@@ -39,7 +39,7 @@ nix run github:numtide/nix-ai-tools#crush
3939
4040<details>
4141<summary><strong>Nix (NUR)</strong></summary>
42−
42+
4343Crush is available via [NUR](https://github.com/nix-community/NUR) in `nur.repos.charmbracelet.crush`.
4444
4545You 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
138138customize Crush, configuration can be added either local to the project itself,
139139or globally, with the following priority:
140140
141−1. `./.crush.json`
142−2. `./crush.json`
143−3. `$HOME/.config/crush/crush.json`
141+1. `.crush.json`
142+2. `crush.json`
143+3. `$HOME/.config/crush/crush.json` (Windows: `%USERPROFILE%\AppData\Local\crush\crush.json`)
144144
145145Configuration itself is stored as a JSON object:
146146
147147```json
148148{
149− "this-setting": { }
150− "that-setting": { }
149+ "this-setting": {"this": "that"},
150+ "that-setting": ["ceci", "cela"]
151151}
152152```
153153
154+As an additional note, Crush also stores ephemeral data, such as application state, in one additional location:
155+
156+```bash
157+# Unix
158+$HOME/.local/shared/crush/crush.json
159+
160+# Windows
161+%LOCALAPPDATA%\crush\crush.json
162+```
163+
154164### LSPs
155165
156166Crush 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.
221231The `.crushignore` file uses the same syntax as `.gitignore` and can be placed
222232in the root of your project or in subdirectories.
223233
224−### Whitelisting Tools
234+### Allowing Tools
225235
226236By default, Crush will ask you for permission before running tool calls. If
227−you'd like, you can whitelist tools to be executed without prompting you for
237+you'd like, you can allow tools to be executed without prompting you for
228238permissions. Use this with care.
229239
230240```json
@@ -245,6 +255,53 @@ permissions. Use this with care.
245255You can also skip all permission prompts entirely by running Crush with the
246256`--yolo` flag. Be very, very careful with this feature.
247257
258+### Local Models
259+
260+Local models can also be configured via OpenAI-compatible API. Here are two common examples:
261+
262+#### Ollama
263+
264+```json
265+{
266+ "providers": {
267+ "ollama": {
268+ "name": "Ollama",
269+ "base_url": "http://localhost:11434/v1/",
270+ "type": "openai",
271+ "models": [
272+ {
273+ "name": "Qwen 3 30B",
274+ "id": "qwen3:30b",
275+ "context_window": 256000,
276+ "default_max_tokens": 20000
277+ }
278+ ]
279+ }
280+}
281+```
282+
283+#### LM Studio
284+
285+```json
286+{
287+ "providers": {
288+ "lmstudio": {
289+ "name": "LM Studio",
290+ "base_url": "http://localhost:1234/v1/",
291+ "type": "openai",
292+ "models": [
293+ {
294+ "name": "Qwen 3 30B",
295+ "id": "qwen/qwen3-30b-a3b-2507",
296+ "context_window": 256000,
297+ "default_max_tokens": 20000
298+ }
299+ ]
300+ }
301+ }
302+}
303+```
304+
248305### Custom Providers
249306
250307Crush supports custom provider configurations for both OpenAI-compatible and
@@ -314,6 +371,48 @@ Custom Anthropic-compatible providers follow this format:
314371}
315372```
316373
374+### Amazon Bedrock
375+
376+Crush currently supports running Anthropic models through Bedrock, with caching disabled.
377+
378+* A Bedrock provider will appear once you have AWS configured, i.e. `aws configure`
379+* Crush also expects the `AWS_REGION` or `AWS_DEFAULT_REGION` to be set
380+* To use a specific AWS profile set `AWS_PROFILE` in your environment, i.e. `AWS_PROFILE=myprofile crush`
381+
382+### Vertex AI Platform
383+
384+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:
385+
386+```bash
387+gcloud auth application-default login
388+```
389+
390+To add specific models to the configuration, configure as such:
391+
392+```json
393+{
394+ "$schema": "https://charm.land/crush.json",
395+ "providers": {
396+ "vertexai": {
397+ "models": [
398+ {
399+ "id": "claude-sonnet-4@20250514",
400+ "name": "VertexAI Sonnet 4",
401+ "cost_per_1m_in": 3,
402+ "cost_per_1m_out": 15,
403+ "cost_per_1m_in_cached": 3.75,
404+ "cost_per_1m_out_cached": 0.3,
405+ "context_window": 200000,
406+ "default_max_tokens": 50000,
407+ "can_reason": true,
408+ "supports_attachments": true
409+ }
410+ ]
411+ }
412+ }
413+}
414+```
415+
317416## Logging
318417
319418Sometimes you need to look at logs. Luckily, Crush logs all sorts of
go.mod+13−6
@@ -6,7 +6,7 @@ require (
66 github.com/JohannesKaufmann/html-to-markdown v1.6.0
77 github.com/MakeNowJust/heredoc v1.0.0
88 github.com/PuerkitoBio/goquery v1.9.2
9− github.com/alecthomas/chroma/v2 v2.15.0
9+ github.com/alecthomas/chroma/v2 v2.20.0
1010 github.com/anthropics/anthropic-sdk-go v1.6.2
1111 github.com/atotto/clipboard v0.1.4
1212 github.com/aymanbagabas/go-udiff v0.3.1
@@ -14,7 +14,7 @@ require (
1414 github.com/charlievieth/fastwalk v1.0.11
1515 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
1616 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d
17− github.com/charmbracelet/catwalk v0.3.5
17+ github.com/charmbracelet/catwalk v0.4.6
1818 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
1919 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
2020 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250721205738-ea66aa652ee0
@@ -27,7 +27,7 @@ require (
2727 github.com/google/uuid v1.6.0
2828 github.com/invopop/jsonschema v0.13.0
2929 github.com/joho/godotenv v1.5.1
30− github.com/mark3labs/mcp-go v0.34.0
30+ github.com/mark3labs/mcp-go v0.37.0
3131 github.com/muesli/termenv v0.16.0
3232 github.com/ncruces/go-sqlite3 v0.25.0
3333 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
@@ -51,12 +51,19 @@ require (
5151require (
5252 cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
5353 github.com/bahlo/generic-list-go v0.2.0 // indirect
54+ github.com/beorn7/perks v1.0.1 // indirect
5455 github.com/buger/jsonparser v1.1.1 // indirect
56+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
5557 github.com/mailru/easyjson v0.7.7 // indirect
58+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
59+ github.com/prometheus/client_golang v1.23.0 // indirect
60+ github.com/prometheus/client_model v0.6.2 // indirect
61+ github.com/prometheus/common v0.65.0 // indirect
62+ github.com/prometheus/procfs v0.16.1 // indirect
5663 github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
5764 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
5865 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
59− golang.org/x/oauth2 v0.25.0 // indirect
66+ golang.org/x/oauth2 v0.30.0 // indirect
6067 golang.org/x/time v0.8.0 // indirect
6168 google.golang.org/api v0.211.0 // indirect
6269)
@@ -93,7 +100,7 @@ require (
93100 github.com/charmbracelet/x/windows v0.2.1 // indirect
94101 github.com/davecgh/go-spew v1.1.1 // indirect
95102 github.com/disintegration/gift v1.1.2 // indirect
96− github.com/dlclark/regexp2 v1.11.4 // indirect
103+ github.com/dlclark/regexp2 v1.11.5 // indirect
97104 github.com/dustin/go-humanize v1.0.1 // indirect
98105 github.com/felixge/httpsnoop v1.0.4 // indirect
99106 github.com/go-logfmt/logfmt v0.6.0 // indirect
@@ -144,7 +151,7 @@ require (
144151 golang.org/x/sync v0.16.0 // indirect
145152 golang.org/x/sys v0.34.0
146153 golang.org/x/term v0.32.0 // indirect
147− golang.org/x/text v0.25.0
154+ golang.org/x/text v0.27.0
148155 google.golang.org/genai v1.3.0
149156 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
150157 google.golang.org/grpc v1.71.0 // indirect
go.sum+30−14
@@ -22,10 +22,10 @@ github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4
2222github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
2323github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
2424github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
25−github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
26−github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
27−github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
28−github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
25+github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
26+github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
27+github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
28+github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
2929github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
3030github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
3131github.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
6868github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
6969github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
7070github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
71+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
72+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
7173github.com/bmatcuk/doublestar/v4 v4.9.0 h1:DBvuZxjdKkRP/dr4GVV4w2fnmrk5Hxc90T51LZjv0JA=
7274github.com/bmatcuk/doublestar/v4 v4.9.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
7375github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
7476github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
77+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
78+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
7579github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8=
7680github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
7781github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5 h1:GTcMIfDQJKyNKS+xVt7GkNIwz+tBuQtIuiP50WpzNgs=
7882github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
7983github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d h1:YMXLZHSo8DjytVY/b5dK8LDuyQsVUmBK3ydQMpu2Ui4=
8084github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d/go.mod h1:XIQ1qQfRph6Z5o2EikCydjumo0oDInQySRHuPATzbZc=
81−github.com/charmbracelet/catwalk v0.3.5 h1:ChMvA5ooTNZhDKFagmGNQgIZvZp8XjpdaJ+cDmhgCgA=
82−github.com/charmbracelet/catwalk v0.3.5/go.mod h1:gUUCqqZ8bk4D7ZzGTu3I77k7cC2x4exRuJBN1H2u2pc=
85+github.com/charmbracelet/catwalk v0.4.5 h1:Kv3PadDe8IF8gpcYTfAJdCee5Bv4HufvtNT61FXtq5g=
86+github.com/charmbracelet/catwalk v0.4.5/go.mod h1:WnKgNPmQHuMyk7GtwAQwl+ezHusfH40IvzML2qwUGwc=
87+github.com/charmbracelet/catwalk v0.4.6 h1:Y0JDq5V4agK8oO3lKC/hhInrYXePGwZPNo8I1Lk08jc=
88+github.com/charmbracelet/catwalk v0.4.6/go.mod h1:WnKgNPmQHuMyk7GtwAQwl+ezHusfH40IvzML2qwUGwc=
8389github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
8490github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
8591github.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
118124github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
119125github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
120126github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
121−github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
122−github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
127+github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
128+github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
123129github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
124130github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
125131github.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
180186github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
181187github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
182188github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
183−github.com/mark3labs/mcp-go v0.34.0 h1:eWy7WBGvhk6EyAAyVzivTCprE52iXJwNtvHV6Cv3bR0=
184−github.com/mark3labs/mcp-go v0.34.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
189+github.com/mark3labs/mcp-go v0.37.0 h1:BywvZLPRT6Zx6mMG/MJfxLSZQkTGIcJSEGKsvr4DsoQ=
190+github.com/mark3labs/mcp-go v0.37.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
185191github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
186192github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
187193github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
@@ -202,6 +208,8 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
202208github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
203209github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
204210github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
211+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
212+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
205213github.com/ncruces/go-sqlite3 v0.25.0 h1:trugKUs98Zwy9KwRr/EUxZHL92LYt7UqcKqAfpGpK+I=
206214github.com/ncruces/go-sqlite3 v0.25.0/go.mod h1:n6Z7036yFilJx04yV0mi5JWaF66rUmXn1It9Ux8dx68=
207215github.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
221229github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
222230github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJzYU=
223231github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k=
232+github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
233+github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
234+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
235+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
236+github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
237+github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
238+github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
239+github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
224240github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c h1:kmzxiX+OB0knCo1V0dkEkdPelzCdAzCURCfmFArn2/A=
225241github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4=
226242github.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=
332348golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
333349golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
334350golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
335−golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
336−golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
351+golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
352+golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
337353golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
338354golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
339355golang.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=
371387golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
372388golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
373389golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
374−golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
375−golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
390+golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
391+golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
376392golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
377393golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
378394golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
internal/app/app.go+2
@@ -207,6 +207,8 @@ func (app *App) setupEvents() {
207207 setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
208208 setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events)
209209 setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
210+ setupSubscriber(ctx, app.serviceEventsWG, "mcp", agent.SubscribeMCPEvents, app.events)
211+ setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events)
210212 cleanupFunc := func() {
211213 cancel()
212214 app.serviceEventsWG.Wait()
internal/app/lsp.go+11−1
@@ -22,13 +22,20 @@ func (app *App) initLSPClients(ctx context.Context) {
2222func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) {
2323 slog.Info("Creating LSP client", "name", name, "command", command, "args", args)
2424
25+ // Update state to starting
26+ updateLSPState(name, lsp.StateStarting, nil, nil, 0)
27+
2528 // Create LSP client.
26− lspClient, err := lsp.NewClient(ctx, command, args...)
29+ lspClient, err := lsp.NewClient(ctx, name, command, args...)
2730 if err != nil {
2831 slog.Error("Failed to create LSP client for", name, err)
32+ updateLSPState(name, lsp.StateError, err, nil, 0)
2933 return
3034 }
3135
36+ // Set diagnostics callback
37+ lspClient.SetDiagnosticsCallback(updateLSPDiagnostics)
38+
3239 // Increase initialization timeout as some servers take more time to start.
3340 initCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
3441 defer cancel()
@@ -37,6 +44,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
3744 _, err = lspClient.InitializeLSPClient(initCtx, app.config.WorkingDir())
3845 if err != nil {
3946 slog.Error("Initialize failed", "name", name, "error", err)
47+ updateLSPState(name, lsp.StateError, err, lspClient, 0)
4048 lspClient.Close()
4149 return
4250 }
@@ -47,10 +55,12 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
4755 // Server never reached a ready state, but let's continue anyway, as
4856 // some functionality might still work.
4957 lspClient.SetServerState(lsp.StateError)
58+ updateLSPState(name, lsp.StateError, err, lspClient, 0)
5059 } else {
5160 // Server reached a ready state scuccessfully.
5261 slog.Info("LSP server is ready", "name", name)
5362 lspClient.SetServerState(lsp.StateReady)
63+ updateLSPState(name, lsp.StateReady, nil, lspClient, 0)
5464 }
5565
5666 slog.Info("LSP client initialized", "name", name)
internal/app/lsp_events.gonew file+102
@@ -0,0 +1,102 @@
1+package app
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/charmbracelet/crush/internal/csync"
8+ "github.com/charmbracelet/crush/internal/lsp"
9+ "github.com/charmbracelet/crush/internal/pubsub"
10+)
11+
12+// LSPEventType represents the type of LSP event
13+type LSPEventType string
14+
15+const (
16+ LSPEventStateChanged LSPEventType = "state_changed"
17+ LSPEventDiagnosticsChanged LSPEventType = "diagnostics_changed"
18+)
19+
20+// LSPEvent represents an event in the LSP system
21+type LSPEvent struct {
22+ Type LSPEventType
23+ Name string
24+ State lsp.ServerState
25+ Error error
26+ DiagnosticCount int
27+}
28+
29+// LSPClientInfo holds information about an LSP client's state
30+type LSPClientInfo struct {
31+ Name string
32+ State lsp.ServerState
33+ Error error
34+ Client *lsp.Client
35+ DiagnosticCount int
36+ ConnectedAt time.Time
37+}
38+
39+var (
40+ lspStates = csync.NewMap[string, LSPClientInfo]()
41+ lspBroker = pubsub.NewBroker[LSPEvent]()
42+)
43+
44+// SubscribeLSPEvents returns a channel for LSP events
45+func SubscribeLSPEvents(ctx context.Context) <-chan pubsub.Event[LSPEvent] {
46+ return lspBroker.Subscribe(ctx)
47+}
48+
49+// GetLSPStates returns the current state of all LSP clients
50+func GetLSPStates() map[string]LSPClientInfo {
51+ states := make(map[string]LSPClientInfo)
52+ for name, info := range lspStates.Seq2() {
53+ states[name] = info
54+ }
55+ return states
56+}
57+
58+// GetLSPState returns the state of a specific LSP client
59+func GetLSPState(name string) (LSPClientInfo, bool) {
60+ return lspStates.Get(name)
61+}
62+
63+// updateLSPState updates the state of an LSP client and publishes an event
64+func updateLSPState(name string, state lsp.ServerState, err error, client *lsp.Client, diagnosticCount int) {
65+ info := LSPClientInfo{
66+ Name: name,
67+ State: state,
68+ Error: err,
69+ Client: client,
70+ DiagnosticCount: diagnosticCount,
71+ }
72+ if state == lsp.StateReady {
73+ info.ConnectedAt = time.Now()
74+ }
75+ lspStates.Set(name, info)
76+
77+ // Publish state change event
78+ lspBroker.Publish(pubsub.UpdatedEvent, LSPEvent{
79+ Type: LSPEventStateChanged,
80+ Name: name,
81+ State: state,
82+ Error: err,
83+ DiagnosticCount: diagnosticCount,
84+ })
85+}
86+
87+// updateLSPDiagnostics updates the diagnostic count for an LSP client and publishes an event
88+func updateLSPDiagnostics(name string, diagnosticCount int) {
89+ if info, exists := lspStates.Get(name); exists {
90+ info.DiagnosticCount = diagnosticCount
91+ lspStates.Set(name, info)
92+
93+ // Publish diagnostics change event
94+ lspBroker.Publish(pubsub.UpdatedEvent, LSPEvent{
95+ Type: LSPEventDiagnosticsChanged,
96+ Name: name,
97+ State: info.State,
98+ Error: info.Error,
99+ DiagnosticCount: diagnosticCount,
100+ })
101+ }
102+}
internal/llm/agent/agent.go+37−31
@@ -159,11 +159,12 @@ func NewAgent(
159159 if err != nil {
160160 return nil, err
161161 }
162+
162163 summarizeOpts := []provider.ProviderClientOption{
163− provider.WithModel(config.SelectedModelTypeSmall),
164− provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, smallModelProviderCfg.ID)),
164+ provider.WithModel(config.SelectedModelTypeLarge),
165+ provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, providerCfg.ID)),
165166 }
166− summarizeProvider, err := provider.NewProvider(*smallModelProviderCfg, summarizeOpts...)
167+ summarizeProvider, err := provider.NewProvider(*providerCfg, summarizeOpts...)
167168 if err != nil {
168169 return nil, err
169170 }
@@ -224,7 +225,7 @@ func NewAgent(
224225 sessions: sessions,
225226 titleProvider: titleProvider,
226227 summarizeProvider: summarizeProvider,
227− summarizeProviderID: string(smallModelProviderCfg.ID),
228+ summarizeProviderID: string(providerCfg.ID),
228229 activeRequests: csync.NewMap[string, context.CancelFunc](),
229230 tools: csync.NewLazySlice(toolFn),
230231 }, nil
@@ -904,54 +905,59 @@ func (a *agent) UpdateModel() error {
904905 a.providerID = string(currentProviderCfg.ID)
905906 }
906907
907− // Check if small model provider has changed (affects title and summarize providers)
908+ // Check if providers have changed for title (small) and summarize (large)
908909 smallModelCfg := cfg.Models[config.SelectedModelTypeSmall]
909910 var smallModelProviderCfg config.ProviderConfig
910−
911911 for p := range cfg.Providers.Seq() {
912912 if p.ID == smallModelCfg.Provider {
913913 smallModelProviderCfg = p
914914 break
915915 }
916916 }
917−
918917 if smallModelProviderCfg.ID == "" {
919918 return fmt.Errorf("provider %s not found in config", smallModelCfg.Provider)
920919 }
921920
922− // Check if summarize provider has changed
923− if string(smallModelProviderCfg.ID) != a.summarizeProviderID {
924− smallModel := cfg.GetModelByType(config.SelectedModelTypeSmall)
925− if smallModel == nil {
926− return fmt.Errorf("model %s not found in provider %s", smallModelCfg.Model, smallModelProviderCfg.ID)
921+ largeModelCfg := cfg.Models[config.SelectedModelTypeLarge]
922+ var largeModelProviderCfg config.ProviderConfig
923+ for p := range cfg.Providers.Seq() {
924+ if p.ID == largeModelCfg.Provider {
925+ largeModelProviderCfg = p
926+ break
927927 }
928+ }
929+ if largeModelProviderCfg.ID == "" {
930+ return fmt.Errorf("provider %s not found in config", largeModelCfg.Provider)
931+ }
928932
929− // Recreate title provider
930− titleOpts := []provider.ProviderClientOption{
931− provider.WithModel(config.SelectedModelTypeSmall),
932− provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptTitle, smallModelProviderCfg.ID)),
933− // We want the title to be short, so we limit the max tokens
934− provider.WithMaxTokens(40),
935− }
936− newTitleProvider, err := provider.NewProvider(smallModelProviderCfg, titleOpts...)
937− if err != nil {
938− return fmt.Errorf("failed to create new title provider: %w", err)
939− }
933+ // Recreate title provider
934+ titleOpts := []provider.ProviderClientOption{
935+ provider.WithModel(config.SelectedModelTypeSmall),
936+ provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptTitle, smallModelProviderCfg.ID)),
937+ provider.WithMaxTokens(40),
938+ }
939+ newTitleProvider, err := provider.NewProvider(smallModelProviderCfg, titleOpts...)
940+ if err != nil {
941+ return fmt.Errorf("failed to create new title provider: %w", err)
942+ }
943+ a.titleProvider = newTitleProvider
940944
941− // Recreate summarize provider
945+ // Recreate summarize provider if provider changed (now large model)
946+ if string(largeModelProviderCfg.ID) != a.summarizeProviderID {
947+ largeModel := cfg.GetModelByType(config.SelectedModelTypeLarge)
948+ if largeModel == nil {
949+ return fmt.Errorf("model %s not found in provider %s", largeModelCfg.Model, largeModelProviderCfg.ID)
950+ }
942951 summarizeOpts := []provider.ProviderClientOption{
943− provider.WithModel(config.SelectedModelTypeSmall),
944− provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, smallModelProviderCfg.ID)),
952+ provider.WithModel(config.SelectedModelTypeLarge),
953+ provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, largeModelProviderCfg.ID)),
945954 }
946− newSummarizeProvider, err := provider.NewProvider(smallModelProviderCfg, summarizeOpts...)
955+ newSummarizeProvider, err := provider.NewProvider(largeModelProviderCfg, summarizeOpts...)
947956 if err != nil {
948957 return fmt.Errorf("failed to create new summarize provider: %w", err)
949958 }
950−
951− // Update the providers and provider ID
952− a.titleProvider = newTitleProvider
953959 a.summarizeProvider = newSummarizeProvider
954− a.summarizeProviderID = string(smallModelProviderCfg.ID)
960+ a.summarizeProviderID = string(largeModelProviderCfg.ID)
955961 }
956962
957963 return nil
internal/llm/agent/mcp-tools.go+142−7
@@ -7,23 +7,76 @@ import (
77 "log/slog"
88 "slices"
99 "sync"
10+ "time"
1011
1112 "github.com/charmbracelet/crush/internal/config"
1213 "github.com/charmbracelet/crush/internal/csync"
1314 "github.com/charmbracelet/crush/internal/llm/tools"
14− "github.com/charmbracelet/crush/internal/version"
15−
1615 "github.com/charmbracelet/crush/internal/permission"
17−
16+ "github.com/charmbracelet/crush/internal/pubsub"
17+ "github.com/charmbracelet/crush/internal/version"
1818 "github.com/mark3labs/mcp-go/client"
1919 "github.com/mark3labs/mcp-go/client/transport"
2020 "github.com/mark3labs/mcp-go/mcp"
2121)
2222
23+// MCPState represents the current state of an MCP client
24+type MCPState int
25+
26+const (
27+ MCPStateDisabled MCPState = iota
28+ MCPStateStarting
29+ MCPStateConnected
30+ MCPStateError
31+)
32+
33+func (s MCPState) String() string {
34+ switch s {
35+ case MCPStateDisabled:
36+ return "disabled"
37+ case MCPStateStarting:
38+ return "starting"
39+ case MCPStateConnected:
40+ return "connected"
41+ case MCPStateError:
42+ return "error"
43+ default:
44+ return "unknown"
45+ }
46+}
47+
48+// MCPEventType represents the type of MCP event
49+type MCPEventType string
50+
51+const (
52+ MCPEventStateChanged MCPEventType = "state_changed"
53+)
54+
55+// MCPEvent represents an event in the MCP system
56+type MCPEvent struct {
57+ Type MCPEventType
58+ Name string
59+ State MCPState
60+ Error error
61+ ToolCount int
62+}
63+
64+// MCPClientInfo holds information about an MCP client's state
65+type MCPClientInfo struct {
66+ Name string
67+ State MCPState
68+ Error error
69+ Client *client.Client
70+ ToolCount int
71+ ConnectedAt time.Time
72+}
73+
2374var (
2475 mcpToolsOnce sync.Once
2576 mcpTools []tools.BaseTool
2677 mcpClients = csync.NewMap[string, *client.Client]()
78+ mcpStates = csync.NewMap[string, MCPClientInfo]()
79+ mcpBroker = pubsub.NewBroker[MCPEvent]()
2780)
2881
2982type McpTool struct {
@@ -109,6 +162,7 @@ func getTools(ctx context.Context, name string, permissions permission.Service,
109162 result, err := c.ListTools(ctx, mcp.ListToolsRequest{})
110163 if err != nil {
111164 slog.Error("error listing tools", "error", err)
165+ updateMCPState(name, MCPStateError, err, nil, 0)
112166 c.Close()
113167 mcpClients.Del(name)
114168 return nil
@@ -125,11 +179,55 @@ func getTools(ctx context.Context, name string, permissions permission.Service,
125179 return mcpTools
126180}
127181
182+// SubscribeMCPEvents returns a channel for MCP events
183+func SubscribeMCPEvents(ctx context.Context) <-chan pubsub.Event[MCPEvent] {
184+ return mcpBroker.Subscribe(ctx)
185+}
186+
187+// GetMCPStates returns the current state of all MCP clients
188+func GetMCPStates() map[string]MCPClientInfo {
189+ states := make(map[string]MCPClientInfo)
190+ for name, info := range mcpStates.Seq2() {
191+ states[name] = info
192+ }
193+ return states
194+}
195+
196+// GetMCPState returns the state of a specific MCP client
197+func GetMCPState(name string) (MCPClientInfo, bool) {
198+ return mcpStates.Get(name)
199+}
200+
201+// updateMCPState updates the state of an MCP client and publishes an event
202+func updateMCPState(name string, state MCPState, err error, client *client.Client, toolCount int) {
203+ info := MCPClientInfo{
204+ Name: name,
205+ State: state,
206+ Error: err,
207+ Client: client,
208+ ToolCount: toolCount,
209+ }
210+ if state == MCPStateConnected {
211+ info.ConnectedAt = time.Now()
212+ }
213+ mcpStates.Set(name, info)
214+
215+ // Publish state change event
216+ mcpBroker.Publish(pubsub.UpdatedEvent, MCPEvent{
217+ Type: MCPEventStateChanged,
218+ Name: name,
219+ State: state,
220+ Error: err,
221+ ToolCount: toolCount,
222+ })
223+}
224+
128225// CloseMCPClients closes all MCP clients. This should be called during application shutdown.
129226func CloseMCPClients() {
130227 for c := range mcpClients.Seq() {
131228 _ = c.Close()
132229 }
230+ mcpBroker.Shutdown()
133231}
134232
135233var mcpInitRequest = mcp.InitializeRequest{
@@ -145,25 +243,51 @@ var mcpInitRequest = mcp.InitializeRequest{
145243func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) []tools.BaseTool {
146244 var wg sync.WaitGroup
147245 result := csync.NewSlice[tools.BaseTool]()
246+
247+ // Initialize states for all configured MCPs
148248 for name, m := range cfg.MCP {
149249 if m.Disabled {
250+ updateMCPState(name, MCPStateDisabled, nil, nil, 0)
150251 slog.Debug("skipping disabled mcp", "name", name)
151252 continue
152253 }
254+
255+ // Set initial starting state
256+ updateMCPState(name, MCPStateStarting, nil, nil, 0)
257+
153258 wg.Add(1)
154259 go func(name string, m config.MCPConfig) {
155− defer wg.Done()
260+ defer func() {
261+ wg.Done()
262+ if r := recover(); r != nil {
263+ var err error
264+ switch v := r.(type) {
265+ case error:
266+ err = v
267+ case string:
268+ err = fmt.Errorf("panic: %s", v)
269+ default:
270+ err = fmt.Errorf("panic: %v", v)
271+ }
272+ updateMCPState(name, MCPStateError, err, nil, 0)
273+ slog.Error("panic in mcp client initialization", "error", err, "name", name)
274+ }
275+ }()
276+
156277 c, err := createMcpClient(m)
157278 if err != nil {
279+ updateMCPState(name, MCPStateError, err, nil, 0)
158280 slog.Error("error creating mcp client", "error", err, "name", name)
159281 return
160282 }
161283 if err := c.Start(ctx); err != nil {
284+ updateMCPState(name, MCPStateError, err, nil, 0)
162285 slog.Error("error starting mcp client", "error", err, "name", name)
163286 _ = c.Close()
164287 return
165288 }
166289 if _, err := c.Initialize(ctx, mcpInitRequest); err != nil {
290+ updateMCPState(name, MCPStateError, err, nil, 0)
167291 slog.Error("error initializing mcp client", "error", err, "name", name)
168292 _ = c.Close()
169293 return
@@ -172,7 +296,9 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con
172296 slog.Info("Initialized mcp client", "name", name)
173297 mcpClients.Set(name, c)
174298
175− result.Append(getTools(ctx, name, permissions, c, cfg.WorkingDir())...)
299+ tools := getTools(ctx, name, permissions, c, cfg.WorkingDir())
300+ updateMCPState(name, MCPStateConnected, nil, c, len(tools))
301+ result.Append(tools...)
176302 }(name, m)
177303 }
178304 wg.Wait()
@@ -182,22 +308,31 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con
182308func createMcpClient(m config.MCPConfig) (*client.Client, error) {
183309 switch m.Type {
184310 case config.MCPStdio:
185− return client.NewStdioMCPClient(
311+ return client.NewStdioMCPClientWithOptions(
186312 m.Command,
187313 m.ResolvedEnv(),
188− m.Args...,
314+ m.Args,
315+ transport.WithCommandLogger(mcpLogger{}),
189316 )
190317 case config.MCPHttp:
191318 return client.NewStreamableHttpClient(
192319 m.URL,
193320 transport.WithHTTPHeaders(m.ResolvedHeaders()),
321+ transport.WithHTTPLogger(mcpLogger{}),
194322 )
195323 case config.MCPSse:
196324 return client.NewSSEMCPClient(
197325 m.URL,
198326 client.WithHeaders(m.ResolvedHeaders()),
327+ transport.WithSSELogger(mcpLogger{}),
199328 )
200329 default:
201330 return nil, fmt.Errorf("unsupported mcp type: %s", m.Type)
202331 }
203332}
333+
334+// for MCP's clients.
335+type mcpLogger struct{}
336+
337+func (l mcpLogger) Errorf(format string, v ...any) { slog.Error(fmt.Sprintf(format, v...)) }
338+func (l mcpLogger) Infof(format string, v ...any) { slog.Info(fmt.Sprintf(format, v...)) }
internal/llm/provider/openai.go+71−67
@@ -2,10 +2,12 @@ package provider
22
33import (
44 "context"
5+ "encoding/json"
56 "errors"
67 "fmt"
78 "io"
89 "log/slog"
10+ "slices"
911 "strings"
1012 "time"
1113
@@ -14,6 +16,7 @@ import (
1416 "github.com/charmbracelet/crush/internal/llm/tools"
1517 "github.com/charmbracelet/crush/internal/log"
1618 "github.com/charmbracelet/crush/internal/message"
19+ "github.com/google/uuid"
1720 "github.com/openai/openai-go"
1821 "github.com/openai/openai-go/option"
1922 "github.com/openai/openai-go/packages/param"
@@ -70,8 +73,9 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
7073 systemMessage = o.providerOptions.systemPromptPrefix + "\n" + systemMessage
7174 }
7275
73− systemTextBlock := openai.ChatCompletionContentPartTextParam{Text: systemMessage}
76+ system := openai.SystemMessage(systemMessage)
7477 if isAnthropicModel && !o.providerOptions.disableCache {
78+ systemTextBlock := openai.ChatCompletionContentPartTextParam{Text: systemMessage}
7579 systemTextBlock.SetExtraFields(
7680 map[string]any{
7781 "cache_control": map[string]string{
@@ -79,10 +83,10 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
7983 },
8084 },
8185 )
86+ var content []openai.ChatCompletionContentPartTextParam
87+ content = append(content, systemTextBlock)
88+ system = openai.SystemMessage(content)
8289 }
83− var content []openai.ChatCompletionContentPartTextParam
84− content = append(content, systemTextBlock)
85− system := openai.SystemMessage(content)
8690 openaiMessages = append(openaiMessages, system)
8791
8892 for i, msg := range messages {
@@ -93,9 +97,12 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
9397 switch msg.Role {
9498 case message.User:
9599 var content []openai.ChatCompletionContentPartUnionParam
100+
96101 textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
97102 content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
103+ hasBinaryContent := false
98104 for _, binaryContent := range msg.BinaryContent() {
105+ hasBinaryContent = true
99106 imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(catwalk.InferenceProviderOpenAI)}
100107 imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
101108
@@ -108,8 +115,11 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
108115 },
109116 })
110117 }
111−
112− openaiMessages = append(openaiMessages, openai.UserMessage(content))
118+ if hasBinaryContent || (isAnthropicModel && !o.providerOptions.disableCache) {
119+ openaiMessages = append(openaiMessages, openai.UserMessage(content))
120+ } else {
121+ openaiMessages = append(openaiMessages, openai.UserMessage(msg.Content().String()))
122+ }
113123
114124 case message.Assistant:
115125 assistantMsg := openai.ChatCompletionAssistantMessageParam{
@@ -134,13 +144,15 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
134144 },
135145 },
136146 }
147+ if !isAnthropicModel {
148+ assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
149+ OfString: param.NewOpt(msg.Content().String()),
150+ }
151+ }
137152 }
138153
139154 if len(msg.ToolCalls()) > 0 {
140155 hasContent = true
141− assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
142− OfString: param.NewOpt(msg.Content().String()),
143− }
144156 assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(msg.ToolCalls()))
145157 for i, call := range msg.ToolCalls() {
146158 assistantMsg.ToolCalls[i] = openai.ChatCompletionMessageToolCallParam{
@@ -329,21 +341,26 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
329341 acc := openai.ChatCompletionAccumulator{}
330342 currentContent := ""
331343 toolCalls := make([]message.ToolCall, 0)
332−
333− var currentToolCallID string
334− var currentToolCall openai.ChatCompletionMessageToolCall
335344 var msgToolCalls []openai.ChatCompletionMessageToolCall
336− currentToolIndex := 0
337345 for openaiStream.Next() {
338346 chunk := openaiStream.Current()
339347 // Kujtim: this is an issue with openrouter qwen, its sending -1 for the tool index
340348 if len(chunk.Choices) > 0 && len(chunk.Choices[0].Delta.ToolCalls) > 0 && chunk.Choices[0].Delta.ToolCalls[0].Index == -1 {
341− chunk.Choices[0].Delta.ToolCalls[0].Index = int64(currentToolIndex)
342− currentToolIndex++
349+ chunk.Choices[0].Delta.ToolCalls[0].Index = 0
343350 }
344351 acc.AddChunk(chunk)
345− // This fixes multiple tool calls for some providers
346− for _, choice := range chunk.Choices {
352+ for i, choice := range chunk.Choices {
353+ reasoning, ok := choice.Delta.JSON.ExtraFields["reasoning"]
354+ if ok && reasoning.Raw() != "" {
355+ reasoningStr := ""
356+ json.Unmarshal([]byte(reasoning.Raw()), &reasoningStr)
357+ if reasoningStr != "" {
358+ eventChan <- ProviderEvent{
359+ Type: EventThinkingDelta,
360+ Thinking: reasoningStr,
361+ }
362+ }
363+ }
347364 if choice.Delta.Content != "" {
348365 eventChan <- ProviderEvent{
349366 Type: EventContentDelta,
@@ -352,63 +369,50 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
352369 currentContent += choice.Delta.Content
353370 } else if len(choice.Delta.ToolCalls) > 0 {
354371 toolCall := choice.Delta.ToolCalls[0]
355− // Detect tool use start
356− if currentToolCallID == "" {
357− if toolCall.ID != "" {
358− currentToolCallID = toolCall.ID
359− eventChan <- ProviderEvent{
360− Type: EventToolUseStart,
361− ToolCall: &message.ToolCall{
362− ID: toolCall.ID,
363− Name: toolCall.Function.Name,
364− Finished: false,
365− },
372+ newToolCall := false
373+ if len(msgToolCalls)-1 >= int(toolCall.Index) { // tool call exists
374+ existingToolCall := msgToolCalls[toolCall.Index]
375+ if toolCall.ID != "" && toolCall.ID != existingToolCall.ID {
376+ found := false
377+ // try to find the tool based on the ID
378+ for i, tool := range msgToolCalls {
379+ if tool.ID == toolCall.ID {
380+ msgToolCalls[i].Function.Arguments += toolCall.Function.Arguments
381+ found = true
382+ }
366383 }
367− currentToolCall = openai.ChatCompletionMessageToolCall{
368− ID: toolCall.ID,
369− Type: "function",
370− Function: openai.ChatCompletionMessageToolCallFunction{
371− Name: toolCall.Function.Name,
372− Arguments: toolCall.Function.Arguments,
373− },
384+ if !found {
385+ newToolCall = true
374386 }
375− }
376− } else {
377− // Delta tool use
378− if toolCall.ID == "" || toolCall.ID == currentToolCallID {
379− currentToolCall.Function.Arguments += toolCall.Function.Arguments
380387 } else {
381− // Detect new tool use
382− if toolCall.ID != currentToolCallID {
383− msgToolCalls = append(msgToolCalls, currentToolCall)
384− currentToolCallID = toolCall.ID
385− eventChan <- ProviderEvent{
386− Type: EventToolUseStart,
387− ToolCall: &message.ToolCall{
388− ID: toolCall.ID,
389− Name: toolCall.Function.Name,
390− Finished: false,
391− },
392− }
393− currentToolCall = openai.ChatCompletionMessageToolCall{
394− ID: toolCall.ID,
395− Type: "function",
396− Function: openai.ChatCompletionMessageToolCallFunction{
397− Name: toolCall.Function.Name,
398− Arguments: toolCall.Function.Arguments,
399− },
400− }
401− }
388+ msgToolCalls[toolCall.Index].Function.Arguments += toolCall.Function.Arguments
402389 }
390+ } else {
391+ newToolCall = true
403392 }
404− }
405− // Kujtim: some models send finish stop even for tool calls
406− if choice.FinishReason == "tool_calls" || (choice.FinishReason == "stop" && currentToolCallID != "") {
407− msgToolCalls = append(msgToolCalls, currentToolCall)
408− if len(acc.Choices) > 0 {
409− acc.Choices[0].Message.ToolCalls = msgToolCalls
393+ if newToolCall { // new tool call
394+ if toolCall.ID == "" {
395+ toolCall.ID = uuid.NewString()
396+ }
397+ eventChan <- ProviderEvent{
398+ Type: EventToolUseStart,
399+ ToolCall: &message.ToolCall{
400+ ID: toolCall.ID,
401+ Name: toolCall.Function.Name,
402+ Finished: false,
403+ },
404+ }
405+ msgToolCalls = append(msgToolCalls, openai.ChatCompletionMessageToolCall{
406+ ID: toolCall.ID,
407+ Type: "function",
408+ Function: openai.ChatCompletionMessageToolCallFunction{
409+ Name: toolCall.Function.Name,
410+ Arguments: toolCall.Function.Arguments,
411+ },
412+ })
410413 }
411414 }
415+ acc.Choices[i].Message.ToolCalls = slices.Clone(msgToolCalls)
412416 }
413417 }
414418
internal/llm/tools/tools.go+74−1
@@ -3,6 +3,8 @@ package tools
33import (
44 "context"
55 "encoding/json"
6+ "fmt"
7+ "strings"
68)
79
810type ToolInfo struct {
@@ -25,6 +27,10 @@ const (
2527
2628 SessionIDContextKey sessionIDContextKey = "session_id"
2729 MessageIDContextKey messageIDContextKey = "message_id"
30+
31+ maxResponseWidth = 3000
32+ maxResponseHeight = 5000
33+ maxResponseChars = 50000
2834)
2935
3036type ToolResponse struct {
@@ -37,10 +43,77 @@ type ToolResponse struct {
3743func NewTextResponse(content string) ToolResponse {
3844 return ToolResponse{
3945 Type: ToolResponseTypeText,
40− Content: content,
46+ Content: truncateContent(content),
4147 }
4248}
4349
50+func truncateContent(content string) string {
51+ if len(content) <= maxResponseChars {
52+ return truncateWidthAndHeight(content)
53+ }
54+
55+ truncated := content[:maxResponseChars]
56+
57+ if lastNewline := strings.LastIndex(truncated, "\n"); lastNewline > maxResponseChars/2 {
58+ truncated = truncated[:lastNewline]
59+ }
60+
61+ truncated += "\n\n... [Content truncated due to length] ..."
62+
63+ return truncateWidthAndHeight(truncated)
64+}
65+
66+func truncateWidthAndHeight(content string) string {
67+ lines := strings.Split(content, "\n")
68+
69+ heightTruncated := false
70+ if len(lines) > maxResponseHeight {
71+ keepLines := maxResponseHeight - 3
72+ firstHalf := keepLines / 2
73+ secondHalf := keepLines - firstHalf
74+
75+ truncatedLines := make([]string, 0, maxResponseHeight)
76+ truncatedLines = append(truncatedLines, lines[:firstHalf]...)
77+ truncatedLines = append(truncatedLines, "")
78+ truncatedLines = append(truncatedLines, fmt.Sprintf("... [%d lines truncated] ...", len(lines)-keepLines))
79+ truncatedLines = append(truncatedLines, "")
80+ truncatedLines = append(truncatedLines, lines[len(lines)-secondHalf:]...)
81+
82+ lines = truncatedLines
83+ heightTruncated = true
84+ }
85+
86+ widthTruncated := false
87+ for i, line := range lines {
88+ if len(line) > maxResponseWidth {
89+ if maxResponseWidth > 20 {
90+ keepChars := maxResponseWidth - 10
91+ firstHalf := keepChars / 2
92+ secondHalf := keepChars - firstHalf
93+ lines[i] = line[:firstHalf] + " ... " + line[len(line)-secondHalf:]
94+ } else {
95+ lines[i] = line[:maxResponseWidth]
96+ }
97+ widthTruncated = true
98+ }
99+ }
100+
101+ result := strings.Join(lines, "\n")
102+
103+ if heightTruncated || widthTruncated {
104+ notices := make([]string, 0, 2)
105+ if heightTruncated {
106+ notices = append(notices, "height")
107+ }
108+ if widthTruncated {
109+ notices = append(notices, "width")
110+ }
111+ result += fmt.Sprintf("\n\n[Note: Content truncated by %s to fit response limits]", strings.Join(notices, " and "))
112+ }
113+
114+ return result
115+}
116+
44117func WithResponseMetadata(response ToolResponse, metadata any) ToolResponse {
45118 if metadata != nil {
46119 metadataBytes, err := json.Marshal(metadata)
internal/lsp/client.go+18−1
@@ -26,6 +26,12 @@ type Client struct {
2626 stdout *bufio.Reader
2727 stderr io.ReadCloser
2828
29+ // Client name for identification
30+ name string
31+
32+ // Diagnostic change callback
33+ onDiagnosticsChanged func(name string, count int)
34+
2935 // Request ID counter
3036 nextID atomic.Int32
3137
@@ -53,7 +59,7 @@ type Client struct {
5359 serverState atomic.Value
5460}
5561
56−func NewClient(ctx context.Context, command string, args ...string) (*Client, error) {
62+func NewClient(ctx context.Context, name, command string, args ...string) (*Client, error) {
5763 cmd := exec.CommandContext(ctx, command, args...)
5864 // Copy env
5965 cmd.Env = os.Environ()
@@ -75,6 +81,7 @@ func NewClient(ctx context.Context, command string, args ...string) (*Client, er
7581
7682 client := &Client{
7783 Cmd: cmd,
84+ name: name,
7885 stdin: stdin,
7986 stdout: bufio.NewReader(stdout),
8087 stderr: stderr,
@@ -284,6 +291,16 @@ func (c *Client) SetServerState(state ServerState) {
284291 c.serverState.Store(state)
285292}
286293
294+// GetName returns the name of the LSP client
295+func (c *Client) GetName() string {
296+ return c.name
297+}
298+
299+// SetDiagnosticsCallback sets the callback function for diagnostic changes
300+func (c *Client) SetDiagnosticsCallback(callback func(name string, count int)) {
301+ c.onDiagnosticsChanged = callback
302+}
303+
287304// WaitForServerReady waits for the server to be ready by polling the server
288305// with a simple request until it responds successfully or times out
289306func (c *Client) WaitForServerReady(ctx context.Context) error {
internal/lsp/handlers.go+12−2
@@ -103,7 +103,17 @@ func HandleDiagnostics(client *Client, params json.RawMessage) {
103103 }
104104
105105 client.diagnosticsMu.Lock()
106− defer client.diagnosticsMu.Unlock()
107−
108106 client.diagnostics[diagParams.URI] = diagParams.Diagnostics
107+
108+ // Calculate total diagnostic count
109+ totalCount := 0
110+ for _, diagnostics := range client.diagnostics {
111+ totalCount += len(diagnostics)
112+ }
113+ client.diagnosticsMu.Unlock()
114+
115+ // Trigger callback if set
116+ if client.onDiagnosticsChanged != nil {
117+ client.onDiagnosticsChanged(client.name, totalCount)
118+ }
109119}
internal/tui/components/chat/messages/messages.go+3−1
@@ -274,11 +274,13 @@ func (m *messageCmp) renderThinkingContent() string {
274274 if reasoningContent.StartedAt > 0 {
275275 duration := m.message.ThinkingDuration()
276276 if reasoningContent.FinishedAt > 0 {
277+ if duration.String() == "0s" {
278+ return ""
279+ }
277280 m.anim.SetLabel("")
278281 opts := core.StatusOpts{
279282 Title: "Thought for",
280283 Description: duration.String(),
281− NoIcon: true,
282284 }
283285 return t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1))
284286 } else if finishReason != nil && finishReason.Reason == message.FinishReasonCanceled {
internal/tui/components/chat/sidebar/sidebar.go+76−411
@@ -5,7 +5,6 @@ import (
55 "fmt"
66 "os"
77 "slices"
8− "sort"
98 "strings"
109
1110 tea "github.com/charmbracelet/bubbletea/v2"
@@ -13,21 +12,21 @@ import (
1312 "github.com/charmbracelet/crush/internal/config"
1413 "github.com/charmbracelet/crush/internal/csync"
1514 "github.com/charmbracelet/crush/internal/diff"
16− "github.com/charmbracelet/crush/internal/fsext"
1715 "github.com/charmbracelet/crush/internal/history"
1816 "github.com/charmbracelet/crush/internal/lsp"
19− "github.com/charmbracelet/crush/internal/lsp/protocol"
2017 "github.com/charmbracelet/crush/internal/pubsub"
2118 "github.com/charmbracelet/crush/internal/session"
2219 "github.com/charmbracelet/crush/internal/tui/components/chat"
2320 "github.com/charmbracelet/crush/internal/tui/components/core"
2421 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
22+ "github.com/charmbracelet/crush/internal/tui/components/files"
2523 "github.com/charmbracelet/crush/internal/tui/components/logo"
24+ lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
25+ "github.com/charmbracelet/crush/internal/tui/components/mcp"
2626 "github.com/charmbracelet/crush/internal/tui/styles"
2727 "github.com/charmbracelet/crush/internal/tui/util"
2828 "github.com/charmbracelet/crush/internal/version"
2929 "github.com/charmbracelet/lipgloss/v2"
30− "github.com/charmbracelet/x/ansi"
3130 "golang.org/x/text/cases"
3231 "golang.org/x/text/language"
3332)
@@ -382,459 +381,125 @@ func (m *sidebarCmp) renderSectionsHorizontal() string {
382381
383382// filesBlockCompact renders the files block with limited width and height for horizontal layout
384383func (m *sidebarCmp) filesBlockCompact(maxWidth int) string {
385− t := styles.CurrentTheme()
386−
387− section := t.S().Subtle.Render("Modified Files")
388−
389− files := slices.Collect(m.files.Seq())
390−
391− if len(files) == 0 {
392− content := lipgloss.JoinVertical(
393− lipgloss.Left,
394− section,
395− "",
396− t.S().Base.Foreground(t.Border).Render("None"),
397− )
398− return lipgloss.NewStyle().Width(maxWidth).Render(content)
384+ // Convert map to slice and handle type conversion
385+ sessionFiles := slices.Collect(m.files.Seq())
386+ fileSlice := make([]files.SessionFile, len(sessionFiles))
387+ for i, sf := range sessionFiles {
388+ fileSlice[i] = files.SessionFile{
389+ History: files.FileHistory{
390+ InitialVersion: sf.History.initialVersion,
391+ LatestVersion: sf.History.latestVersion,
392+ },
393+ FilePath: sf.FilePath,
394+ Additions: sf.Additions,
395+ Deletions: sf.Deletions,
396+ }
399397 }
400398
401− fileList := []string{section, ""}
402− sort.Slice(files, func(i, j int) bool {
403− return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
404− })
405−
406− // Limit items for horizontal layout - use less space
407− maxItems := min(5, len(files))
399+ // Limit items for horizontal layout
400+ maxItems := min(5, len(fileSlice))
408401 availableHeight := m.height - 8 // Reserve space for header and other content
409402 if availableHeight > 0 {
410403 maxItems = min(maxItems, availableHeight)
411404 }
412405
413− filesShown := 0
414− for _, file := range files {
415− if file.Additions == 0 && file.Deletions == 0 {
416− continue
417− }
418− if filesShown >= maxItems {
419− break
420− }
421−
422− var statusParts []string
423− if file.Additions > 0 {
424− statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
425− }
426− if file.Deletions > 0 {
427− statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
428− }
429−
430− extraContent := strings.Join(statusParts, " ")
431− cwd := config.Get().WorkingDir() + string(os.PathSeparator)
432− filePath := file.FilePath
433− filePath = strings.TrimPrefix(filePath, cwd)
434− filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
435− filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…")
436−
437− fileList = append(fileList,
438− core.Status(
439− core.StatusOpts{
440− IconColor: t.FgMuted,
441− NoIcon: true,
442− Title: filePath,
443− ExtraContent: extraContent,
444− },
445− maxWidth,
446− ),
447− )
448− filesShown++
449− }
450−
451− // Add "..." indicator if there are more files
452− totalFilesWithChanges := 0
453− for _, file := range files {
454− if file.Additions > 0 || file.Deletions > 0 {
455− totalFilesWithChanges++
456− }
457− }
458− if totalFilesWithChanges > maxItems {
459− fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("…"))
460− }
461−
462− content := lipgloss.JoinVertical(lipgloss.Left, fileList...)
463− return lipgloss.NewStyle().Width(maxWidth).Render(content)
406+ return files.RenderFileBlock(fileSlice, files.RenderOptions{
407+ MaxWidth: maxWidth,
408+ MaxItems: maxItems,
409+ ShowSection: true,
410+ SectionName: "Modified Files",
411+ }, true)
464412}
465413
466414// lspBlockCompact renders the LSP block with limited width and height for horizontal layout
467415func (m *sidebarCmp) lspBlockCompact(maxWidth int) string {
468− t := styles.CurrentTheme()
469−
470− section := t.S().Subtle.Render("LSPs")
471−
472− lspList := []string{section, ""}
473−
474− lsp := config.Get().LSP.Sorted()
475− if len(lsp) == 0 {
476− content := lipgloss.JoinVertical(
477− lipgloss.Left,
478− section,
479− "",
480− t.S().Base.Foreground(t.Border).Render("None"),
481− )
482− return lipgloss.NewStyle().Width(maxWidth).Render(content)
483− }
484−
485416 // Limit items for horizontal layout
486− maxItems := min(5, len(lsp))
417+ lspConfigs := config.Get().LSP.Sorted()
418+ maxItems := min(5, len(lspConfigs))
487419 availableHeight := m.height - 8
488420 if availableHeight > 0 {
489421 maxItems = min(maxItems, availableHeight)
490422 }
491423
492− for i, l := range lsp {
493− if i >= maxItems {
494− break
495− }
496−
497− iconColor := t.Success
498− if l.LSP.Disabled {
499− iconColor = t.FgMuted
500− }
501−
502− lspErrs := map[protocol.DiagnosticSeverity]int{
503− protocol.SeverityError: 0,
504− protocol.SeverityWarning: 0,
505− protocol.SeverityHint: 0,
506− protocol.SeverityInformation: 0,
507− }
508− if client, ok := m.lspClients[l.Name]; ok {
509− for _, diagnostics := range client.GetDiagnostics() {
510− for _, diagnostic := range diagnostics {
511− if severity, ok := lspErrs[diagnostic.Severity]; ok {
512− lspErrs[diagnostic.Severity] = severity + 1
513− }
514− }
515− }
516− }
517−
518− errs := []string{}
519− if lspErrs[protocol.SeverityError] > 0 {
520− errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
521− }
522− if lspErrs[protocol.SeverityWarning] > 0 {
523− errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
524− }
525− if lspErrs[protocol.SeverityHint] > 0 {
526− errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
527− }
528− if lspErrs[protocol.SeverityInformation] > 0 {
529− errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
530− }
531−
532− lspList = append(lspList,
533− core.Status(
534− core.StatusOpts{
535− IconColor: iconColor,
536− Title: l.Name,
537− Description: l.LSP.Command,
538− ExtraContent: strings.Join(errs, " "),
539− },
540− maxWidth,
541− ),
542− )
543− }
544−
545− // Add "..." indicator if there are more LSPs
546− if len(lsp) > maxItems {
547− lspList = append(lspList, t.S().Base.Foreground(t.FgMuted).Render("…"))
548− }
549−
550− content := lipgloss.JoinVertical(lipgloss.Left, lspList...)
551− return lipgloss.NewStyle().Width(maxWidth).Render(content)
424+ return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
425+ MaxWidth: maxWidth,
426+ MaxItems: maxItems,
427+ ShowSection: true,
428+ SectionName: "LSPs",
429+ }, true)
552430}
553431
554432// mcpBlockCompact renders the MCP block with limited width and height for horizontal layout
555433func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
556− t := styles.CurrentTheme()
557−
558− section := t.S().Subtle.Render("MCPs")
559−
560− mcpList := []string{section, ""}
561−
562− mcps := config.Get().MCP.Sorted()
563− if len(mcps) == 0 {
564− content := lipgloss.JoinVertical(
565− lipgloss.Left,
566− section,
567− "",
568− t.S().Base.Foreground(t.Border).Render("None"),
569− )
570− return lipgloss.NewStyle().Width(maxWidth).Render(content)
571− }
572−
573434 // Limit items for horizontal layout
574− maxItems := min(5, len(mcps))
435+ maxItems := min(5, len(config.Get().MCP.Sorted()))
575436 availableHeight := m.height - 8
576437 if availableHeight > 0 {
577438 maxItems = min(maxItems, availableHeight)
578439 }
579440
580− for i, l := range mcps {
581− if i >= maxItems {
582− break
583− }
584−
585− iconColor := t.Success
586− if l.MCP.Disabled {
587− iconColor = t.FgMuted
588− }
589−
590− mcpList = append(mcpList,
591− core.Status(
592− core.StatusOpts{
593− IconColor: iconColor,
594− Title: l.Name,
595− Description: l.MCP.Command,
596− },
597− maxWidth,
598− ),
599− )
600− }
601−
602− // Add "..." indicator if there are more MCPs
603− if len(mcps) > maxItems {
604− mcpList = append(mcpList, t.S().Base.Foreground(t.FgMuted).Render("…"))
605− }
606−
607− content := lipgloss.JoinVertical(lipgloss.Left, mcpList...)
608− return lipgloss.NewStyle().Width(maxWidth).Render(content)
441+ return mcp.RenderMCPBlock(mcp.RenderOptions{
442+ MaxWidth: maxWidth,
443+ MaxItems: maxItems,
444+ ShowSection: true,
445+ SectionName: "MCPs",
446+ }, true)
609447}
610448
611449func (m *sidebarCmp) filesBlock() string {
612− t := styles.CurrentTheme()
613−
614− section := t.S().Subtle.Render(
615− core.Section("Modified Files", m.getMaxWidth()),
616− )
617−
618− files := slices.Collect(m.files.Seq())
619− if len(files) == 0 {
620− return lipgloss.JoinVertical(
621− lipgloss.Left,
622− section,
623− "",
624− t.S().Base.Foreground(t.Border).Render("None"),
625− )
450+ // Convert map to slice and handle type conversion
451+ sessionFiles := slices.Collect(m.files.Seq())
452+ fileSlice := make([]files.SessionFile, len(sessionFiles))
453+ for i, sf := range sessionFiles {
454+ fileSlice[i] = files.SessionFile{
455+ History: files.FileHistory{
456+ InitialVersion: sf.History.initialVersion,
457+ LatestVersion: sf.History.latestVersion,
458+ },
459+ FilePath: sf.FilePath,
460+ Additions: sf.Additions,
461+ Deletions: sf.Deletions,
462+ }
626463 }
627464
628− fileList := []string{section, ""}
629− // order files by the latest version's created time
630− sort.Slice(files, func(i, j int) bool {
631− return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
632− })
633−
634465 // Limit the number of files shown
635466 maxFiles, _, _ := m.getDynamicLimits()
636− maxFiles = min(len(files), maxFiles)
637− filesShown := 0
638−
639− for _, file := range files {
640− if file.Additions == 0 && file.Deletions == 0 {
641− continue // skip files with no changes
642− }
643− if filesShown >= maxFiles {
644− break
645− }
646−
647− var statusParts []string
648− if file.Additions > 0 {
649− statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
650− }
651− if file.Deletions > 0 {
652− statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
653− }
654−
655− extraContent := strings.Join(statusParts, " ")
656− cwd := config.Get().WorkingDir() + string(os.PathSeparator)
657− filePath := file.FilePath
658− filePath = strings.TrimPrefix(filePath, cwd)
659− filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
660− filePath = ansi.Truncate(filePath, m.getMaxWidth()-lipgloss.Width(extraContent)-2, "…")
661− fileList = append(fileList,
662− core.Status(
663− core.StatusOpts{
664− IconColor: t.FgMuted,
665− NoIcon: true,
666− Title: filePath,
667− ExtraContent: extraContent,
668− },
669− m.getMaxWidth(),
670− ),
671− )
672− filesShown++
673− }
674−
675− // Add indicator if there are more files
676− totalFilesWithChanges := 0
677− for _, file := range files {
678− if file.Additions > 0 || file.Deletions > 0 {
679− totalFilesWithChanges++
680− }
681− }
682− if totalFilesWithChanges > maxFiles {
683− remaining := totalFilesWithChanges - maxFiles
684− fileList = append(fileList,
685− t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
686− )
687− }
688−
689− return lipgloss.JoinVertical(
690− lipgloss.Left,
691− fileList...,
692− )
467+ maxFiles = min(len(fileSlice), maxFiles)
468+
469+ return files.RenderFileBlock(fileSlice, files.RenderOptions{
470+ MaxWidth: m.getMaxWidth(),
471+ MaxItems: maxFiles,
472+ ShowSection: true,
473+ SectionName: core.Section("Modified Files", m.getMaxWidth()),
474+ }, true)
693475}
694476
695477func (m *sidebarCmp) lspBlock() string {
696− t := styles.CurrentTheme()
697−
698− section := t.S().Subtle.Render(
699− core.Section("LSPs", m.getMaxWidth()),
700− )
701−
702− lspList := []string{section, ""}
703−
704− lsp := config.Get().LSP.Sorted()
705− if len(lsp) == 0 {
706− return lipgloss.JoinVertical(
707− lipgloss.Left,
708− section,
709− "",
710− t.S().Base.Foreground(t.Border).Render("None"),
711− )
712− }
713−
714478 // Limit the number of LSPs shown
715479 _, maxLSPs, _ := m.getDynamicLimits()
716− maxLSPs = min(len(lsp), maxLSPs)
717− for i, l := range lsp {
718− if i >= maxLSPs {
719− break
720− }
721−
722− iconColor := t.Success
723− if l.LSP.Disabled {
724− iconColor = t.FgMuted
725− }
726− lspErrs := map[protocol.DiagnosticSeverity]int{
727− protocol.SeverityError: 0,
728− protocol.SeverityWarning: 0,
729− protocol.SeverityHint: 0,
730− protocol.SeverityInformation: 0,
731− }
732− if client, ok := m.lspClients[l.Name]; ok {
733− for _, diagnostics := range client.GetDiagnostics() {
734− for _, diagnostic := range diagnostics {
735− if severity, ok := lspErrs[diagnostic.Severity]; ok {
736− lspErrs[diagnostic.Severity] = severity + 1
737− }
738− }
739− }
740− }
741−
742− errs := []string{}
743− if lspErrs[protocol.SeverityError] > 0 {
744− errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
745− }
746− if lspErrs[protocol.SeverityWarning] > 0 {
747− errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
748− }
749− if lspErrs[protocol.SeverityHint] > 0 {
750− errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
751− }
752− if lspErrs[protocol.SeverityInformation] > 0 {
753− errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
754− }
755−
756− lspList = append(lspList,
757− core.Status(
758− core.StatusOpts{
759− IconColor: iconColor,
760− Title: l.Name,
761− Description: l.LSP.Command,
762− ExtraContent: strings.Join(errs, " "),
763− },
764− m.getMaxWidth(),
765− ),
766− )
767− }
768−
769− // Add indicator if there are more LSPs
770− if len(lsp) > maxLSPs {
771− remaining := len(lsp) - maxLSPs
772− lspList = append(lspList,
773− t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
774− )
775− }
776−
777− return lipgloss.JoinVertical(
778− lipgloss.Left,
779− lspList...,
780− )
480+ lspConfigs := config.Get().LSP.Sorted()
481+ maxLSPs = min(len(lspConfigs), maxLSPs)
482+
483+ return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
484+ MaxWidth: m.getMaxWidth(),
485+ MaxItems: maxLSPs,
486+ ShowSection: true,
487+ SectionName: core.Section("LSPs", m.getMaxWidth()),
488+ }, true)
781489}
782490
783491func (m *sidebarCmp) mcpBlock() string {
784− t := styles.CurrentTheme()
785−
786− section := t.S().Subtle.Render(
787− core.Section("MCPs", m.getMaxWidth()),
788− )
789−
790− mcpList := []string{section, ""}
791−
792− mcps := config.Get().MCP.Sorted()
793− if len(mcps) == 0 {
794− return lipgloss.JoinVertical(
795− lipgloss.Left,
796− section,
797− "",
798− t.S().Base.Foreground(t.Border).Render("None"),
799− )
800− }
801−
802492 // Limit the number of MCPs shown
803493 _, _, maxMCPs := m.getDynamicLimits()
494+ mcps := config.Get().MCP.Sorted()
804495 maxMCPs = min(len(mcps), maxMCPs)
805− for i, l := range mcps {
806− if i >= maxMCPs {
807− break
808− }
809−
810− iconColor := t.Success
811− if l.MCP.Disabled {
812− iconColor = t.FgMuted
813− }
814− mcpList = append(mcpList,
815− core.Status(
816− core.StatusOpts{
817− IconColor: iconColor,
818− Title: l.Name,
819− Description: l.MCP.Command,
820− },
821− m.getMaxWidth(),
822− ),
823− )
824− }
825−
826− // Add indicator if there are more MCPs
827− if len(mcps) > maxMCPs {
828− remaining := len(mcps) - maxMCPs
829− mcpList = append(mcpList,
830− t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
831− )
832− }
833496
834− return lipgloss.JoinVertical(
835− lipgloss.Left,
836− mcpList...,
837− )
497+ return mcp.RenderMCPBlock(mcp.RenderOptions{
498+ MaxWidth: m.getMaxWidth(),
499+ MaxItems: maxMCPs,
500+ ShowSection: true,
501+ SectionName: core.Section("MCPs", m.getMaxWidth()),
502+ }, true)
838503}
839504
840505func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
internal/tui/components/chat/splash/splash.go+11−69
@@ -3,7 +3,6 @@ package splash
33import (
44 "fmt"
55 "os"
6− "slices"
76 "strings"
87 "time"
98
@@ -18,6 +17,8 @@ import (
1817 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
1918 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
2019 "github.com/charmbracelet/crush/internal/tui/components/logo"
20+ lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
21+ "github.com/charmbracelet/crush/internal/tui/components/mcp"
2122 "github.com/charmbracelet/crush/internal/tui/exp/list"
2223 "github.com/charmbracelet/crush/internal/tui/styles"
2324 "github.com/charmbracelet/crush/internal/tui/util"
@@ -101,27 +102,6 @@ func New() Splash {
101102
102103func (s *splashCmp) SetOnboarding(onboarding bool) {
103104 s.isOnboarding = onboarding
104− if onboarding {
105− providers, err := config.Providers()
106− if err != nil {
107− return
108− }
109− filteredProviders := []catwalk.Provider{}
110− simpleProviders := []string{
111− "anthropic",
112− "openai",
113− "gemini",
114− "xai",
115− "groq",
116− "openrouter",
117− }
118− for _, p := range providers {
119− if slices.Contains(simpleProviders, string(p.ID)) {
120− filteredProviders = append(filteredProviders, p)
121− }
122− }
123− s.modelList.SetProviders(filteredProviders)
124− }
125105}
126106
127107func (s *splashCmp) SetProjectInit(needsInit bool) {
@@ -655,7 +635,7 @@ func (s *splashCmp) Bindings() []key.Binding {
655635}
656636
657637func (s *splashCmp) getMaxInfoWidth() int {
658− return min(s.width-2, 40) // 2 for left padding
638+ return min(s.width-2, 90) // 2 for left padding
659639}
660640
661641func (s *splashCmp) cwd() string {
@@ -670,29 +650,10 @@ func (s *splashCmp) cwd() string {
670650}
671651
672652func LSPList(maxWidth int) []string {
673− t := styles.CurrentTheme()
674− lspList := []string{}
675− lsp := config.Get().LSP.Sorted()
676− if len(lsp) == 0 {
677− return []string{t.S().Base.Foreground(t.Border).Render("None")}
678− }
679− for _, l := range lsp {
680− iconColor := t.Success
681− if l.LSP.Disabled {
682− iconColor = t.FgMuted
683− }
684− lspList = append(lspList,
685− core.Status(
686− core.StatusOpts{
687− IconColor: iconColor,
688− Title: l.Name,
689− Description: l.LSP.Command,
690− },
691− maxWidth,
692− ),
693− )
694− }
695− return lspList
653+ return lspcomponent.RenderLSPList(nil, lspcomponent.RenderOptions{
654+ MaxWidth: maxWidth,
655+ ShowSection: false,
656+ })
696657}
697658
698659func (s *splashCmp) lspBlock() string {
@@ -709,29 +670,10 @@ func (s *splashCmp) lspBlock() string {
709670}
710671
711672func MCPList(maxWidth int) []string {
712− t := styles.CurrentTheme()
713− mcpList := []string{}
714− mcps := config.Get().MCP.Sorted()
715− if len(mcps) == 0 {
716− return []string{t.S().Base.Foreground(t.Border).Render("None")}
717− }
718− for _, l := range mcps {
719− iconColor := t.Success
720− if l.MCP.Disabled {
721− iconColor = t.FgMuted
722− }
723− mcpList = append(mcpList,
724− core.Status(
725− core.StatusOpts{
726− IconColor: iconColor,
727− Title: l.Name,
728− Description: l.MCP.Command,
729− },
730− maxWidth,
731− ),
732− )
733− }
734− return mcpList
673+ return mcp.RenderMCPList(mcp.RenderOptions{
674+ MaxWidth: maxWidth,
675+ ShowSection: false,
676+ })
735677}
736678
737679func (s *splashCmp) mcpBlock() string {
internal/tui/components/core/core.go+14−25
@@ -82,41 +82,30 @@ func Title(title string, width int) string {
8282}
8383
8484type StatusOpts struct {
85− Icon string
86− IconColor color.Color
87− NoIcon bool // If true, no icon will be displayed
85+ Icon string // if empty no icon will be shown
8886 Title string
8987 TitleColor color.Color
9088 Description string
9189 DescriptionColor color.Color
92− ExtraContent string // Additional content to append after the description
90+ ExtraContent string // additional content to append after the description
9391}
9492
95−func Status(ops StatusOpts, width int) string {
93+func Status(opts StatusOpts, width int) string {
9694 t := styles.CurrentTheme()
97− icon := "●"
98− iconColor := t.Success
99− if ops.Icon != "" {
100− icon = ops.Icon
101− } else if ops.NoIcon {
102− icon = ""
103− }
104− if ops.IconColor != nil {
105− iconColor = ops.IconColor
106− }
107− title := ops.Title
95+ icon := opts.Icon
96+ title := opts.Title
10897 titleColor := t.FgMuted
109− if ops.TitleColor != nil {
110− titleColor = ops.TitleColor
98+ if opts.TitleColor != nil {
99+ titleColor = opts.TitleColor
111100 }
112− description := ops.Description
101+ description := opts.Description
113102 descriptionColor := t.FgSubtle
114− if ops.DescriptionColor != nil {
115− descriptionColor = ops.DescriptionColor
103+ if opts.DescriptionColor != nil {
104+ descriptionColor = opts.DescriptionColor
116105 }
117106 title = t.S().Base.Foreground(titleColor).Render(title)
118107 if description != "" {
119− extraContentWidth := lipgloss.Width(ops.ExtraContent)
108+ extraContentWidth := lipgloss.Width(opts.ExtraContent)
120109 if extraContentWidth > 0 {
121110 extraContentWidth += 1
122111 }
@@ -126,11 +115,11 @@ func Status(ops StatusOpts, width int) string {
126115
127116 content := []string{}
128117 if icon != "" {
129− content = append(content, t.S().Base.Foreground(iconColor).Render(icon))
118+ content = append(content, icon)
130119 }
131120 content = append(content, title, description)
132− if ops.ExtraContent != "" {
133− content = append(content, ops.ExtraContent)
121+ if opts.ExtraContent != "" {
122+ content = append(content, opts.ExtraContent)
134123 }
135124
136125 return strings.Join(content, " ")
internal/tui/components/core/status_test.go−3
@@ -37,7 +37,6 @@ func TestStatus(t *testing.T) {
3737 {
3838 name: "NoIcon",
3939 opts: core.StatusOpts{
40− NoIcon: true,
4140 Title: "Info",
4241 Description: "This status has no icon",
4342 },
@@ -47,7 +46,6 @@ func TestStatus(t *testing.T) {
4746 name: "WithColors",
4847 opts: core.StatusOpts{
4948 Icon: "⚠",
50− IconColor: color.RGBA{255, 165, 0, 255}, // Orange
5149 Title: "Warning",
5250 TitleColor: color.RGBA{255, 255, 0, 255}, // Yellow
5351 Description: "This is a warning message",
@@ -102,7 +100,6 @@ func TestStatus(t *testing.T) {
102100 name: "AllFieldsWithExtraContent",
103101 opts: core.StatusOpts{
104102 Icon: "🚀",
105− IconColor: color.RGBA{0, 255, 0, 255}, // Green
106103 Title: "Deployment",
107104 TitleColor: color.RGBA{0, 0, 255, 255}, // Blue
108105 Description: "Deploying to production environment",
internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden+1−1
@@ -1 +1 @@
1−🚀 Deployment Deploying to production environment v1.2.3
1+🚀 Deployment Deploying to production environment v1.2.3
internal/tui/components/core/testdata/TestStatus/Default.golden+1−1
@@ -1 +1 @@
1−● Status Everything is working fine
1+Status Everything is working fine
internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden+1−1
@@ -1 +1 @@
1−● Title Only 
1+● Title Only 
internal/tui/components/core/testdata/TestStatus/LongDescription.golden+1−1
@@ -1 +1 @@
1−● Processing This is a very long description that should be…
1+Processing This is a very long description that should be …
internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden+1−1
@@ -1 +1 @@
1−● Status Short message
1+● Status Short message
internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden+1−1
@@ -1 +1 @@
1−● Test This will be…
1+● Test This will be…
internal/tui/components/core/testdata/TestStatus/WithColors.golden+1−1
@@ -1 +1 @@
1−⚠ Warning This is a warning message
1+⚠ Warning This is a warning message
internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden+1−1
@@ -1 +1 @@
1−✓ Success Operation completed successfully
1+✓ Success Operation completed successfully
internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden+1−1
@@ -1 +1 @@
1−● Build Building project [2/5]
1+Build Building project [2/5]
internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden+1−1
@@ -1 +1 @@
1−● Very Long Title  [extra]
1+● Very Long Title  [extra]
internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden+1−1
@@ -1 +1 @@
1−● Very Long Title Thi… [extra]
1+● Very Long Title Thi… [extra]
internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden+1−1
@@ -1 +1 @@
1−● Very Long Title This is an ex… [extra]
1+● Very Long Title This is an ex… [extra]
internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden+1−1
@@ -1 +1 @@
1−● Very Long Title This is an extremely lo… [extra]
1+● Very Long Title This is an extremely lo… [extra]
internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden+1−1
@@ -1 +1 @@
1−● Very Long Title This is an extremely long descrip… [extra]
1+● Very Long Title This is an extremely long descrip… [extra]
internal/tui/components/dialogs/commands/commands.go+4−2
@@ -119,7 +119,10 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
119119 case tea.WindowSizeMsg:
120120 c.wWidth = msg.Width
121121 c.wHeight = msg.Height
122− return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
122+ return c, tea.Batch(
123+ c.SetCommandType(c.commandType),
124+ c.commandList.SetSize(c.listWidth(), c.listHeight()),
125+ )
123126 case tea.KeyPressMsg:
124127 switch {
125128 case key.Matches(msg, c.keyMap.Select):
@@ -318,7 +321,6 @@ func (c *commandDialogCmp) defaultCommands() []Command {
318321 })
319322 }
320323 }
321−
322324 // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
323325 if c.wWidth > 120 && c.sessionID != "" {
324326 commands = append(commands, Command{
internal/tui/components/dialogs/models/list.go+10−5
@@ -3,6 +3,7 @@ package models
33import (
44 "fmt"
55 "slices"
6+ "strings"
67
78 tea "github.com/charmbracelet/bubbletea/v2"
89 "github.com/charmbracelet/catwalk/pkg/catwalk"
@@ -49,7 +50,15 @@ func (m *ModelListComponent) Init() tea.Cmd {
4950 var cmds []tea.Cmd
5051 if len(m.providers) == 0 {
5152 providers, err := config.Providers()
52− m.providers = providers
53+ filteredProviders := []catwalk.Provider{}
54+ for _, p := range providers {
55+ hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$")
56+ if hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure {
57+ filteredProviders = append(filteredProviders, p)
58+ }
59+ }
60+
61+ m.providers = filteredProviders
5362 if err != nil {
5463 cmds = append(cmds, util.ReportError(err))
5564 }
@@ -242,7 +251,3 @@ func (m *ModelListComponent) GetModelType() int {
242251func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
243252 m.list.SetInputPlaceholder(placeholder)
244253}
245−
246−func (m *ModelListComponent) SetProviders(providers []catwalk.Provider) {
247− m.providers = providers
248−}
internal/tui/components/dialogs/models/models.go−19
@@ -2,7 +2,6 @@ package models
22
33import (
44 "fmt"
5− "slices"
65 "time"
76
87 "github.com/charmbracelet/bubbles/v2/help"
@@ -96,24 +95,6 @@ func NewModelDialogCmp() ModelDialog {
9695}
9796
9897func (m *modelDialogCmp) Init() tea.Cmd {
99− providers, err := config.Providers()
100− if err == nil {
101− filteredProviders := []catwalk.Provider{}
102− simpleProviders := []string{
103− "anthropic",
104− "openai",
105− "gemini",
106− "xai",
107− "groq",
108− "openrouter",
109− }
110− for _, p := range providers {
111− if slices.Contains(simpleProviders, string(p.ID)) {
112− filteredProviders = append(filteredProviders, p)
113− }
114− }
115− m.modelList.SetProviders(filteredProviders)
116− }
11798 return tea.Batch(m.modelList.Init(), m.apiKeyInput.Init())
11899}
119100
internal/tui/components/files/files.gonew file+143
@@ -0,0 +1,143 @@
1+package files
2+
3+import (
4+ "fmt"
5+ "os"
6+ "sort"
7+ "strings"
8+
9+ "github.com/charmbracelet/lipgloss/v2"
10+ "github.com/charmbracelet/x/ansi"
11+
12+ "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/fsext"
14+ "github.com/charmbracelet/crush/internal/history"
15+ "github.com/charmbracelet/crush/internal/tui/components/core"
16+ "github.com/charmbracelet/crush/internal/tui/styles"
17+)
18+
19+// FileHistory represents a file history with initial and latest versions.
20+type FileHistory struct {
21+ InitialVersion history.File
22+ LatestVersion history.File
23+}
24+
25+// SessionFile represents a file with its history information.
26+type SessionFile struct {
27+ History FileHistory
28+ FilePath string
29+ Additions int
30+ Deletions int
31+}
32+
33+// RenderOptions contains options for rendering file lists.
34+type RenderOptions struct {
35+ MaxWidth int
36+ MaxItems int
37+ ShowSection bool
38+ SectionName string
39+}
40+
41+// RenderFileList renders a list of file status items with the given options.
42+func RenderFileList(fileSlice []SessionFile, opts RenderOptions) []string {
43+ t := styles.CurrentTheme()
44+ fileList := []string{}
45+
46+ if opts.ShowSection {
47+ sectionName := opts.SectionName
48+ if sectionName == "" {
49+ sectionName = "Modified Files"
50+ }
51+ section := t.S().Subtle.Render(sectionName)
52+ fileList = append(fileList, section, "")
53+ }
54+
55+ if len(fileSlice) == 0 {
56+ fileList = append(fileList, t.S().Base.Foreground(t.Border).Render("None"))
57+ return fileList
58+ }
59+
60+ // Sort files by the latest version's created time
61+ sort.Slice(fileSlice, func(i, j int) bool {
62+ if fileSlice[i].History.LatestVersion.CreatedAt == fileSlice[j].History.LatestVersion.CreatedAt {
63+ return strings.Compare(fileSlice[i].FilePath, fileSlice[j].FilePath) < 0
64+ }
65+ return fileSlice[i].History.LatestVersion.CreatedAt > fileSlice[j].History.LatestVersion.CreatedAt
66+ })
67+
68+ // Determine how many items to show
69+ maxItems := len(fileSlice)
70+ if opts.MaxItems > 0 {
71+ maxItems = min(opts.MaxItems, len(fileSlice))
72+ }
73+
74+ filesShown := 0
75+ for _, file := range fileSlice {
76+ if file.Additions == 0 && file.Deletions == 0 {
77+ continue // skip files with no changes
78+ }
79+ if filesShown >= maxItems {
80+ break
81+ }
82+
83+ var statusParts []string
84+ if file.Additions > 0 {
85+ statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
86+ }
87+ if file.Deletions > 0 {
88+ statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
89+ }
90+
91+ extraContent := strings.Join(statusParts, " ")
92+ cwd := config.Get().WorkingDir() + string(os.PathSeparator)
93+ filePath := file.FilePath
94+ filePath = strings.TrimPrefix(filePath, cwd)
95+ filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
96+ filePath = ansi.Truncate(filePath, opts.MaxWidth-lipgloss.Width(extraContent)-2, "…")
97+
98+ fileList = append(fileList,
99+ core.Status(
100+ core.StatusOpts{
101+ Title: filePath,
102+ ExtraContent: extraContent,
103+ },
104+ opts.MaxWidth,
105+ ),
106+ )
107+ filesShown++
108+ }
109+
110+ return fileList
111+}
112+
113+// RenderFileBlock renders a complete file block with optional truncation indicator.
114+func RenderFileBlock(fileSlice []SessionFile, opts RenderOptions, showTruncationIndicator bool) string {
115+ t := styles.CurrentTheme()
116+ fileList := RenderFileList(fileSlice, opts)
117+
118+ // Add truncation indicator if needed
119+ if showTruncationIndicator && opts.MaxItems > 0 {
120+ totalFilesWithChanges := 0
121+ for _, file := range fileSlice {
122+ if file.Additions > 0 || file.Deletions > 0 {
123+ totalFilesWithChanges++
124+ }
125+ }
126+ if totalFilesWithChanges > opts.MaxItems {
127+ remaining := totalFilesWithChanges - opts.MaxItems
128+ if remaining == 1 {
129+ fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("…"))
130+ } else {
131+ fileList = append(fileList,
132+ t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
133+ )
134+ }
135+ }
136+ }
137+
138+ content := lipgloss.JoinVertical(lipgloss.Left, fileList...)
139+ if opts.MaxWidth > 0 {
140+ return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content)
141+ }
142+ return content
143+}
internal/tui/components/lsp/lsp.gonew file+159
@@ -0,0 +1,159 @@
1+package lsp
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/charmbracelet/lipgloss/v2"
8+
9+ "github.com/charmbracelet/crush/internal/app"
10+ "github.com/charmbracelet/crush/internal/config"
11+ "github.com/charmbracelet/crush/internal/lsp"
12+ "github.com/charmbracelet/crush/internal/lsp/protocol"
13+ "github.com/charmbracelet/crush/internal/tui/components/core"
14+ "github.com/charmbracelet/crush/internal/tui/styles"
15+)
16+
17+// RenderOptions contains options for rendering LSP lists.
18+type RenderOptions struct {
19+ MaxWidth int
20+ MaxItems int
21+ ShowSection bool
22+ SectionName string
23+}
24+
25+// RenderLSPList renders a list of LSP status items with the given options.
26+func RenderLSPList(lspClients map[string]*lsp.Client, opts RenderOptions) []string {
27+ t := styles.CurrentTheme()
28+ lspList := []string{}
29+
30+ if opts.ShowSection {
31+ sectionName := opts.SectionName
32+ if sectionName == "" {
33+ sectionName = "LSPs"
34+ }
35+ section := t.S().Subtle.Render(sectionName)
36+ lspList = append(lspList, section, "")
37+ }
38+
39+ lspConfigs := config.Get().LSP.Sorted()
40+ if len(lspConfigs) == 0 {
41+ lspList = append(lspList, t.S().Base.Foreground(t.Border).Render("None"))
42+ return lspList
43+ }
44+
45+ // Get LSP states
46+ lspStates := app.GetLSPStates()
47+
48+ // Determine how many items to show
49+ maxItems := len(lspConfigs)
50+ if opts.MaxItems > 0 {
51+ maxItems = min(opts.MaxItems, len(lspConfigs))
52+ }
53+
54+ for i, l := range lspConfigs {
55+ if i >= maxItems {
56+ break
57+ }
58+
59+ // Determine icon color and description based on state
60+ icon := t.ItemOfflineIcon
61+ description := l.LSP.Command
62+
63+ if l.LSP.Disabled {
64+ description = t.S().Subtle.Render("disabled")
65+ } else if state, exists := lspStates[l.Name]; exists {
66+ switch state.State {
67+ case lsp.StateStarting:
68+ icon = t.ItemBusyIcon
69+ description = t.S().Subtle.Render("starting...")
70+ case lsp.StateReady:
71+ icon = t.ItemOnlineIcon
72+ description = l.LSP.Command
73+ case lsp.StateError:
74+ icon = t.ItemErrorIcon
75+ if state.Error != nil {
76+ description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
77+ } else {
78+ description = t.S().Subtle.Render("error")
79+ }
80+ }
81+ }
82+
83+ // Calculate diagnostic counts if we have LSP clients
84+ var extraContent string
85+ if lspClients != nil {
86+ lspErrs := map[protocol.DiagnosticSeverity]int{
87+ protocol.SeverityError: 0,
88+ protocol.SeverityWarning: 0,
89+ protocol.SeverityHint: 0,
90+ protocol.SeverityInformation: 0,
91+ }
92+ if client, ok := lspClients[l.Name]; ok {
93+ for _, diagnostics := range client.GetDiagnostics() {
94+ for _, diagnostic := range diagnostics {
95+ if severity, ok := lspErrs[diagnostic.Severity]; ok {
96+ lspErrs[diagnostic.Severity] = severity + 1
97+ }
98+ }
99+ }
100+ }
101+
102+ errs := []string{}
103+ if lspErrs[protocol.SeverityError] > 0 {
104+ errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
105+ }
106+ if lspErrs[protocol.SeverityWarning] > 0 {
107+ errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
108+ }
109+ if lspErrs[protocol.SeverityHint] > 0 {
110+ errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
111+ }
112+ if lspErrs[protocol.SeverityInformation] > 0 {
113+ errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
114+ }
115+ extraContent = strings.Join(errs, " ")
116+ }
117+
118+ lspList = append(lspList,
119+ core.Status(
120+ core.StatusOpts{
121+ Icon: icon.String(),
122+ Title: l.Name,
123+ Description: description,
124+ ExtraContent: extraContent,
125+ },
126+ opts.MaxWidth,
127+ ),
128+ )
129+ }
130+
131+ return lspList
132+}
133+
134+// RenderLSPBlock renders a complete LSP block with optional truncation indicator.
135+func RenderLSPBlock(lspClients map[string]*lsp.Client, opts RenderOptions, showTruncationIndicator bool) string {
136+ t := styles.CurrentTheme()
137+ lspList := RenderLSPList(lspClients, opts)
138+
139+ // Add truncation indicator if needed
140+ if showTruncationIndicator && opts.MaxItems > 0 {
141+ lspConfigs := config.Get().LSP.Sorted()
142+ if len(lspConfigs) > opts.MaxItems {
143+ remaining := len(lspConfigs) - opts.MaxItems
144+ if remaining == 1 {
145+ lspList = append(lspList, t.S().Base.Foreground(t.FgMuted).Render("…"))
146+ } else {
147+ lspList = append(lspList,
148+ t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
149+ )
150+ }
151+ }
152+ }
153+
154+ content := lipgloss.JoinVertical(lipgloss.Left, lspList...)
155+ if opts.MaxWidth > 0 {
156+ return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content)
157+ }
158+ return content
159+}
internal/tui/components/mcp/mcp.gonew file+126
@@ -0,0 +1,126 @@
1+package mcp
2+
3+import (
4+ "fmt"
5+
6+ "github.com/charmbracelet/lipgloss/v2"
7+
8+ "github.com/charmbracelet/crush/internal/config"
9+ "github.com/charmbracelet/crush/internal/llm/agent"
10+ "github.com/charmbracelet/crush/internal/tui/components/core"
11+ "github.com/charmbracelet/crush/internal/tui/styles"
12+)
13+
14+// RenderOptions contains options for rendering MCP lists.
15+type RenderOptions struct {
16+ MaxWidth int
17+ MaxItems int
18+ ShowSection bool
19+ SectionName string
20+}
21+
22+// RenderMCPList renders a list of MCP status items with the given options.
23+func RenderMCPList(opts RenderOptions) []string {
24+ t := styles.CurrentTheme()
25+ mcpList := []string{}
26+
27+ if opts.ShowSection {
28+ sectionName := opts.SectionName
29+ if sectionName == "" {
30+ sectionName = "MCPs"
31+ }
32+ section := t.S().Subtle.Render(sectionName)
33+ mcpList = append(mcpList, section, "")
34+ }
35+
36+ mcps := config.Get().MCP.Sorted()
37+ if len(mcps) == 0 {
38+ mcpList = append(mcpList, t.S().Base.Foreground(t.Border).Render("None"))
39+ return mcpList
40+ }
41+
42+ // Get MCP states
43+ mcpStates := agent.GetMCPStates()
44+
45+ // Determine how many items to show
46+ maxItems := len(mcps)
47+ if opts.MaxItems > 0 {
48+ maxItems = min(opts.MaxItems, len(mcps))
49+ }
50+
51+ for i, l := range mcps {
52+ if i >= maxItems {
53+ break
54+ }
55+
56+ // Determine icon and color based on state
57+ icon := t.ItemOfflineIcon
58+ description := l.MCP.Command
59+ extraContent := ""
60+
61+ if state, exists := mcpStates[l.Name]; exists {
62+ switch state.State {
63+ case agent.MCPStateDisabled:
64+ description = t.S().Subtle.Render("disabled")
65+ case agent.MCPStateStarting:
66+ icon = t.ItemBusyIcon
67+ description = t.S().Subtle.Render("starting...")
68+ case agent.MCPStateConnected:
69+ icon = t.ItemOnlineIcon
70+ if state.ToolCount > 0 {
71+ extraContent = t.S().Subtle.Render(fmt.Sprintf("(%d tools)", state.ToolCount))
72+ }
73+ case agent.MCPStateError:
74+ icon = t.ItemErrorIcon
75+ if state.Error != nil {
76+ description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
77+ } else {
78+ description = t.S().Subtle.Render("error")
79+ }
80+ }
81+ } else if l.MCP.Disabled {
82+ description = t.S().Subtle.Render("disabled")
83+ }
84+
85+ mcpList = append(mcpList,
86+ core.Status(
87+ core.StatusOpts{
88+ Icon: icon.String(),
89+ Title: l.Name,
90+ Description: description,
91+ ExtraContent: extraContent,
92+ },
93+ opts.MaxWidth,
94+ ),
95+ )
96+ }
97+
98+ return mcpList
99+}
100+
101+// RenderMCPBlock renders a complete MCP block with optional truncation indicator.
102+func RenderMCPBlock(opts RenderOptions, showTruncationIndicator bool) string {
103+ t := styles.CurrentTheme()
104+ mcpList := RenderMCPList(opts)
105+
106+ // Add truncation indicator if needed
107+ if showTruncationIndicator && opts.MaxItems > 0 {
108+ mcps := config.Get().MCP.Sorted()
109+ if len(mcps) > opts.MaxItems {
110+ remaining := len(mcps) - opts.MaxItems
111+ if remaining == 1 {
112+ mcpList = append(mcpList, t.S().Base.Foreground(t.FgMuted).Render("…"))
113+ } else {
114+ mcpList = append(mcpList,
115+ t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
116+ )
117+ }
118+ }
119+ }
120+
121+ content := lipgloss.JoinVertical(lipgloss.Left, mcpList...)
122+ if opts.MaxWidth > 0 {
123+ return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content)
124+ }
125+ return content
126+}
internal/tui/exp/list/list.go+9−2
@@ -3,6 +3,7 @@ package list
33import (
44 "slices"
55 "strings"
6+ "sync"
67
78 "github.com/charmbracelet/bubbles/v2/key"
89 tea "github.com/charmbracelet/bubbletea/v2"
@@ -90,6 +91,7 @@ type list[T Item] struct {
9091
9192 renderedItems *csync.Map[string, renderedItem]
9293
94+ renderMu sync.Mutex
9395 rendered string
9496
9597 movingByItem bool
@@ -328,7 +330,9 @@ func (l *list[T]) render() tea.Cmd {
328330 // we are not rendering the first time
329331 if l.rendered != "" {
330332 // rerender everything will mostly hit cache
333+ l.renderMu.Lock()
331334 l.rendered, _ = l.renderIterator(0, false, "")
335+ l.renderMu.Unlock()
332336 if l.direction == DirectionBackward {
333337 l.recalculateItemPositions()
334338 }
@@ -338,9 +342,10 @@ func (l *list[T]) render() tea.Cmd {
338342 }
339343 return focusChangeCmd
340344 }
345+ l.renderMu.Lock()
341346 rendered, finishIndex := l.renderIterator(0, true, "")
342347 l.rendered = rendered
343−
348+ l.renderMu.Unlock()
344349 // recalculate for the initial items
345350 if l.direction == DirectionBackward {
346351 l.recalculateItemPositions()
@@ -348,7 +353,10 @@ func (l *list[T]) render() tea.Cmd {
348353 renderCmd := func() tea.Msg {
349354 l.offset = 0
350355 // render the rest
356+
357+ l.renderMu.Lock()
351358 l.rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
359+ l.renderMu.Unlock()
352360 // needed for backwards
353361 if l.direction == DirectionBackward {
354362 l.recalculateItemPositions()
@@ -357,7 +365,6 @@ func (l *list[T]) render() tea.Cmd {
357365 if l.focused {
358366 l.scrollToSelection()
359367 }
360−
361368 return nil
362369 }
363370 return tea.Batch(focusChangeCmd, renderCmd)
internal/tui/styles/crush.go+10−1
@@ -1,11 +1,12 @@
11package styles
22
33import (
4+ "github.com/charmbracelet/lipgloss/v2"
45 "github.com/charmbracelet/x/exp/charmtone"
56)
67
78func NewCrushTheme() *Theme {
8− return &Theme{
9+ t := &Theme{
910 Name: "crush",
1011 IsDark: true,
1112
@@ -54,4 +55,12 @@ func NewCrushTheme() *Theme {
5455 RedLight: charmtone.Salmon,
5556 Cherry: charmtone.Cherry,
5657 }
58+
59+ // LSP and MCP status.
60+ t.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("●")
61+ t.ItemBusyIcon = t.ItemOfflineIcon.Foreground(charmtone.Citron)
62+ t.ItemErrorIcon = t.ItemOfflineIcon.Foreground(charmtone.Coral)
63+ t.ItemOnlineIcon = t.ItemOfflineIcon.Foreground(charmtone.Guac)
64+
65+ return t
5766}
internal/tui/styles/theme.go+6
@@ -74,6 +74,12 @@ type Theme struct {
7474 RedLight color.Color
7575 Cherry color.Color
7676
77+ // LSP and MCP status indicators.
78+ ItemOfflineIcon lipgloss.Style
79+ ItemBusyIcon lipgloss.Style
80+ ItemErrorIcon lipgloss.Style
81+ ItemOnlineIcon lipgloss.Style
82+
7783 styles *Styles
7884}
7985