on main help sign in
Merge branch 'charmbracelet:main' into esc_permission
05e4c80bbrodriges
.github/cla-signatures.json+48
@@ -199,6 +199,54 @@
199 "created_at": "2025-08-03T04:07:16Z",
199 "created_at": "2025-08-03T04:07:16Z",
200 "repoId": 987670088,
200 "repoId": 987670088,
201 "pullRequestNo": 519
201 "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
202 }
250 }
203 ]
251 ]
204}
252}
.github/dependabot.yml+2−2
@@ -9,7 +9,7 @@ updates:
9 time: "05:00"
9 time: "05:00"
10 timezone: "America/New_York"
10 timezone: "America/New_York"
11 labels:
11 labels:
12− - "dependencies"
12+ - "area: dependencies"
13 commit-message:
13 commit-message:
14 prefix: "chore"
14 prefix: "chore"
15 include: "scope"
15 include: "scope"
@@ -22,7 +22,7 @@ updates:
22 time: "05:00"
22 time: "05:00"
23 timezone: "America/New_York"
23 timezone: "America/New_York"
24 labels:
24 labels:
25− - "dependencies"
25+ - "area: dependencies"
26 commit-message:
26 commit-message:
27 prefix: "chore"
27 prefix: "chore"
28 include: "scope"
28 include: "scope"
.github/workflows/build.yml+2
@@ -23,7 +23,9 @@ jobs:
23 with:
23 with:
24 github-token: "${{ secrets.GITHUB_TOKEN }}"
24 github-token: "${{ secrets.GITHUB_TOKEN }}"
25 - run: |
25 - run: |
26+ echo "Approving..."
26 gh pr review --approve "$PR_URL"
27 gh pr review --approve "$PR_URL"
28+ echo "Merging..."
27 gh pr merge --squash --auto "$PR_URL"
29 gh pr merge --squash --auto "$PR_URL"
28 env:
30 env:
29 PR_URL: ${{github.event.pull_request.html_url}}
31 PR_URL: ${{github.event.pull_request.html_url}}
.goreleaser.yml+6
@@ -166,6 +166,12 @@ brews:
166 fish_completion.install "completions/{{ .ProjectName }}.fish"
166 fish_completion.install "completions/{{ .ProjectName }}.fish"
167 man1.install "manpages/{{ .ProjectName }}.1.gz"
167 man1.install "manpages/{{ .ProjectName }}.1.gz"
168
168
169+scoops:
170+ - repository:
171+ owner: charmbracelet
172+ name: scoop-bucket
173+ token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
174+
169npms:
175npms:
170 - name: "@charmland/crush"
176 - name: "@charmland/crush"
171 repository: "git+https://github.com/charmbracelet/crush.git"
177 repository: "git+https://github.com/charmbracelet/crush.git"
CRUSH.md+2
@@ -4,6 +4,8 @@
4
4
5- **Build**: `go build .` or `go run .`
5- **Build**: `go build .` or `go run .`
6- **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`)
6- **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")
7- **Lint**: `task lint-fix`
9- **Lint**: `task lint-fix`
8- **Format**: `task fmt` (gofumpt -w .)
10- **Format**: `task fmt` (gofumpt -w .)
9- **Dev**: `task dev` (runs with profiling enabled)
11- **Dev**: `task dev` (runs with profiling enabled)
README.md+107−8
@@ -39,7 +39,7 @@ nix run github:numtide/nix-ai-tools#crush
39
39
40<details>
40<details>
41<summary><strong>Nix (NUR)</strong></summary>
41<summary><strong>Nix (NUR)</strong></summary>
42−
42+
43Crush is available via [NUR](https://github.com/nix-community/NUR) in `nur.repos.charmbracelet.crush`.
43Crush is available via [NUR](https://github.com/nix-community/NUR) in `nur.repos.charmbracelet.crush`.
44
44
45You can also try out Crush via `nix-shell`:
45You 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
138customize Crush, configuration can be added either local to the project itself,
138customize Crush, configuration can be added either local to the project itself,
139or globally, with the following priority:
139or globally, with the following priority:
140
140
141−1. `./.crush.json`
141+1. `.crush.json`
142−2. `./crush.json`
142+2. `crush.json`
143−3. `$HOME/.config/crush/crush.json`
143+3. `$HOME/.config/crush/crush.json` (Windows: `%USERPROFILE%\AppData\Local\crush\crush.json`)
144
144
145Configuration itself is stored as a JSON object:
145Configuration itself is stored as a JSON object:
146
146
147```json
147```json
148{
148{
149− "this-setting": { }
149+ "this-setting": {"this": "that"},
150− "that-setting": { }
150+ "that-setting": ["ceci", "cela"]
151}
151}
152```
152```
153
153
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+
154### LSPs
164### LSPs
155
165
156Crush can use LSPs for additional context to help inform its decisions, just
166Crush 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.
221The `.crushignore` file uses the same syntax as `.gitignore` and can be placed
231The `.crushignore` file uses the same syntax as `.gitignore` and can be placed
222in the root of your project or in subdirectories.
232in the root of your project or in subdirectories.
223
233
224−### Whitelisting Tools
234+### Allowing Tools
225
235
226By default, Crush will ask you for permission before running tool calls. If
236By 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
228permissions. Use this with care.
238permissions. Use this with care.
229
239
230```json
240```json
@@ -245,6 +255,53 @@ permissions. Use this with care.
245You can also skip all permission prompts entirely by running Crush with the
255You can also skip all permission prompts entirely by running Crush with the
246`--yolo` flag. Be very, very careful with this feature.
256`--yolo` flag. Be very, very careful with this feature.
247
257
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+
248### Custom Providers
305### Custom Providers
249
306
250Crush supports custom provider configurations for both OpenAI-compatible and
307Crush supports custom provider configurations for both OpenAI-compatible and
@@ -314,6 +371,48 @@ Custom Anthropic-compatible providers follow this format:
314}
371}
315```
372```
316
373
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+
317## Logging
416## Logging
318
417
319Sometimes you need to look at logs. Luckily, Crush logs all sorts of
418Sometimes you need to look at logs. Luckily, Crush logs all sorts of
go.mod+13−6
@@ -6,7 +6,7 @@ require (
6 github.com/JohannesKaufmann/html-to-markdown v1.6.0
6 github.com/JohannesKaufmann/html-to-markdown v1.6.0
7 github.com/MakeNowJust/heredoc v1.0.0
7 github.com/MakeNowJust/heredoc v1.0.0
8 github.com/PuerkitoBio/goquery v1.9.2
8 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
10 github.com/anthropics/anthropic-sdk-go v1.6.2
10 github.com/anthropics/anthropic-sdk-go v1.6.2
11 github.com/atotto/clipboard v0.1.4
11 github.com/atotto/clipboard v0.1.4
12 github.com/aymanbagabas/go-udiff v0.3.1
12 github.com/aymanbagabas/go-udiff v0.3.1
@@ -14,7 +14,7 @@ require (
14 github.com/charlievieth/fastwalk v1.0.11
14 github.com/charlievieth/fastwalk v1.0.11
15 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
15 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
16 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d
16 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
18 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
18 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
19 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
19 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
20 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250721205738-ea66aa652ee0
20 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250721205738-ea66aa652ee0
@@ -27,7 +27,7 @@ require (
27 github.com/google/uuid v1.6.0
27 github.com/google/uuid v1.6.0
28 github.com/invopop/jsonschema v0.13.0
28 github.com/invopop/jsonschema v0.13.0
29 github.com/joho/godotenv v1.5.1
29 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
31 github.com/muesli/termenv v0.16.0
31 github.com/muesli/termenv v0.16.0
32 github.com/ncruces/go-sqlite3 v0.25.0
32 github.com/ncruces/go-sqlite3 v0.25.0
33 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
33 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
@@ -51,12 +51,19 @@ require (
51require (
51require (
52 cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
52 cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
53 github.com/bahlo/generic-list-go v0.2.0 // indirect
53 github.com/bahlo/generic-list-go v0.2.0 // indirect
54+ github.com/beorn7/perks v1.0.1 // indirect
54 github.com/buger/jsonparser v1.1.1 // indirect
55 github.com/buger/jsonparser v1.1.1 // indirect
56+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
55 github.com/mailru/easyjson v0.7.7 // indirect
57 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
56 github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
63 github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
57 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
64 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
58 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
65 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
60 golang.org/x/time v0.8.0 // indirect
67 golang.org/x/time v0.8.0 // indirect
61 google.golang.org/api v0.211.0 // indirect
68 google.golang.org/api v0.211.0 // indirect
62)
69)
@@ -93,7 +100,7 @@ require (
93 github.com/charmbracelet/x/windows v0.2.1 // indirect
100 github.com/charmbracelet/x/windows v0.2.1 // indirect
94 github.com/davecgh/go-spew v1.1.1 // indirect
101 github.com/davecgh/go-spew v1.1.1 // indirect
95 github.com/disintegration/gift v1.1.2 // indirect
102 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
97 github.com/dustin/go-humanize v1.0.1 // indirect
104 github.com/dustin/go-humanize v1.0.1 // indirect
98 github.com/felixge/httpsnoop v1.0.4 // indirect
105 github.com/felixge/httpsnoop v1.0.4 // indirect
99 github.com/go-logfmt/logfmt v0.6.0 // indirect
106 github.com/go-logfmt/logfmt v0.6.0 // indirect
@@ -144,7 +151,7 @@ require (
144 golang.org/x/sync v0.16.0 // indirect
151 golang.org/x/sync v0.16.0 // indirect
145 golang.org/x/sys v0.34.0
152 golang.org/x/sys v0.34.0
146 golang.org/x/term v0.32.0 // indirect
153 golang.org/x/term v0.32.0 // indirect
147− golang.org/x/text v0.25.0
154+ golang.org/x/text v0.27.0
148 google.golang.org/genai v1.3.0
155 google.golang.org/genai v1.3.0
149 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
156 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
150 google.golang.org/grpc v1.71.0 // indirect
157 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
22github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
22github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
23github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
23github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
24github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
24github.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=
25+github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
26−github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
26+github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
27−github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
27+github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
28−github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
28+github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
29github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
29github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
30github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
30github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
31github.com/anthropics/anthropic-sdk-go v1.6.2 h1:oORA212y0/zAxe7OPvdgIbflnn/x5PGk5uwjF60GqXM=
31github.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
68github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
68github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
69github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
69github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
70github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
70github.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=
71github.com/bmatcuk/doublestar/v4 v4.9.0 h1:DBvuZxjdKkRP/dr4GVV4w2fnmrk5Hxc90T51LZjv0JA=
73github.com/bmatcuk/doublestar/v4 v4.9.0 h1:DBvuZxjdKkRP/dr4GVV4w2fnmrk5Hxc90T51LZjv0JA=
72github.com/bmatcuk/doublestar/v4 v4.9.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
74github.com/bmatcuk/doublestar/v4 v4.9.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
73github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
75github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
74github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
76github.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=
75github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8=
79github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8=
76github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
80github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
77github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5 h1:GTcMIfDQJKyNKS+xVt7GkNIwz+tBuQtIuiP50WpzNgs=
81github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5 h1:GTcMIfDQJKyNKS+xVt7GkNIwz+tBuQtIuiP50WpzNgs=
78github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
82github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
79github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d h1:YMXLZHSo8DjytVY/b5dK8LDuyQsVUmBK3ydQMpu2Ui4=
83github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d h1:YMXLZHSo8DjytVY/b5dK8LDuyQsVUmBK3ydQMpu2Ui4=
80github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d/go.mod h1:XIQ1qQfRph6Z5o2EikCydjumo0oDInQySRHuPATzbZc=
84github.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=
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=
82−github.com/charmbracelet/catwalk v0.3.5/go.mod h1:gUUCqqZ8bk4D7ZzGTu3I77k7cC2x4exRuJBN1H2u2pc=
88+github.com/charmbracelet/catwalk v0.4.6/go.mod h1:WnKgNPmQHuMyk7GtwAQwl+ezHusfH40IvzML2qwUGwc=
83github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
89github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
84github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
90github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
85github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 h1:+Cz+VfxD5DO+JT1LlswXWhre0HYLj6l2HW8HVGfMuC0=
91github.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
118github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
124github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
119github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
125github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
120github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
126github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
121−github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
127+github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
122−github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
128+github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
123github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
129github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
124github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
130github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
125github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
131github.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
180github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
186github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
181github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
187github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
182github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
188github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
183−github.com/mark3labs/mcp-go v0.34.0 h1:eWy7WBGvhk6EyAAyVzivTCprE52iXJwNtvHV6Cv3bR0=
189+github.com/mark3labs/mcp-go v0.37.0 h1:BywvZLPRT6Zx6mMG/MJfxLSZQkTGIcJSEGKsvr4DsoQ=
184−github.com/mark3labs/mcp-go v0.34.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
190+github.com/mark3labs/mcp-go v0.37.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
185github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
191github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
186github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
192github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
187github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
193github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
@@ -202,6 +208,8 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
202github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
208github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
203github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
209github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
204github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
210github.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=
205github.com/ncruces/go-sqlite3 v0.25.0 h1:trugKUs98Zwy9KwRr/EUxZHL92LYt7UqcKqAfpGpK+I=
213github.com/ncruces/go-sqlite3 v0.25.0 h1:trugKUs98Zwy9KwRr/EUxZHL92LYt7UqcKqAfpGpK+I=
206github.com/ncruces/go-sqlite3 v0.25.0/go.mod h1:n6Z7036yFilJx04yV0mi5JWaF66rUmXn1It9Ux8dx68=
214github.com/ncruces/go-sqlite3 v0.25.0/go.mod h1:n6Z7036yFilJx04yV0mi5JWaF66rUmXn1It9Ux8dx68=
207github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
215github.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
221github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
229github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
222github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJzYU=
230github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJzYU=
223github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k=
231github.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=
224github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c h1:kmzxiX+OB0knCo1V0dkEkdPelzCdAzCURCfmFArn2/A=
240github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c h1:kmzxiX+OB0knCo1V0dkEkdPelzCdAzCURCfmFArn2/A=
225github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4=
241github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4=
226github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
242github.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=
332golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
348golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
333golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
349golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
334golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
350golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
335−golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
351+golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
336−golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
352+golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
337golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
353golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
338golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
354golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
339golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
355golang.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=
371golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
387golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
372golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
388golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
373golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
389golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
374−golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
390+golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
375−golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
391+golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
376golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
392golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
377golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
393golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
378golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
394golang.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() {
207 setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
207 setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
208 setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events)
208 setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events)
209 setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
209 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)
210 cleanupFunc := func() {
212 cleanupFunc := func() {
211 cancel()
213 cancel()
212 app.serviceEventsWG.Wait()
214 app.serviceEventsWG.Wait()
internal/app/lsp.go+11−1
@@ -22,13 +22,20 @@ func (app *App) initLSPClients(ctx context.Context) {
22func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) {
22func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) {
23 slog.Info("Creating LSP client", "name", name, "command", command, "args", args)
23 slog.Info("Creating LSP client", "name", name, "command", command, "args", args)
24
24
25+ // Update state to starting
26+ updateLSPState(name, lsp.StateStarting, nil, nil, 0)
27+
25 // Create LSP client.
28 // Create LSP client.
26− lspClient, err := lsp.NewClient(ctx, command, args...)
29+ lspClient, err := lsp.NewClient(ctx, name, command, args...)
27 if err != nil {
30 if err != nil {
28 slog.Error("Failed to create LSP client for", name, err)
31 slog.Error("Failed to create LSP client for", name, err)
32+ updateLSPState(name, lsp.StateError, err, nil, 0)
29 return
33 return
30 }
34 }
31
35
36+ // Set diagnostics callback
37+ lspClient.SetDiagnosticsCallback(updateLSPDiagnostics)
38+
32 // Increase initialization timeout as some servers take more time to start.
39 // Increase initialization timeout as some servers take more time to start.
33 initCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
40 initCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
34 defer cancel()
41 defer cancel()
@@ -37,6 +44,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
37 _, err = lspClient.InitializeLSPClient(initCtx, app.config.WorkingDir())
44 _, err = lspClient.InitializeLSPClient(initCtx, app.config.WorkingDir())
38 if err != nil {
45 if err != nil {
39 slog.Error("Initialize failed", "name", name, "error", err)
46 slog.Error("Initialize failed", "name", name, "error", err)
47+ updateLSPState(name, lsp.StateError, err, lspClient, 0)
40 lspClient.Close()
48 lspClient.Close()
41 return
49 return
42 }
50 }
@@ -47,10 +55,12 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
47 // Server never reached a ready state, but let's continue anyway, as
55 // Server never reached a ready state, but let's continue anyway, as
48 // some functionality might still work.
56 // some functionality might still work.
49 lspClient.SetServerState(lsp.StateError)
57 lspClient.SetServerState(lsp.StateError)
58+ updateLSPState(name, lsp.StateError, err, lspClient, 0)
50 } else {
59 } else {
51 // Server reached a ready state scuccessfully.
60 // Server reached a ready state scuccessfully.
52 slog.Info("LSP server is ready", "name", name)
61 slog.Info("LSP server is ready", "name", name)
53 lspClient.SetServerState(lsp.StateReady)
62 lspClient.SetServerState(lsp.StateReady)
63+ updateLSPState(name, lsp.StateReady, nil, lspClient, 0)
54 }
64 }
55
65
56 slog.Info("LSP client initialized", "name", name)
66 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(
159 if err != nil {
159 if err != nil {
160 return nil, err
160 return nil, err
161 }
161 }
162+
162 summarizeOpts := []provider.ProviderClientOption{
163 summarizeOpts := []provider.ProviderClientOption{
163− provider.WithModel(config.SelectedModelTypeSmall),
164+ provider.WithModel(config.SelectedModelTypeLarge),
164− provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, smallModelProviderCfg.ID)),
165+ provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, providerCfg.ID)),
165 }
166 }
166− summarizeProvider, err := provider.NewProvider(*smallModelProviderCfg, summarizeOpts...)
167+ summarizeProvider, err := provider.NewProvider(*providerCfg, summarizeOpts...)
167 if err != nil {
168 if err != nil {
168 return nil, err
169 return nil, err
169 }
170 }
@@ -224,7 +225,7 @@ func NewAgent(
224 sessions: sessions,
225 sessions: sessions,
225 titleProvider: titleProvider,
226 titleProvider: titleProvider,
226 summarizeProvider: summarizeProvider,
227 summarizeProvider: summarizeProvider,
227− summarizeProviderID: string(smallModelProviderCfg.ID),
228+ summarizeProviderID: string(providerCfg.ID),
228 activeRequests: csync.NewMap[string, context.CancelFunc](),
229 activeRequests: csync.NewMap[string, context.CancelFunc](),
229 tools: csync.NewLazySlice(toolFn),
230 tools: csync.NewLazySlice(toolFn),
230 }, nil
231 }, nil
@@ -904,54 +905,59 @@ func (a *agent) UpdateModel() error {
904 a.providerID = string(currentProviderCfg.ID)
905 a.providerID = string(currentProviderCfg.ID)
905 }
906 }
906
907
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)
908 smallModelCfg := cfg.Models[config.SelectedModelTypeSmall]
909 smallModelCfg := cfg.Models[config.SelectedModelTypeSmall]
909 var smallModelProviderCfg config.ProviderConfig
910 var smallModelProviderCfg config.ProviderConfig
910−
911 for p := range cfg.Providers.Seq() {
911 for p := range cfg.Providers.Seq() {
912 if p.ID == smallModelCfg.Provider {
912 if p.ID == smallModelCfg.Provider {
913 smallModelProviderCfg = p
913 smallModelProviderCfg = p
914 break
914 break
915 }
915 }
916 }
916 }
917−
918 if smallModelProviderCfg.ID == "" {
917 if smallModelProviderCfg.ID == "" {
919 return fmt.Errorf("provider %s not found in config", smallModelCfg.Provider)
918 return fmt.Errorf("provider %s not found in config", smallModelCfg.Provider)
920 }
919 }
921
920
922− // Check if summarize provider has changed
921+ largeModelCfg := cfg.Models[config.SelectedModelTypeLarge]
923− if string(smallModelProviderCfg.ID) != a.summarizeProviderID {
922+ var largeModelProviderCfg config.ProviderConfig
924− smallModel := cfg.GetModelByType(config.SelectedModelTypeSmall)
923+ for p := range cfg.Providers.Seq() {
925− if smallModel == nil {
924+ if p.ID == largeModelCfg.Provider {
926− return fmt.Errorf("model %s not found in provider %s", smallModelCfg.Model, smallModelProviderCfg.ID)
925+ largeModelProviderCfg = p
926+ break
927 }
927 }
928+ }
929+ if largeModelProviderCfg.ID == "" {
930+ return fmt.Errorf("provider %s not found in config", largeModelCfg.Provider)
931+ }
928
932
929− // Recreate title provider
933+ // Recreate title provider
930− titleOpts := []provider.ProviderClientOption{
934+ titleOpts := []provider.ProviderClientOption{
931− provider.WithModel(config.SelectedModelTypeSmall),
935+ provider.WithModel(config.SelectedModelTypeSmall),
932− provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptTitle, smallModelProviderCfg.ID)),
936+ 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),
937+ provider.WithMaxTokens(40),
935− }
938+ }
936− newTitleProvider, err := provider.NewProvider(smallModelProviderCfg, titleOpts...)
939+ newTitleProvider, err := provider.NewProvider(smallModelProviderCfg, titleOpts...)
937− if err != nil {
940+ if err != nil {
938− return fmt.Errorf("failed to create new title provider: %w", err)
941+ return fmt.Errorf("failed to create new title provider: %w", err)
939− }
942+ }
943+ a.titleProvider = newTitleProvider
940
944
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+ }
942 summarizeOpts := []provider.ProviderClientOption{
951 summarizeOpts := []provider.ProviderClientOption{
943− provider.WithModel(config.SelectedModelTypeSmall),
952+ provider.WithModel(config.SelectedModelTypeLarge),
944− provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, smallModelProviderCfg.ID)),
953+ provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, largeModelProviderCfg.ID)),
945 }
954 }
946− newSummarizeProvider, err := provider.NewProvider(smallModelProviderCfg, summarizeOpts...)
955+ newSummarizeProvider, err := provider.NewProvider(largeModelProviderCfg, summarizeOpts...)
947 if err != nil {
956 if err != nil {
948 return fmt.Errorf("failed to create new summarize provider: %w", err)
957 return fmt.Errorf("failed to create new summarize provider: %w", err)
949 }
958 }
950−
951− // Update the providers and provider ID
952− a.titleProvider = newTitleProvider
953 a.summarizeProvider = newSummarizeProvider
959 a.summarizeProvider = newSummarizeProvider
954− a.summarizeProviderID = string(smallModelProviderCfg.ID)
960+ a.summarizeProviderID = string(largeModelProviderCfg.ID)
955 }
961 }
956
962
957 return nil
963 return nil
internal/llm/agent/mcp-tools.go+142−7
@@ -7,23 +7,76 @@ import (
7 "log/slog"
7 "log/slog"
8 "slices"
8 "slices"
9 "sync"
9 "sync"
10+ "time"
10
11
11 "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/csync"
13 "github.com/charmbracelet/crush/internal/csync"
13 "github.com/charmbracelet/crush/internal/llm/tools"
14 "github.com/charmbracelet/crush/internal/llm/tools"
14− "github.com/charmbracelet/crush/internal/version"
15−
16 "github.com/charmbracelet/crush/internal/permission"
15 "github.com/charmbracelet/crush/internal/permission"
17−
16+ "github.com/charmbracelet/crush/internal/pubsub"
17+ "github.com/charmbracelet/crush/internal/version"
18 "github.com/mark3labs/mcp-go/client"
18 "github.com/mark3labs/mcp-go/client"
19 "github.com/mark3labs/mcp-go/client/transport"
19 "github.com/mark3labs/mcp-go/client/transport"
20 "github.com/mark3labs/mcp-go/mcp"
20 "github.com/mark3labs/mcp-go/mcp"
21)
21)
22
22
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+
23var (
74var (
24 mcpToolsOnce sync.Once
75 mcpToolsOnce sync.Once
25 mcpTools []tools.BaseTool
76 mcpTools []tools.BaseTool
26 mcpClients = csync.NewMap[string, *client.Client]()
77 mcpClients = csync.NewMap[string, *client.Client]()
78+ mcpStates = csync.NewMap[string, MCPClientInfo]()
79+ mcpBroker = pubsub.NewBroker[MCPEvent]()
27)
80)
28
81
29type McpTool struct {
82type McpTool struct {
@@ -109,6 +162,7 @@ func getTools(ctx context.Context, name string, permissions permission.Service,
109 result, err := c.ListTools(ctx, mcp.ListToolsRequest{})
162 result, err := c.ListTools(ctx, mcp.ListToolsRequest{})
110 if err != nil {
163 if err != nil {
111 slog.Error("error listing tools", "error", err)
164 slog.Error("error listing tools", "error", err)
165+ updateMCPState(name, MCPStateError, err, nil, 0)
112 c.Close()
166 c.Close()
113 mcpClients.Del(name)
167 mcpClients.Del(name)
114 return nil
168 return nil
@@ -125,11 +179,55 @@ func getTools(ctx context.Context, name string, permissions permission.Service,
125 return mcpTools
179 return mcpTools
126}
180}
127
181
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+
128// CloseMCPClients closes all MCP clients. This should be called during application shutdown.
225// CloseMCPClients closes all MCP clients. This should be called during application shutdown.
129func CloseMCPClients() {
226func CloseMCPClients() {
130 for c := range mcpClients.Seq() {
227 for c := range mcpClients.Seq() {
131 _ = c.Close()
228 _ = c.Close()
132 }
229 }
230+ mcpBroker.Shutdown()
133}
231}
134
232
135var mcpInitRequest = mcp.InitializeRequest{
233var mcpInitRequest = mcp.InitializeRequest{
@@ -145,25 +243,51 @@ var mcpInitRequest = mcp.InitializeRequest{
145func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) []tools.BaseTool {
243func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) []tools.BaseTool {
146 var wg sync.WaitGroup
244 var wg sync.WaitGroup
147 result := csync.NewSlice[tools.BaseTool]()
245 result := csync.NewSlice[tools.BaseTool]()
246+
247+ // Initialize states for all configured MCPs
148 for name, m := range cfg.MCP {
248 for name, m := range cfg.MCP {
149 if m.Disabled {
249 if m.Disabled {
250+ updateMCPState(name, MCPStateDisabled, nil, nil, 0)
150 slog.Debug("skipping disabled mcp", "name", name)
251 slog.Debug("skipping disabled mcp", "name", name)
151 continue
252 continue
152 }
253 }
254+
255+ // Set initial starting state
256+ updateMCPState(name, MCPStateStarting, nil, nil, 0)
257+
153 wg.Add(1)
258 wg.Add(1)
154 go func(name string, m config.MCPConfig) {
259 go func(name string, m config.MCPConfig) {
260+ defer func() {
155− defer wg.Done()
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+
156 c, err := createMcpClient(m)
277 c, err := createMcpClient(m)
157 if err != nil {
278 if err != nil {
279+ updateMCPState(name, MCPStateError, err, nil, 0)
158 slog.Error("error creating mcp client", "error", err, "name", name)
280 slog.Error("error creating mcp client", "error", err, "name", name)
159 return
281 return
160 }
282 }
161 if err := c.Start(ctx); err != nil {
283 if err := c.Start(ctx); err != nil {
284+ updateMCPState(name, MCPStateError, err, nil, 0)
162 slog.Error("error starting mcp client", "error", err, "name", name)
285 slog.Error("error starting mcp client", "error", err, "name", name)
163 _ = c.Close()
286 _ = c.Close()
164 return
287 return
165 }
288 }
166 if _, err := c.Initialize(ctx, mcpInitRequest); err != nil {
289 if _, err := c.Initialize(ctx, mcpInitRequest); err != nil {
290+ updateMCPState(name, MCPStateError, err, nil, 0)
167 slog.Error("error initializing mcp client", "error", err, "name", name)
291 slog.Error("error initializing mcp client", "error", err, "name", name)
168 _ = c.Close()
292 _ = c.Close()
169 return
293 return
@@ -172,7 +296,9 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con
172 slog.Info("Initialized mcp client", "name", name)
296 slog.Info("Initialized mcp client", "name", name)
173 mcpClients.Set(name, c)
297 mcpClients.Set(name, c)
174
298
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...)
176 }(name, m)
302 }(name, m)
177 }
303 }
178 wg.Wait()
304 wg.Wait()
@@ -182,22 +308,31 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con
182func createMcpClient(m config.MCPConfig) (*client.Client, error) {
308func createMcpClient(m config.MCPConfig) (*client.Client, error) {
183 switch m.Type {
309 switch m.Type {
184 case config.MCPStdio:
310 case config.MCPStdio:
185− return client.NewStdioMCPClient(
311+ return client.NewStdioMCPClientWithOptions(
186 m.Command,
312 m.Command,
187 m.ResolvedEnv(),
313 m.ResolvedEnv(),
188− m.Args...,
314+ m.Args,
315+ transport.WithCommandLogger(mcpLogger{}),
189 )
316 )
190 case config.MCPHttp:
317 case config.MCPHttp:
191 return client.NewStreamableHttpClient(
318 return client.NewStreamableHttpClient(
192 m.URL,
319 m.URL,
193 transport.WithHTTPHeaders(m.ResolvedHeaders()),
320 transport.WithHTTPHeaders(m.ResolvedHeaders()),
321+ transport.WithHTTPLogger(mcpLogger{}),
194 )
322 )
195 case config.MCPSse:
323 case config.MCPSse:
196 return client.NewSSEMCPClient(
324 return client.NewSSEMCPClient(
197 m.URL,
325 m.URL,
198 client.WithHeaders(m.ResolvedHeaders()),
326 client.WithHeaders(m.ResolvedHeaders()),
327+ transport.WithSSELogger(mcpLogger{}),
199 )
328 )
200 default:
329 default:
201 return nil, fmt.Errorf("unsupported mcp type: %s", m.Type)
330 return nil, fmt.Errorf("unsupported mcp type: %s", m.Type)
202 }
331 }
203}
332}
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
2
2
3import (
3import (
4 "context"
4 "context"
5+ "encoding/json"
5 "errors"
6 "errors"
6 "fmt"
7 "fmt"
7 "io"
8 "io"
8 "log/slog"
9 "log/slog"
10+ "slices"
9 "strings"
11 "strings"
10 "time"
12 "time"
11
13
@@ -14,6 +16,7 @@ import (
14 "github.com/charmbracelet/crush/internal/llm/tools"
16 "github.com/charmbracelet/crush/internal/llm/tools"
15 "github.com/charmbracelet/crush/internal/log"
17 "github.com/charmbracelet/crush/internal/log"
16 "github.com/charmbracelet/crush/internal/message"
18 "github.com/charmbracelet/crush/internal/message"
19+ "github.com/google/uuid"
17 "github.com/openai/openai-go"
20 "github.com/openai/openai-go"
18 "github.com/openai/openai-go/option"
21 "github.com/openai/openai-go/option"
19 "github.com/openai/openai-go/packages/param"
22 "github.com/openai/openai-go/packages/param"
@@ -70,8 +73,9 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
70 systemMessage = o.providerOptions.systemPromptPrefix + "\n" + systemMessage
73 systemMessage = o.providerOptions.systemPromptPrefix + "\n" + systemMessage
71 }
74 }
72
75
73− systemTextBlock := openai.ChatCompletionContentPartTextParam{Text: systemMessage}
76+ system := openai.SystemMessage(systemMessage)
74 if isAnthropicModel && !o.providerOptions.disableCache {
77 if isAnthropicModel && !o.providerOptions.disableCache {
78+ systemTextBlock := openai.ChatCompletionContentPartTextParam{Text: systemMessage}
75 systemTextBlock.SetExtraFields(
79 systemTextBlock.SetExtraFields(
76 map[string]any{
80 map[string]any{
77 "cache_control": map[string]string{
81 "cache_control": map[string]string{
@@ -79,10 +83,10 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
79 },
83 },
80 },
84 },
81 )
85 )
86+ var content []openai.ChatCompletionContentPartTextParam
87+ content = append(content, systemTextBlock)
88+ system = openai.SystemMessage(content)
82 }
89 }
83− var content []openai.ChatCompletionContentPartTextParam
84− content = append(content, systemTextBlock)
85− system := openai.SystemMessage(content)
86 openaiMessages = append(openaiMessages, system)
90 openaiMessages = append(openaiMessages, system)
87
91
88 for i, msg := range messages {
92 for i, msg := range messages {
@@ -93,9 +97,12 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
93 switch msg.Role {
97 switch msg.Role {
94 case message.User:
98 case message.User:
95 var content []openai.ChatCompletionContentPartUnionParam
99 var content []openai.ChatCompletionContentPartUnionParam
100+
96 textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
101 textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
97 content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
102 content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
103+ hasBinaryContent := false
98 for _, binaryContent := range msg.BinaryContent() {
104 for _, binaryContent := range msg.BinaryContent() {
105+ hasBinaryContent = true
99 imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(catwalk.InferenceProviderOpenAI)}
106 imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(catwalk.InferenceProviderOpenAI)}
100 imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
107 imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
101
108
@@ -108,8 +115,11 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
108 },
115 },
109 })
116 })
110 }
117 }
111−
118+ if hasBinaryContent || (isAnthropicModel && !o.providerOptions.disableCache) {
112− openaiMessages = append(openaiMessages, openai.UserMessage(content))
119+ openaiMessages = append(openaiMessages, openai.UserMessage(content))
120+ } else {
121+ openaiMessages = append(openaiMessages, openai.UserMessage(msg.Content().String()))
122+ }
113
123
114 case message.Assistant:
124 case message.Assistant:
115 assistantMsg := openai.ChatCompletionAssistantMessageParam{
125 assistantMsg := openai.ChatCompletionAssistantMessageParam{
@@ -134,13 +144,15 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
134 },
144 },
135 },
145 },
136 }
146 }
147+ if !isAnthropicModel {
148+ assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
149+ OfString: param.NewOpt(msg.Content().String()),
150+ }
151+ }
137 }
152 }
138
153
139 if len(msg.ToolCalls()) > 0 {
154 if len(msg.ToolCalls()) > 0 {
140 hasContent = true
155 hasContent = true
141− assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
142− OfString: param.NewOpt(msg.Content().String()),
143− }
144 assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(msg.ToolCalls()))
156 assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(msg.ToolCalls()))
145 for i, call := range msg.ToolCalls() {
157 for i, call := range msg.ToolCalls() {
146 assistantMsg.ToolCalls[i] = openai.ChatCompletionMessageToolCallParam{
158 assistantMsg.ToolCalls[i] = openai.ChatCompletionMessageToolCallParam{
@@ -329,21 +341,26 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
329 acc := openai.ChatCompletionAccumulator{}
341 acc := openai.ChatCompletionAccumulator{}
330 currentContent := ""
342 currentContent := ""
331 toolCalls := make([]message.ToolCall, 0)
343 toolCalls := make([]message.ToolCall, 0)
332−
333− var currentToolCallID string
334− var currentToolCall openai.ChatCompletionMessageToolCall
335 var msgToolCalls []openai.ChatCompletionMessageToolCall
344 var msgToolCalls []openai.ChatCompletionMessageToolCall
336− currentToolIndex := 0
337 for openaiStream.Next() {
345 for openaiStream.Next() {
338 chunk := openaiStream.Current()
346 chunk := openaiStream.Current()
339 // Kujtim: this is an issue with openrouter qwen, its sending -1 for the tool index
347 // Kujtim: this is an issue with openrouter qwen, its sending -1 for the tool index
340 if len(chunk.Choices) > 0 && len(chunk.Choices[0].Delta.ToolCalls) > 0 && chunk.Choices[0].Delta.ToolCalls[0].Index == -1 {
348 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)
349+ chunk.Choices[0].Delta.ToolCalls[0].Index = 0
342− currentToolIndex++
343 }
350 }
344 acc.AddChunk(chunk)
351 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+ }
347 if choice.Delta.Content != "" {
364 if choice.Delta.Content != "" {
348 eventChan <- ProviderEvent{
365 eventChan <- ProviderEvent{
349 Type: EventContentDelta,
366 Type: EventContentDelta,
@@ -352,63 +369,50 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
352 currentContent += choice.Delta.Content
369 currentContent += choice.Delta.Content
353 } else if len(choice.Delta.ToolCalls) > 0 {
370 } else if len(choice.Delta.ToolCalls) > 0 {
354 toolCall := choice.Delta.ToolCalls[0]
371 toolCall := choice.Delta.ToolCalls[0]
355− // Detect tool use start
372+ newToolCall := false
356− if currentToolCallID == "" {
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 {
357− if toolCall.ID != "" {
379+ if tool.ID == toolCall.ID {
358− currentToolCallID = toolCall.ID
380+ msgToolCalls[i].Function.Arguments += toolCall.Function.Arguments
359− eventChan <- ProviderEvent{
381+ found = true
360− Type: EventToolUseStart,
361− ToolCall: &message.ToolCall{
362− ID: toolCall.ID,
363− Name: toolCall.Function.Name,
364− Finished: false,
365− },
382+ }
366 }
383 }
367− currentToolCall = openai.ChatCompletionMessageToolCall{
384+ if !found {
368− ID: toolCall.ID,
385+ newToolCall = true
369− Type: "function",
370− Function: openai.ChatCompletionMessageToolCallFunction{
371− Name: toolCall.Function.Name,
372− Arguments: toolCall.Function.Arguments,
373− },
374 }
386 }
375− }
376− } else {
377− // Delta tool use
378− if toolCall.ID == "" || toolCall.ID == currentToolCallID {
379− currentToolCall.Function.Arguments += toolCall.Function.Arguments
380 } else {
387 } else {
381− // Detect new tool use
388+ msgToolCalls[toolCall.Index].Function.Arguments += toolCall.Function.Arguments
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− }
402 }
389 }
390+ } else {
391+ newToolCall = true
403 }
392 }
404− }
393+ if newToolCall { // new tool call
405− // Kujtim: some models send finish stop even for tool calls
394+ if toolCall.ID == "" {
406− if choice.FinishReason == "tool_calls" || (choice.FinishReason == "stop" && currentToolCallID != "") {
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+ }
407− msgToolCalls = append(msgToolCalls, currentToolCall)
405+ msgToolCalls = append(msgToolCalls, openai.ChatCompletionMessageToolCall{
408− if len(acc.Choices) > 0 {
406+ ID: toolCall.ID,
409− acc.Choices[0].Message.ToolCalls = msgToolCalls
407+ Type: "function",
408+ Function: openai.ChatCompletionMessageToolCallFunction{
409+ Name: toolCall.Function.Name,
410+ Arguments: toolCall.Function.Arguments,
411+ },
412+ })
410 }
413 }
411 }
414 }
415+ acc.Choices[i].Message.ToolCalls = slices.Clone(msgToolCalls)
412 }
416 }
413 }
417 }
414
418
internal/llm/tools/tools.go+74−1
@@ -3,6 +3,8 @@ package tools
3import (
3import (
4 "context"
4 "context"
5 "encoding/json"
5 "encoding/json"
6+ "fmt"
7+ "strings"
6)
8)
7
9
8type ToolInfo struct {
10type ToolInfo struct {
@@ -25,6 +27,10 @@ const (
25
27
26 SessionIDContextKey sessionIDContextKey = "session_id"
28 SessionIDContextKey sessionIDContextKey = "session_id"
27 MessageIDContextKey messageIDContextKey = "message_id"
29 MessageIDContextKey messageIDContextKey = "message_id"
30+
31+ maxResponseWidth = 3000
32+ maxResponseHeight = 5000
33+ maxResponseChars = 50000
28)
34)
29
35
30type ToolResponse struct {
36type ToolResponse struct {
@@ -37,10 +43,77 @@ type ToolResponse struct {
37func NewTextResponse(content string) ToolResponse {
43func NewTextResponse(content string) ToolResponse {
38 return ToolResponse{
44 return ToolResponse{
39 Type: ToolResponseTypeText,
45 Type: ToolResponseTypeText,
40− Content: content,
46+ Content: truncateContent(content),
41 }
47 }
42}
48}
43
49
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+
44func WithResponseMetadata(response ToolResponse, metadata any) ToolResponse {
117func WithResponseMetadata(response ToolResponse, metadata any) ToolResponse {
45 if metadata != nil {
118 if metadata != nil {
46 metadataBytes, err := json.Marshal(metadata)
119 metadataBytes, err := json.Marshal(metadata)
internal/lsp/client.go+18−1
@@ -26,6 +26,12 @@ type Client struct {
26 stdout *bufio.Reader
26 stdout *bufio.Reader
27 stderr io.ReadCloser
27 stderr io.ReadCloser
28
28
29+ // Client name for identification
30+ name string
31+
32+ // Diagnostic change callback
33+ onDiagnosticsChanged func(name string, count int)
34+
29 // Request ID counter
35 // Request ID counter
30 nextID atomic.Int32
36 nextID atomic.Int32
31
37
@@ -53,7 +59,7 @@ type Client struct {
53 serverState atomic.Value
59 serverState atomic.Value
54}
60}
55
61
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) {
57 cmd := exec.CommandContext(ctx, command, args...)
63 cmd := exec.CommandContext(ctx, command, args...)
58 // Copy env
64 // Copy env
59 cmd.Env = os.Environ()
65 cmd.Env = os.Environ()
@@ -75,6 +81,7 @@ func NewClient(ctx context.Context, command string, args ...string) (*Client, er
75
81
76 client := &Client{
82 client := &Client{
77 Cmd: cmd,
83 Cmd: cmd,
84+ name: name,
78 stdin: stdin,
85 stdin: stdin,
79 stdout: bufio.NewReader(stdout),
86 stdout: bufio.NewReader(stdout),
80 stderr: stderr,
87 stderr: stderr,
@@ -284,6 +291,16 @@ func (c *Client) SetServerState(state ServerState) {
284 c.serverState.Store(state)
291 c.serverState.Store(state)
285}
292}
286
293
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+
287// WaitForServerReady waits for the server to be ready by polling the server
304// WaitForServerReady waits for the server to be ready by polling the server
288// with a simple request until it responds successfully or times out
305// with a simple request until it responds successfully or times out
289func (c *Client) WaitForServerReady(ctx context.Context) error {
306func (c *Client) WaitForServerReady(ctx context.Context) error {
internal/lsp/handlers.go+12−2
@@ -103,7 +103,17 @@ func HandleDiagnostics(client *Client, params json.RawMessage) {
103 }
103 }
104
104
105 client.diagnosticsMu.Lock()
105 client.diagnosticsMu.Lock()
106− defer client.diagnosticsMu.Unlock()
107−
108 client.diagnostics[diagParams.URI] = diagParams.Diagnostics
106 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+ }
109}
119}
internal/tui/components/chat/messages/messages.go+3−1
@@ -274,11 +274,13 @@ func (m *messageCmp) renderThinkingContent() string {
274 if reasoningContent.StartedAt > 0 {
274 if reasoningContent.StartedAt > 0 {
275 duration := m.message.ThinkingDuration()
275 duration := m.message.ThinkingDuration()
276 if reasoningContent.FinishedAt > 0 {
276 if reasoningContent.FinishedAt > 0 {
277+ if duration.String() == "0s" {
278+ return ""
279+ }
277 m.anim.SetLabel("")
280 m.anim.SetLabel("")
278 opts := core.StatusOpts{
281 opts := core.StatusOpts{
279 Title: "Thought for",
282 Title: "Thought for",
280 Description: duration.String(),
283 Description: duration.String(),
281− NoIcon: true,
282 }
284 }
283 return t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1))
285 return t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1))
284 } else if finishReason != nil && finishReason.Reason == message.FinishReasonCanceled {
286 } else if finishReason != nil && finishReason.Reason == message.FinishReasonCanceled {
internal/tui/components/chat/sidebar/sidebar.go+76−411
@@ -5,7 +5,6 @@ import (
5 "fmt"
5 "fmt"
6 "os"
6 "os"
7 "slices"
7 "slices"
8− "sort"
9 "strings"
8 "strings"
10
9
11 tea "github.com/charmbracelet/bubbletea/v2"
10 tea "github.com/charmbracelet/bubbletea/v2"
@@ -13,21 +12,21 @@ import (
13 "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/csync"
13 "github.com/charmbracelet/crush/internal/csync"
15 "github.com/charmbracelet/crush/internal/diff"
14 "github.com/charmbracelet/crush/internal/diff"
16− "github.com/charmbracelet/crush/internal/fsext"
17 "github.com/charmbracelet/crush/internal/history"
15 "github.com/charmbracelet/crush/internal/history"
18 "github.com/charmbracelet/crush/internal/lsp"
16 "github.com/charmbracelet/crush/internal/lsp"
19− "github.com/charmbracelet/crush/internal/lsp/protocol"
20 "github.com/charmbracelet/crush/internal/pubsub"
17 "github.com/charmbracelet/crush/internal/pubsub"
21 "github.com/charmbracelet/crush/internal/session"
18 "github.com/charmbracelet/crush/internal/session"
22 "github.com/charmbracelet/crush/internal/tui/components/chat"
19 "github.com/charmbracelet/crush/internal/tui/components/chat"
23 "github.com/charmbracelet/crush/internal/tui/components/core"
20 "github.com/charmbracelet/crush/internal/tui/components/core"
24 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
21 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
22+ "github.com/charmbracelet/crush/internal/tui/components/files"
25 "github.com/charmbracelet/crush/internal/tui/components/logo"
23 "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"
26 "github.com/charmbracelet/crush/internal/tui/styles"
26 "github.com/charmbracelet/crush/internal/tui/styles"
27 "github.com/charmbracelet/crush/internal/tui/util"
27 "github.com/charmbracelet/crush/internal/tui/util"
28 "github.com/charmbracelet/crush/internal/version"
28 "github.com/charmbracelet/crush/internal/version"
29 "github.com/charmbracelet/lipgloss/v2"
29 "github.com/charmbracelet/lipgloss/v2"
30− "github.com/charmbracelet/x/ansi"
31 "golang.org/x/text/cases"
30 "golang.org/x/text/cases"
32 "golang.org/x/text/language"
31 "golang.org/x/text/language"
33)
32)
@@ -382,459 +381,125 @@ func (m *sidebarCmp) renderSectionsHorizontal() string {
382
381
383// filesBlockCompact renders the files block with limited width and height for horizontal layout
382// filesBlockCompact renders the files block with limited width and height for horizontal layout
384func (m *sidebarCmp) filesBlockCompact(maxWidth int) string {
383func (m *sidebarCmp) filesBlockCompact(maxWidth int) string {
385− t := styles.CurrentTheme()
384+ // Convert map to slice and handle type conversion
386−
387− section := t.S().Subtle.Render("Modified Files")
388−
389− files := slices.Collect(m.files.Seq())
385+ sessionFiles := slices.Collect(m.files.Seq())
390−
386+ fileSlice := make([]files.SessionFile, len(sessionFiles))
391− if len(files) == 0 {
387+ for i, sf := range sessionFiles {
392− content := lipgloss.JoinVertical(
388+ fileSlice[i] = files.SessionFile{
393− lipgloss.Left,
389+ History: files.FileHistory{
394− section,
390+ InitialVersion: sf.History.initialVersion,
395− "",
391+ LatestVersion: sf.History.latestVersion,
396− t.S().Base.Foreground(t.Border).Render("None"),
392+ },
397− )
393+ FilePath: sf.FilePath,
398− return lipgloss.NewStyle().Width(maxWidth).Render(content)
394+ Additions: sf.Additions,
395+ Deletions: sf.Deletions,
396+ }
399 }
397 }
400
398
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
399+ // Limit items for horizontal layout
407− maxItems := min(5, len(files))
400+ maxItems := min(5, len(fileSlice))
408 availableHeight := m.height - 8 // Reserve space for header and other content
401 availableHeight := m.height - 8 // Reserve space for header and other content
409 if availableHeight > 0 {
402 if availableHeight > 0 {
410 maxItems = min(maxItems, availableHeight)
403 maxItems = min(maxItems, availableHeight)
411 }
404 }
412
405
413− filesShown := 0
406+ return files.RenderFileBlock(fileSlice, files.RenderOptions{
414− for _, file := range files {
407+ MaxWidth: maxWidth,
415− if file.Additions == 0 && file.Deletions == 0 {
408+ MaxItems: maxItems,
416− continue
409+ ShowSection: true,
417− }
410+ SectionName: "Modified Files",
418− if filesShown >= maxItems {
411+ }, true)
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)
464}
412}
465
413
466// lspBlockCompact renders the LSP block with limited width and height for horizontal layout
414// lspBlockCompact renders the LSP block with limited width and height for horizontal layout
467func (m *sidebarCmp) lspBlockCompact(maxWidth int) string {
415func (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−
485 // Limit items for horizontal layout
416 // Limit items for horizontal layout
417+ lspConfigs := config.Get().LSP.Sorted()
486− maxItems := min(5, len(lsp))
418+ maxItems := min(5, len(lspConfigs))
487 availableHeight := m.height - 8
419 availableHeight := m.height - 8
488 if availableHeight > 0 {
420 if availableHeight > 0 {
489 maxItems = min(maxItems, availableHeight)
421 maxItems = min(maxItems, availableHeight)
490 }
422 }
491
423
492− for i, l := range lsp {
424+ return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
493− if i >= maxItems {
425+ MaxWidth: maxWidth,
494− break
426+ MaxItems: maxItems,
495− }
427+ ShowSection: true,
496−
428+ SectionName: "LSPs",
497− iconColor := t.Success
429+ }, true)
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)
552}
430}
553
431
554// mcpBlockCompact renders the MCP block with limited width and height for horizontal layout
432// mcpBlockCompact renders the MCP block with limited width and height for horizontal layout
555func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
433func (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−
573 // Limit items for horizontal layout
434 // Limit items for horizontal layout
574− maxItems := min(5, len(mcps))
435+ maxItems := min(5, len(config.Get().MCP.Sorted()))
575 availableHeight := m.height - 8
436 availableHeight := m.height - 8
576 if availableHeight > 0 {
437 if availableHeight > 0 {
577 maxItems = min(maxItems, availableHeight)
438 maxItems = min(maxItems, availableHeight)
578 }
439 }
579
440
580− for i, l := range mcps {
441+ return mcp.RenderMCPBlock(mcp.RenderOptions{
581− if i >= maxItems {
442+ MaxWidth: maxWidth,
582− break
443+ MaxItems: maxItems,
583− }
444+ ShowSection: true,
584−
445+ SectionName: "MCPs",
585− iconColor := t.Success
446+ }, true)
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)
609}
447}
610
448
611func (m *sidebarCmp) filesBlock() string {
449func (m *sidebarCmp) filesBlock() string {
612− t := styles.CurrentTheme()
450+ // Convert map to slice and handle type conversion
613−
614− section := t.S().Subtle.Render(
615− core.Section("Modified Files", m.getMaxWidth()),
616− )
617−
618− files := slices.Collect(m.files.Seq())
451+ sessionFiles := slices.Collect(m.files.Seq())
619− if len(files) == 0 {
452+ fileSlice := make([]files.SessionFile, len(sessionFiles))
620− return lipgloss.JoinVertical(
453+ for i, sf := range sessionFiles {
621− lipgloss.Left,
454+ fileSlice[i] = files.SessionFile{
622− section,
455+ History: files.FileHistory{
456+ InitialVersion: sf.History.initialVersion,
457+ LatestVersion: sf.History.latestVersion,
623− "",
458+ },
624− t.S().Base.Foreground(t.Border).Render("None"),
459+ FilePath: sf.FilePath,
625− )
460+ Additions: sf.Additions,
461+ Deletions: sf.Deletions,
462+ }
626 }
463 }
627
464
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−
634 // Limit the number of files shown
465 // Limit the number of files shown
635 maxFiles, _, _ := m.getDynamicLimits()
466 maxFiles, _, _ := m.getDynamicLimits()
636− maxFiles = min(len(files), maxFiles)
467+ maxFiles = min(len(fileSlice), maxFiles)
637− filesShown := 0
468+
638−
469+ return files.RenderFileBlock(fileSlice, files.RenderOptions{
639− for _, file := range files {
470+ MaxWidth: m.getMaxWidth(),
640− if file.Additions == 0 && file.Deletions == 0 {
471+ MaxItems: maxFiles,
641− continue // skip files with no changes
472+ ShowSection: true,
642− }
473+ SectionName: core.Section("Modified Files", m.getMaxWidth()),
643− if filesShown >= maxFiles {
474+ }, true)
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− )
693}
475}
694
476
695func (m *sidebarCmp) lspBlock() string {
477func (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−
714 // Limit the number of LSPs shown
478 // Limit the number of LSPs shown
715 _, maxLSPs, _ := m.getDynamicLimits()
479 _, maxLSPs, _ := m.getDynamicLimits()
480+ lspConfigs := config.Get().LSP.Sorted()
716− maxLSPs = min(len(lsp), maxLSPs)
481+ maxLSPs = min(len(lspConfigs), maxLSPs)
717− for i, l := range lsp {
482+
718− if i >= maxLSPs {
483+ return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
719− break
484+ MaxWidth: m.getMaxWidth(),
720− }
485+ MaxItems: maxLSPs,
721−
486+ ShowSection: true,
722− iconColor := t.Success
487+ SectionName: core.Section("LSPs", m.getMaxWidth()),
723− if l.LSP.Disabled {
488+ }, true)
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− )
781}
489}
782
490
783func (m *sidebarCmp) mcpBlock() string {
491func (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−
802 // Limit the number of MCPs shown
492 // Limit the number of MCPs shown
803 _, _, maxMCPs := m.getDynamicLimits()
493 _, _, maxMCPs := m.getDynamicLimits()
494+ mcps := config.Get().MCP.Sorted()
804 maxMCPs = min(len(mcps), maxMCPs)
495 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− }
833
496
834− return lipgloss.JoinVertical(
497+ return mcp.RenderMCPBlock(mcp.RenderOptions{
835− lipgloss.Left,
498+ MaxWidth: m.getMaxWidth(),
836− mcpList...,
499+ MaxItems: maxMCPs,
837− )
500+ ShowSection: true,
501+ SectionName: core.Section("MCPs", m.getMaxWidth()),
502+ }, true)
838}
503}
839
504
840func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
505func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
internal/tui/components/chat/splash/splash.go+11−69
@@ -3,7 +3,6 @@ package splash
3import (
3import (
4 "fmt"
4 "fmt"
5 "os"
5 "os"
6− "slices"
7 "strings"
6 "strings"
8 "time"
7 "time"
9
8
@@ -18,6 +17,8 @@ import (
18 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
17 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
18 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
20 "github.com/charmbracelet/crush/internal/tui/components/logo"
19 "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"
21 "github.com/charmbracelet/crush/internal/tui/exp/list"
22 "github.com/charmbracelet/crush/internal/tui/exp/list"
22 "github.com/charmbracelet/crush/internal/tui/styles"
23 "github.com/charmbracelet/crush/internal/tui/styles"
23 "github.com/charmbracelet/crush/internal/tui/util"
24 "github.com/charmbracelet/crush/internal/tui/util"
@@ -101,27 +102,6 @@ func New() Splash {
101
102
102func (s *splashCmp) SetOnboarding(onboarding bool) {
103func (s *splashCmp) SetOnboarding(onboarding bool) {
103 s.isOnboarding = onboarding
104 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− }
125}
105}
126
106
127func (s *splashCmp) SetProjectInit(needsInit bool) {
107func (s *splashCmp) SetProjectInit(needsInit bool) {
@@ -655,7 +635,7 @@ func (s *splashCmp) Bindings() []key.Binding {
655}
635}
656
636
657func (s *splashCmp) getMaxInfoWidth() int {
637func (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
659}
639}
660
640
661func (s *splashCmp) cwd() string {
641func (s *splashCmp) cwd() string {
@@ -670,29 +650,10 @@ func (s *splashCmp) cwd() string {
670}
650}
671
651
672func LSPList(maxWidth int) []string {
652func LSPList(maxWidth int) []string {
673− t := styles.CurrentTheme()
653+ return lspcomponent.RenderLSPList(nil, lspcomponent.RenderOptions{
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,
654+ MaxWidth: maxWidth,
692− ),
655+ ShowSection: false,
693− )
694− }
656+ })
695− return lspList
696}
657}
697
658
698func (s *splashCmp) lspBlock() string {
659func (s *splashCmp) lspBlock() string {
@@ -709,29 +670,10 @@ func (s *splashCmp) lspBlock() string {
709}
670}
710
671
711func MCPList(maxWidth int) []string {
672func MCPList(maxWidth int) []string {
712− t := styles.CurrentTheme()
673+ return mcp.RenderMCPList(mcp.RenderOptions{
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,
674+ MaxWidth: maxWidth,
731− ),
675+ ShowSection: false,
732− )
733− }
676+ })
734− return mcpList
735}
677}
736
678
737func (s *splashCmp) mcpBlock() string {
679func (s *splashCmp) mcpBlock() string {
internal/tui/components/core/core.go+14−25
@@ -82,41 +82,30 @@ func Title(title string, width int) string {
82}
82}
83
83
84type StatusOpts struct {
84type StatusOpts struct {
85− Icon string
85+ Icon string // if empty no icon will be shown
86− IconColor color.Color
87− NoIcon bool // If true, no icon will be displayed
88 Title string
86 Title string
89 TitleColor color.Color
87 TitleColor color.Color
90 Description string
88 Description string
91 DescriptionColor color.Color
89 DescriptionColor color.Color
92− ExtraContent string // Additional content to append after the description
90+ ExtraContent string // additional content to append after the description
93}
91}
94
92
95−func Status(ops StatusOpts, width int) string {
93+func Status(opts StatusOpts, width int) string {
96 t := styles.CurrentTheme()
94 t := styles.CurrentTheme()
97− icon := "●"
95+ icon := opts.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
96+ title := opts.Title
108 titleColor := t.FgMuted
97 titleColor := t.FgMuted
109− if ops.TitleColor != nil {
98+ if opts.TitleColor != nil {
110− titleColor = ops.TitleColor
99+ titleColor = opts.TitleColor
111 }
100 }
112− description := ops.Description
101+ description := opts.Description
113 descriptionColor := t.FgSubtle
102 descriptionColor := t.FgSubtle
114− if ops.DescriptionColor != nil {
103+ if opts.DescriptionColor != nil {
115− descriptionColor = ops.DescriptionColor
104+ descriptionColor = opts.DescriptionColor
116 }
105 }
117 title = t.S().Base.Foreground(titleColor).Render(title)
106 title = t.S().Base.Foreground(titleColor).Render(title)
118 if description != "" {
107 if description != "" {
119− extraContentWidth := lipgloss.Width(ops.ExtraContent)
108+ extraContentWidth := lipgloss.Width(opts.ExtraContent)
120 if extraContentWidth > 0 {
109 if extraContentWidth > 0 {
121 extraContentWidth += 1
110 extraContentWidth += 1
122 }
111 }
@@ -126,11 +115,11 @@ func Status(ops StatusOpts, width int) string {
126
115
127 content := []string{}
116 content := []string{}
128 if icon != "" {
117 if icon != "" {
129− content = append(content, t.S().Base.Foreground(iconColor).Render(icon))
118+ content = append(content, icon)
130 }
119 }
131 content = append(content, title, description)
120 content = append(content, title, description)
132− if ops.ExtraContent != "" {
121+ if opts.ExtraContent != "" {
133− content = append(content, ops.ExtraContent)
122+ content = append(content, opts.ExtraContent)
134 }
123 }
135
124
136 return strings.Join(content, " ")
125 return strings.Join(content, " ")
internal/tui/components/core/status_test.go−3
@@ -37,7 +37,6 @@ func TestStatus(t *testing.T) {
37 {
37 {
38 name: "NoIcon",
38 name: "NoIcon",
39 opts: core.StatusOpts{
39 opts: core.StatusOpts{
40− NoIcon: true,
41 Title: "Info",
40 Title: "Info",
42 Description: "This status has no icon",
41 Description: "This status has no icon",
43 },
42 },
@@ -47,7 +46,6 @@ func TestStatus(t *testing.T) {
47 name: "WithColors",
46 name: "WithColors",
48 opts: core.StatusOpts{
47 opts: core.StatusOpts{
49 Icon: "⚠",
48 Icon: "⚠",
50− IconColor: color.RGBA{255, 165, 0, 255}, // Orange
51 Title: "Warning",
49 Title: "Warning",
52 TitleColor: color.RGBA{255, 255, 0, 255}, // Yellow
50 TitleColor: color.RGBA{255, 255, 0, 255}, // Yellow
53 Description: "This is a warning message",
51 Description: "This is a warning message",
@@ -102,7 +100,6 @@ func TestStatus(t *testing.T) {
102 name: "AllFieldsWithExtraContent",
100 name: "AllFieldsWithExtraContent",
103 opts: core.StatusOpts{
101 opts: core.StatusOpts{
104 Icon: "🚀",
102 Icon: "🚀",
105− IconColor: color.RGBA{0, 255, 0, 255}, // Green
106 Title: "Deployment",
103 Title: "Deployment",
107 TitleColor: color.RGBA{0, 0, 255, 255}, // Blue
104 TitleColor: color.RGBA{0, 0, 255, 255}, // Blue
108 Description: "Deploying to production environment",
105 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) {
119 case tea.WindowSizeMsg:
119 case tea.WindowSizeMsg:
120 c.wWidth = msg.Width
120 c.wWidth = msg.Width
121 c.wHeight = msg.Height
121 c.wHeight = msg.Height
122+ return c, tea.Batch(
123+ c.SetCommandType(c.commandType),
122− return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
124+ c.commandList.SetSize(c.listWidth(), c.listHeight()),
125+ )
123 case tea.KeyPressMsg:
126 case tea.KeyPressMsg:
124 switch {
127 switch {
125 case key.Matches(msg, c.keyMap.Select):
128 case key.Matches(msg, c.keyMap.Select):
@@ -318,7 +321,6 @@ func (c *commandDialogCmp) defaultCommands() []Command {
318 })
321 })
319 }
322 }
320 }
323 }
321−
322 // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
324 // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
323 if c.wWidth > 120 && c.sessionID != "" {
325 if c.wWidth > 120 && c.sessionID != "" {
324 commands = append(commands, Command{
326 commands = append(commands, Command{
internal/tui/components/dialogs/models/list.go+10−5
@@ -3,6 +3,7 @@ package models
3import (
3import (
4 "fmt"
4 "fmt"
5 "slices"
5 "slices"
6+ "strings"
6
7
7 tea "github.com/charmbracelet/bubbletea/v2"
8 tea "github.com/charmbracelet/bubbletea/v2"
8 "github.com/charmbracelet/catwalk/pkg/catwalk"
9 "github.com/charmbracelet/catwalk/pkg/catwalk"
@@ -49,7 +50,15 @@ func (m *ModelListComponent) Init() tea.Cmd {
49 var cmds []tea.Cmd
50 var cmds []tea.Cmd
50 if len(m.providers) == 0 {
51 if len(m.providers) == 0 {
51 providers, err := config.Providers()
52 providers, err := config.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+
52− m.providers = providers
61+ m.providers = filteredProviders
53 if err != nil {
62 if err != nil {
54 cmds = append(cmds, util.ReportError(err))
63 cmds = append(cmds, util.ReportError(err))
55 }
64 }
@@ -242,7 +251,3 @@ func (m *ModelListComponent) GetModelType() int {
242func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
251func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
243 m.list.SetInputPlaceholder(placeholder)
252 m.list.SetInputPlaceholder(placeholder)
244}
253}
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
2
2
3import (
3import (
4 "fmt"
4 "fmt"
5− "slices"
6 "time"
5 "time"
7
6
8 "github.com/charmbracelet/bubbles/v2/help"
7 "github.com/charmbracelet/bubbles/v2/help"
@@ -96,24 +95,6 @@ func NewModelDialogCmp() ModelDialog {
96}
95}
97
96
98func (m *modelDialogCmp) Init() tea.Cmd {
97func (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− }
117 return tea.Batch(m.modelList.Init(), m.apiKeyInput.Init())
98 return tea.Batch(m.modelList.Init(), m.apiKeyInput.Init())
118}
99}
119
100
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
3import (
3import (
4 "slices"
4 "slices"
5 "strings"
5 "strings"
6+ "sync"
6
7
7 "github.com/charmbracelet/bubbles/v2/key"
8 "github.com/charmbracelet/bubbles/v2/key"
8 tea "github.com/charmbracelet/bubbletea/v2"
9 tea "github.com/charmbracelet/bubbletea/v2"
@@ -90,6 +91,7 @@ type list[T Item] struct {
90
91
91 renderedItems *csync.Map[string, renderedItem]
92 renderedItems *csync.Map[string, renderedItem]
92
93
94+ renderMu sync.Mutex
93 rendered string
95 rendered string
94
96
95 movingByItem bool
97 movingByItem bool
@@ -328,7 +330,9 @@ func (l *list[T]) render() tea.Cmd {
328 // we are not rendering the first time
330 // we are not rendering the first time
329 if l.rendered != "" {
331 if l.rendered != "" {
330 // rerender everything will mostly hit cache
332 // rerender everything will mostly hit cache
333+ l.renderMu.Lock()
331 l.rendered, _ = l.renderIterator(0, false, "")
334 l.rendered, _ = l.renderIterator(0, false, "")
335+ l.renderMu.Unlock()
332 if l.direction == DirectionBackward {
336 if l.direction == DirectionBackward {
333 l.recalculateItemPositions()
337 l.recalculateItemPositions()
334 }
338 }
@@ -338,9 +342,10 @@ func (l *list[T]) render() tea.Cmd {
338 }
342 }
339 return focusChangeCmd
343 return focusChangeCmd
340 }
344 }
345+ l.renderMu.Lock()
341 rendered, finishIndex := l.renderIterator(0, true, "")
346 rendered, finishIndex := l.renderIterator(0, true, "")
342 l.rendered = rendered
347 l.rendered = rendered
343−
348+ l.renderMu.Unlock()
344 // recalculate for the initial items
349 // recalculate for the initial items
345 if l.direction == DirectionBackward {
350 if l.direction == DirectionBackward {
346 l.recalculateItemPositions()
351 l.recalculateItemPositions()
@@ -348,7 +353,10 @@ func (l *list[T]) render() tea.Cmd {
348 renderCmd := func() tea.Msg {
353 renderCmd := func() tea.Msg {
349 l.offset = 0
354 l.offset = 0
350 // render the rest
355 // render the rest
356+
357+ l.renderMu.Lock()
351 l.rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
358 l.rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
359+ l.renderMu.Unlock()
352 // needed for backwards
360 // needed for backwards
353 if l.direction == DirectionBackward {
361 if l.direction == DirectionBackward {
354 l.recalculateItemPositions()
362 l.recalculateItemPositions()
@@ -357,7 +365,6 @@ func (l *list[T]) render() tea.Cmd {
357 if l.focused {
365 if l.focused {
358 l.scrollToSelection()
366 l.scrollToSelection()
359 }
367 }
360−
361 return nil
368 return nil
362 }
369 }
363 return tea.Batch(focusChangeCmd, renderCmd)
370 return tea.Batch(focusChangeCmd, renderCmd)
internal/tui/styles/crush.go+10−1
@@ -1,11 +1,12 @@
1package styles
1package styles
2
2
3import (
3import (
4+ "github.com/charmbracelet/lipgloss/v2"
4 "github.com/charmbracelet/x/exp/charmtone"
5 "github.com/charmbracelet/x/exp/charmtone"
5)
6)
6
7
7func NewCrushTheme() *Theme {
8func NewCrushTheme() *Theme {
8− return &Theme{
9+ t := &Theme{
9 Name: "crush",
10 Name: "crush",
10 IsDark: true,
11 IsDark: true,
11
12
@@ -54,4 +55,12 @@ func NewCrushTheme() *Theme {
54 RedLight: charmtone.Salmon,
55 RedLight: charmtone.Salmon,
55 Cherry: charmtone.Cherry,
56 Cherry: charmtone.Cherry,
56 }
57 }
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
57}
66}
internal/tui/styles/theme.go+6
@@ -74,6 +74,12 @@ type Theme struct {
74 RedLight color.Color
74 RedLight color.Color
75 Cherry color.Color
75 Cherry color.Color
76
76
77+ // LSP and MCP status indicators.
78+ ItemOfflineIcon lipgloss.Style
79+ ItemBusyIcon lipgloss.Style
80+ ItemErrorIcon lipgloss.Style
81+ ItemOnlineIcon lipgloss.Style
82+
77 styles *Styles
83 styles *Styles
78}
84}
79
85