on main help sign in
Merge remote-tracking branch 'origin/main' into fix-rendering
df83063Kujtim Hoxha
.github/cla-signatures.json+24
@@ -199,6 +199,30 @@
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
202 }
226 }
203 ]
227 ]
204}
228}
.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"
go.mod+4−4
@@ -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
@@ -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.36.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
@@ -93,7 +93,7 @@ require (
93 github.com/charmbracelet/x/windows v0.2.1 // indirect
93 github.com/charmbracelet/x/windows v0.2.1 // indirect
94 github.com/davecgh/go-spew v1.1.1 // indirect
94 github.com/davecgh/go-spew v1.1.1 // indirect
95 github.com/disintegration/gift v1.1.2 // indirect
95 github.com/disintegration/gift v1.1.2 // indirect
96− github.com/dlclark/regexp2 v1.11.4 // indirect
96+ github.com/dlclark/regexp2 v1.11.5 // indirect
97 github.com/dustin/go-humanize v1.0.1 // indirect
97 github.com/dustin/go-humanize v1.0.1 // indirect
98 github.com/felixge/httpsnoop v1.0.4 // indirect
98 github.com/felixge/httpsnoop v1.0.4 // indirect
99 github.com/go-logfmt/logfmt v0.6.0 // indirect
99 github.com/go-logfmt/logfmt v0.6.0 // indirect
@@ -144,7 +144,7 @@ require (
144 golang.org/x/sync v0.16.0 // indirect
144 golang.org/x/sync v0.16.0 // indirect
145 golang.org/x/sys v0.34.0
145 golang.org/x/sys v0.34.0
146 golang.org/x/term v0.32.0 // indirect
146 golang.org/x/term v0.32.0 // indirect
147− golang.org/x/text v0.25.0
147+ golang.org/x/text v0.27.0
148 google.golang.org/genai v1.3.0
148 google.golang.org/genai v1.3.0
149 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
149 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
150 google.golang.org/grpc v1.71.0 // indirect
150 google.golang.org/grpc v1.71.0 // indirect
go.sum+10−10
@@ -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=
@@ -118,8 +118,8 @@ github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4G
118github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
118github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
119github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
119github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
120github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
120github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
121−github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
121+github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
122−github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
122+github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
123github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
123github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
124github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
124github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
125github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
125github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -180,8 +180,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=
180github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
181github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
181github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
182github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
182github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
183−github.com/mark3labs/mcp-go v0.34.0 h1:eWy7WBGvhk6EyAAyVzivTCprE52iXJwNtvHV6Cv3bR0=
183+github.com/mark3labs/mcp-go v0.36.0 h1:rIZaijrRYPeSbJG8/qNDe0hWlGrCJ7FWHNMz2SQpTis=
184−github.com/mark3labs/mcp-go v0.34.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
184+github.com/mark3labs/mcp-go v0.36.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
185github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
185github.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=
186github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
187github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
187github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
@@ -371,8 +371,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=
371golang.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=
372golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
373golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
373golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
374−golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
374+golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
375−golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
375+golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
376golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
376golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
377golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
377golang.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=
378golang.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/mcp-tools.go+138−5
@@ -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()
@@ -191,6 +317,7 @@ func createMcpClient(m config.MCPConfig) (*client.Client, error) {
191 return client.NewStreamableHttpClient(
317 return client.NewStreamableHttpClient(
192 m.URL,
318 m.URL,
193 transport.WithHTTPHeaders(m.ResolvedHeaders()),
319 transport.WithHTTPHeaders(m.ResolvedHeaders()),
320+ transport.WithLogger(mcpHTTPLogger{}),
194 )
321 )
195 case config.MCPSse:
322 case config.MCPSse:
196 return client.NewSSEMCPClient(
323 return client.NewSSEMCPClient(
@@ -201,3 +328,9 @@ func createMcpClient(m config.MCPConfig) (*client.Client, error) {
201 return nil, fmt.Errorf("unsupported mcp type: %s", m.Type)
328 return nil, fmt.Errorf("unsupported mcp type: %s", m.Type)
202 }
329 }
203}
330}
331+
332+// for MCP's HTTP client.
333+type mcpHTTPLogger struct{}
334+
335+func (l mcpHTTPLogger) Errorf(format string, v ...any) { slog.Error(fmt.Sprintf(format, v...)) }
336+func (l mcpHTTPLogger) Infof(format string, v ...any) { slog.Info(fmt.Sprintf(format, v...)) }
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/sidebar/sidebar.go+76−417
@@ -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,465 +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"),
397− )
398− return lipgloss.NewStyle().Width(maxWidth).Render(content)
399− }
392+ },
400−
393+ FilePath: sf.FilePath,
401− fileList := []string{section, ""}
394+ Additions: sf.Additions,
402− sort.Slice(files, func(i, j int) bool {
395+ Deletions: sf.Deletions,
403− if files[i].History.latestVersion.CreatedAt == files[j].History.latestVersion.CreatedAt {
404− return files[i].FilePath < files[j].FilePath
405 }
396 }
406− return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
407− })
397+ }
408
398
409− // Limit items for horizontal layout - use less space
399+ // Limit items for horizontal layout
410− maxItems := min(5, len(files))
400+ maxItems := min(5, len(fileSlice))
411 availableHeight := m.height - 8 // Reserve space for header and other content
401 availableHeight := m.height - 8 // Reserve space for header and other content
412 if availableHeight > 0 {
402 if availableHeight > 0 {
413 maxItems = min(maxItems, availableHeight)
403 maxItems = min(maxItems, availableHeight)
414 }
404 }
415
405
416− filesShown := 0
406+ return files.RenderFileBlock(fileSlice, files.RenderOptions{
417− for _, file := range files {
407+ MaxWidth: maxWidth,
418− if file.Additions == 0 && file.Deletions == 0 {
408+ MaxItems: maxItems,
419− continue
409+ ShowSection: true,
420− }
410+ SectionName: "Modified Files",
421− if filesShown >= maxItems {
411+ }, true)
422− break
423− }
424−
425− var statusParts []string
426− if file.Additions > 0 {
427− statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
428− }
429− if file.Deletions > 0 {
430− statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
431− }
432−
433− extraContent := strings.Join(statusParts, " ")
434− cwd := config.Get().WorkingDir() + string(os.PathSeparator)
435− filePath := file.FilePath
436− filePath = strings.TrimPrefix(filePath, cwd)
437− filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
438− filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…")
439−
440− fileList = append(fileList,
441− core.Status(
442− core.StatusOpts{
443− IconColor: t.FgMuted,
444− NoIcon: true,
445− Title: filePath,
446− ExtraContent: extraContent,
447− },
448− maxWidth,
449− ),
450− )
451− filesShown++
452− }
453−
454− // Add "..." indicator if there are more files
455− totalFilesWithChanges := 0
456− for _, file := range files {
457− if file.Additions > 0 || file.Deletions > 0 {
458− totalFilesWithChanges++
459− }
460− }
461− if totalFilesWithChanges > maxItems {
462− fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("…"))
463− }
464−
465− content := lipgloss.JoinVertical(lipgloss.Left, fileList...)
466− return lipgloss.NewStyle().Width(maxWidth).Render(content)
467}
412}
468
413
469// 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
470func (m *sidebarCmp) lspBlockCompact(maxWidth int) string {
415func (m *sidebarCmp) lspBlockCompact(maxWidth int) string {
471− t := styles.CurrentTheme()
472−
473− section := t.S().Subtle.Render("LSPs")
474−
475− lspList := []string{section, ""}
476−
477− lsp := config.Get().LSP.Sorted()
478− if len(lsp) == 0 {
479− content := lipgloss.JoinVertical(
480− lipgloss.Left,
481− section,
482− "",
483− t.S().Base.Foreground(t.Border).Render("None"),
484− )
485− return lipgloss.NewStyle().Width(maxWidth).Render(content)
486− }
487−
488 // Limit items for horizontal layout
416 // Limit items for horizontal layout
417+ lspConfigs := config.Get().LSP.Sorted()
489− maxItems := min(5, len(lsp))
418+ maxItems := min(5, len(lspConfigs))
490 availableHeight := m.height - 8
419 availableHeight := m.height - 8
491 if availableHeight > 0 {
420 if availableHeight > 0 {
492 maxItems = min(maxItems, availableHeight)
421 maxItems = min(maxItems, availableHeight)
493 }
422 }
494
423
495− for i, l := range lsp {
424+ return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
496− if i >= maxItems {
425+ MaxWidth: maxWidth,
497− break
426+ MaxItems: maxItems,
498− }
427+ ShowSection: true,
499−
428+ SectionName: "LSPs",
500− iconColor := t.Success
429+ }, true)
501− if l.LSP.Disabled {
502− iconColor = t.FgMuted
503− }
504−
505− lspErrs := map[protocol.DiagnosticSeverity]int{
506− protocol.SeverityError: 0,
507− protocol.SeverityWarning: 0,
508− protocol.SeverityHint: 0,
509− protocol.SeverityInformation: 0,
510− }
511− if client, ok := m.lspClients[l.Name]; ok {
512− for _, diagnostics := range client.GetDiagnostics() {
513− for _, diagnostic := range diagnostics {
514− if severity, ok := lspErrs[diagnostic.Severity]; ok {
515− lspErrs[diagnostic.Severity] = severity + 1
516− }
517− }
518− }
519− }
520−
521− errs := []string{}
522− if lspErrs[protocol.SeverityError] > 0 {
523− errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
524− }
525− if lspErrs[protocol.SeverityWarning] > 0 {
526− errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
527− }
528− if lspErrs[protocol.SeverityHint] > 0 {
529− errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
530− }
531− if lspErrs[protocol.SeverityInformation] > 0 {
532− errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
533− }
534−
535− lspList = append(lspList,
536− core.Status(
537− core.StatusOpts{
538− IconColor: iconColor,
539− Title: l.Name,
540− Description: l.LSP.Command,
541− ExtraContent: strings.Join(errs, " "),
542− },
543− maxWidth,
544− ),
545− )
546− }
547−
548− // Add "..." indicator if there are more LSPs
549− if len(lsp) > maxItems {
550− lspList = append(lspList, t.S().Base.Foreground(t.FgMuted).Render("…"))
551− }
552−
553− content := lipgloss.JoinVertical(lipgloss.Left, lspList...)
554− return lipgloss.NewStyle().Width(maxWidth).Render(content)
555}
430}
556
431
557// 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
558func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
433func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
559− t := styles.CurrentTheme()
560−
561− section := t.S().Subtle.Render("MCPs")
562−
563− mcpList := []string{section, ""}
564−
565− mcps := config.Get().MCP.Sorted()
566− if len(mcps) == 0 {
567− content := lipgloss.JoinVertical(
568− lipgloss.Left,
569− section,
570− "",
571− t.S().Base.Foreground(t.Border).Render("None"),
572− )
573− return lipgloss.NewStyle().Width(maxWidth).Render(content)
574− }
575−
576 // Limit items for horizontal layout
434 // Limit items for horizontal layout
577− maxItems := min(5, len(mcps))
435+ maxItems := min(5, len(config.Get().MCP.Sorted()))
578 availableHeight := m.height - 8
436 availableHeight := m.height - 8
579 if availableHeight > 0 {
437 if availableHeight > 0 {
580 maxItems = min(maxItems, availableHeight)
438 maxItems = min(maxItems, availableHeight)
581 }
439 }
582
440
583− for i, l := range mcps {
441+ return mcp.RenderMCPBlock(mcp.RenderOptions{
584− if i >= maxItems {
442+ MaxWidth: maxWidth,
585− break
443+ MaxItems: maxItems,
586− }
444+ ShowSection: true,
587−
445+ SectionName: "MCPs",
588− iconColor := t.Success
446+ }, true)
589− if l.MCP.Disabled {
590− iconColor = t.FgMuted
591− }
592−
593− mcpList = append(mcpList,
594− core.Status(
595− core.StatusOpts{
596− IconColor: iconColor,
597− Title: l.Name,
598− Description: l.MCP.Command,
599− },
600− maxWidth,
601− ),
602− )
603− }
604−
605− // Add "..." indicator if there are more MCPs
606− if len(mcps) > maxItems {
607− mcpList = append(mcpList, t.S().Base.Foreground(t.FgMuted).Render("…"))
608− }
609−
610− content := lipgloss.JoinVertical(lipgloss.Left, mcpList...)
611− return lipgloss.NewStyle().Width(maxWidth).Render(content)
612}
447}
613
448
614func (m *sidebarCmp) filesBlock() string {
449func (m *sidebarCmp) filesBlock() string {
615− t := styles.CurrentTheme()
450+ // Convert map to slice and handle type conversion
616−
617− section := t.S().Subtle.Render(
618− core.Section("Modified Files", m.getMaxWidth()),
619− )
620−
621− files := slices.Collect(m.files.Seq())
451+ sessionFiles := slices.Collect(m.files.Seq())
622− if len(files) == 0 {
452+ fileSlice := make([]files.SessionFile, len(sessionFiles))
623− return lipgloss.JoinVertical(
453+ for i, sf := range sessionFiles {
624− lipgloss.Left,
454+ fileSlice[i] = files.SessionFile{
625− section,
455+ History: files.FileHistory{
626− "",
456+ InitialVersion: sf.History.initialVersion,
627− t.S().Base.Foreground(t.Border).Render("None"),
457+ LatestVersion: sf.History.latestVersion,
628− )
458+ },
629− }
459+ FilePath: sf.FilePath,
630−
460+ Additions: sf.Additions,
631− fileList := []string{section, ""}
461+ Deletions: sf.Deletions,
632− // order files by the latest version's created time, then by path for stability
633− sort.Slice(files, func(i, j int) bool {
634− if files[i].History.latestVersion.CreatedAt == files[j].History.latestVersion.CreatedAt {
635− return files[i].FilePath < files[j].FilePath
636 }
462 }
637− return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
638− })
463+ }
639
464
640 // Limit the number of files shown
465 // Limit the number of files shown
641 maxFiles, _, _ := m.getDynamicLimits()
466 maxFiles, _, _ := m.getDynamicLimits()
642− maxFiles = min(len(files), maxFiles)
467+ maxFiles = min(len(fileSlice), maxFiles)
643− filesShown := 0
468+
644−
469+ return files.RenderFileBlock(fileSlice, files.RenderOptions{
645− for _, file := range files {
470+ MaxWidth: m.getMaxWidth(),
646− if file.Additions == 0 && file.Deletions == 0 {
471+ MaxItems: maxFiles,
647− continue // skip files with no changes
472+ ShowSection: true,
648− }
473+ SectionName: core.Section("Modified Files", m.getMaxWidth()),
649− if filesShown >= maxFiles {
474+ }, true)
650− break
651− }
652−
653− var statusParts []string
654− if file.Additions > 0 {
655− statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
656− }
657− if file.Deletions > 0 {
658− statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
659− }
660−
661− extraContent := strings.Join(statusParts, " ")
662− cwd := config.Get().WorkingDir() + string(os.PathSeparator)
663− filePath := file.FilePath
664− filePath = strings.TrimPrefix(filePath, cwd)
665− filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
666− filePath = ansi.Truncate(filePath, m.getMaxWidth()-lipgloss.Width(extraContent)-2, "…")
667− fileList = append(fileList,
668− core.Status(
669− core.StatusOpts{
670− IconColor: t.FgMuted,
671− NoIcon: true,
672− Title: filePath,
673− ExtraContent: extraContent,
674− },
675− m.getMaxWidth(),
676− ),
677− )
678− filesShown++
679− }
680−
681− // Add indicator if there are more files
682− totalFilesWithChanges := 0
683− for _, file := range files {
684− if file.Additions > 0 || file.Deletions > 0 {
685− totalFilesWithChanges++
686− }
687− }
688− if totalFilesWithChanges > maxFiles {
689− remaining := totalFilesWithChanges - maxFiles
690− fileList = append(fileList,
691− t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
692− )
693− }
694−
695− return lipgloss.JoinVertical(
696− lipgloss.Left,
697− fileList...,
698− )
699}
475}
700
476
701func (m *sidebarCmp) lspBlock() string {
477func (m *sidebarCmp) lspBlock() string {
702− t := styles.CurrentTheme()
703−
704− section := t.S().Subtle.Render(
705− core.Section("LSPs", m.getMaxWidth()),
706− )
707−
708− lspList := []string{section, ""}
709−
710− lsp := config.Get().LSP.Sorted()
711− if len(lsp) == 0 {
712− return lipgloss.JoinVertical(
713− lipgloss.Left,
714− section,
715− "",
716− t.S().Base.Foreground(t.Border).Render("None"),
717− )
718− }
719−
720 // Limit the number of LSPs shown
478 // Limit the number of LSPs shown
721 _, maxLSPs, _ := m.getDynamicLimits()
479 _, maxLSPs, _ := m.getDynamicLimits()
480+ lspConfigs := config.Get().LSP.Sorted()
722− maxLSPs = min(len(lsp), maxLSPs)
481+ maxLSPs = min(len(lspConfigs), maxLSPs)
723− for i, l := range lsp {
482+
724− if i >= maxLSPs {
483+ return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
725− break
484+ MaxWidth: m.getMaxWidth(),
726− }
485+ MaxItems: maxLSPs,
727−
486+ ShowSection: true,
728− iconColor := t.Success
487+ SectionName: core.Section("LSPs", m.getMaxWidth()),
729− if l.LSP.Disabled {
488+ }, true)
730− iconColor = t.FgMuted
731− }
732− lspErrs := map[protocol.DiagnosticSeverity]int{
733− protocol.SeverityError: 0,
734− protocol.SeverityWarning: 0,
735− protocol.SeverityHint: 0,
736− protocol.SeverityInformation: 0,
737− }
738− if client, ok := m.lspClients[l.Name]; ok {
739− for _, diagnostics := range client.GetDiagnostics() {
740− for _, diagnostic := range diagnostics {
741− if severity, ok := lspErrs[diagnostic.Severity]; ok {
742− lspErrs[diagnostic.Severity] = severity + 1
743− }
744− }
745− }
746− }
747−
748− errs := []string{}
749− if lspErrs[protocol.SeverityError] > 0 {
750− errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
751− }
752− if lspErrs[protocol.SeverityWarning] > 0 {
753− errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
754− }
755− if lspErrs[protocol.SeverityHint] > 0 {
756− errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
757− }
758− if lspErrs[protocol.SeverityInformation] > 0 {
759− errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
760− }
761−
762− lspList = append(lspList,
763− core.Status(
764− core.StatusOpts{
765− IconColor: iconColor,
766− Title: l.Name,
767− Description: l.LSP.Command,
768− ExtraContent: strings.Join(errs, " "),
769− },
770− m.getMaxWidth(),
771− ),
772− )
773− }
774−
775− // Add indicator if there are more LSPs
776− if len(lsp) > maxLSPs {
777− remaining := len(lsp) - maxLSPs
778− lspList = append(lspList,
779− t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
780− )
781− }
782−
783− return lipgloss.JoinVertical(
784− lipgloss.Left,
785− lspList...,
786− )
787}
489}
788
490
789func (m *sidebarCmp) mcpBlock() string {
491func (m *sidebarCmp) mcpBlock() string {
790− t := styles.CurrentTheme()
791−
792− section := t.S().Subtle.Render(
793− core.Section("MCPs", m.getMaxWidth()),
794− )
795−
796− mcpList := []string{section, ""}
797−
798− mcps := config.Get().MCP.Sorted()
799− if len(mcps) == 0 {
800− return lipgloss.JoinVertical(
801− lipgloss.Left,
802− section,
803− "",
804− t.S().Base.Foreground(t.Border).Render("None"),
805− )
806− }
807−
808 // Limit the number of MCPs shown
492 // Limit the number of MCPs shown
809 _, _, maxMCPs := m.getDynamicLimits()
493 _, _, maxMCPs := m.getDynamicLimits()
494+ mcps := config.Get().MCP.Sorted()
810 maxMCPs = min(len(mcps), maxMCPs)
495 maxMCPs = min(len(mcps), maxMCPs)
811− for i, l := range mcps {
812− if i >= maxMCPs {
813− break
814− }
815−
816− iconColor := t.Success
817− if l.MCP.Disabled {
818− iconColor = t.FgMuted
819− }
820− mcpList = append(mcpList,
821− core.Status(
822− core.StatusOpts{
823− IconColor: iconColor,
824− Title: l.Name,
825− Description: l.MCP.Command,
826− },
827− m.getMaxWidth(),
828− ),
829− )
830− }
831−
832− // Add indicator if there are more MCPs
833− if len(mcps) > maxMCPs {
834− remaining := len(mcps) - maxMCPs
835− mcpList = append(mcpList,
836− t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
837− )
838− }
839
496
840− return lipgloss.JoinVertical(
497+ return mcp.RenderMCPBlock(mcp.RenderOptions{
841− lipgloss.Left,
498+ MaxWidth: m.getMaxWidth(),
842− mcpList...,
499+ MaxItems: maxMCPs,
843− )
500+ ShowSection: true,
501+ SectionName: core.Section("MCPs", m.getMaxWidth()),
502+ }, true)
844}
503}
845
504
846func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
505func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
internal/tui/components/chat/splash/splash.go+11−47
@@ -18,6 +18,8 @@ import (
18 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
18 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
20 "github.com/charmbracelet/crush/internal/tui/components/logo"
20 "github.com/charmbracelet/crush/internal/tui/components/logo"
21+ lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
22+ "github.com/charmbracelet/crush/internal/tui/components/mcp"
21 "github.com/charmbracelet/crush/internal/tui/exp/list"
23 "github.com/charmbracelet/crush/internal/tui/exp/list"
22 "github.com/charmbracelet/crush/internal/tui/styles"
24 "github.com/charmbracelet/crush/internal/tui/styles"
23 "github.com/charmbracelet/crush/internal/tui/util"
25 "github.com/charmbracelet/crush/internal/tui/util"
@@ -655,7 +657,7 @@ func (s *splashCmp) Bindings() []key.Binding {
655}
657}
656
658
657func (s *splashCmp) getMaxInfoWidth() int {
659func (s *splashCmp) getMaxInfoWidth() int {
658− return min(s.width-2, 40) // 2 for left padding
660+ return min(s.width-2, 90) // 2 for left padding
659}
661}
660
662
661func (s *splashCmp) cwd() string {
663func (s *splashCmp) cwd() string {
@@ -670,29 +672,10 @@ func (s *splashCmp) cwd() string {
670}
672}
671
673
672func LSPList(maxWidth int) []string {
674func LSPList(maxWidth int) []string {
673− t := styles.CurrentTheme()
675+ 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,
676+ MaxWidth: maxWidth,
692− ),
677+ ShowSection: false,
693− )
694− }
678+ })
695− return lspList
696}
679}
697
680
698func (s *splashCmp) lspBlock() string {
681func (s *splashCmp) lspBlock() string {
@@ -709,29 +692,10 @@ func (s *splashCmp) lspBlock() string {
709}
692}
710
693
711func MCPList(maxWidth int) []string {
694func MCPList(maxWidth int) []string {
712− t := styles.CurrentTheme()
695+ 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,
696+ MaxWidth: maxWidth,
731− ),
697+ ShowSection: false,
732− )
733− }
698+ })
734− return mcpList
735}
699}
736
700
737func (s *splashCmp) mcpBlock() string {
701func (s *splashCmp) mcpBlock() string {
internal/tui/components/files/files.gonew file+145
@@ -0,0 +1,145 @@
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+ IconColor: t.FgMuted,
102+ NoIcon: true,
103+ Title: filePath,
104+ ExtraContent: extraContent,
105+ },
106+ opts.MaxWidth,
107+ ),
108+ )
109+ filesShown++
110+ }
111+
112+ return fileList
113+}
114+
115+// RenderFileBlock renders a complete file block with optional truncation indicator.
116+func RenderFileBlock(fileSlice []SessionFile, opts RenderOptions, showTruncationIndicator bool) string {
117+ t := styles.CurrentTheme()
118+ fileList := RenderFileList(fileSlice, opts)
119+
120+ // Add truncation indicator if needed
121+ if showTruncationIndicator && opts.MaxItems > 0 {
122+ totalFilesWithChanges := 0
123+ for _, file := range fileSlice {
124+ if file.Additions > 0 || file.Deletions > 0 {
125+ totalFilesWithChanges++
126+ }
127+ }
128+ if totalFilesWithChanges > opts.MaxItems {
129+ remaining := totalFilesWithChanges - opts.MaxItems
130+ if remaining == 1 {
131+ fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("…"))
132+ } else {
133+ fileList = append(fileList,
134+ t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
135+ )
136+ }
137+ }
138+ }
139+
140+ content := lipgloss.JoinVertical(lipgloss.Left, fileList...)
141+ if opts.MaxWidth > 0 {
142+ return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content)
143+ }
144+ return content
145+}
internal/tui/components/lsp/lsp.gonew file+160
@@ -0,0 +1,160 @@
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+ iconColor := t.FgMuted
61+ description := l.LSP.Command
62+
63+ if l.LSP.Disabled {
64+ iconColor = t.FgMuted
65+ description = t.S().Subtle.Render("disabled")
66+ } else if state, exists := lspStates[l.Name]; exists {
67+ switch state.State {
68+ case lsp.StateStarting:
69+ iconColor = t.Yellow
70+ description = t.S().Subtle.Render("starting...")
71+ case lsp.StateReady:
72+ iconColor = t.Success
73+ description = l.LSP.Command
74+ case lsp.StateError:
75+ iconColor = t.Red
76+ if state.Error != nil {
77+ description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
78+ } else {
79+ description = t.S().Subtle.Render("error")
80+ }
81+ }
82+ }
83+
84+ // Calculate diagnostic counts if we have LSP clients
85+ var extraContent string
86+ if lspClients != nil {
87+ lspErrs := map[protocol.DiagnosticSeverity]int{
88+ protocol.SeverityError: 0,
89+ protocol.SeverityWarning: 0,
90+ protocol.SeverityHint: 0,
91+ protocol.SeverityInformation: 0,
92+ }
93+ if client, ok := lspClients[l.Name]; ok {
94+ for _, diagnostics := range client.GetDiagnostics() {
95+ for _, diagnostic := range diagnostics {
96+ if severity, ok := lspErrs[diagnostic.Severity]; ok {
97+ lspErrs[diagnostic.Severity] = severity + 1
98+ }
99+ }
100+ }
101+ }
102+
103+ errs := []string{}
104+ if lspErrs[protocol.SeverityError] > 0 {
105+ errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
106+ }
107+ if lspErrs[protocol.SeverityWarning] > 0 {
108+ errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
109+ }
110+ if lspErrs[protocol.SeverityHint] > 0 {
111+ errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
112+ }
113+ if lspErrs[protocol.SeverityInformation] > 0 {
114+ errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
115+ }
116+ extraContent = strings.Join(errs, " ")
117+ }
118+
119+ lspList = append(lspList,
120+ core.Status(
121+ core.StatusOpts{
122+ IconColor: iconColor,
123+ Title: l.Name,
124+ Description: description,
125+ ExtraContent: extraContent,
126+ },
127+ opts.MaxWidth,
128+ ),
129+ )
130+ }
131+
132+ return lspList
133+}
134+
135+// RenderLSPBlock renders a complete LSP block with optional truncation indicator.
136+func RenderLSPBlock(lspClients map[string]*lsp.Client, opts RenderOptions, showTruncationIndicator bool) string {
137+ t := styles.CurrentTheme()
138+ lspList := RenderLSPList(lspClients, opts)
139+
140+ // Add truncation indicator if needed
141+ if showTruncationIndicator && opts.MaxItems > 0 {
142+ lspConfigs := config.Get().LSP.Sorted()
143+ if len(lspConfigs) > opts.MaxItems {
144+ remaining := len(lspConfigs) - opts.MaxItems
145+ if remaining == 1 {
146+ lspList = append(lspList, t.S().Base.Foreground(t.FgMuted).Render("…"))
147+ } else {
148+ lspList = append(lspList,
149+ t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
150+ )
151+ }
152+ }
153+ }
154+
155+ content := lipgloss.JoinVertical(lipgloss.Left, lspList...)
156+ if opts.MaxWidth > 0 {
157+ return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content)
158+ }
159+ return content
160+}
internal/tui/components/mcp/mcp.gonew file+128
@@ -0,0 +1,128 @@
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+ iconColor := t.FgMuted
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+ iconColor = t.FgMuted
65+ description = t.S().Subtle.Render("disabled")
66+ case agent.MCPStateStarting:
67+ iconColor = t.Yellow
68+ description = t.S().Subtle.Render("starting...")
69+ case agent.MCPStateConnected:
70+ iconColor = t.Success
71+ if state.ToolCount > 0 {
72+ extraContent = t.S().Subtle.Render(fmt.Sprintf("(%d tools)", state.ToolCount))
73+ }
74+ case agent.MCPStateError:
75+ iconColor = t.Red
76+ if state.Error != nil {
77+ description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
78+ } else {
79+ description = t.S().Subtle.Render("error")
80+ }
81+ }
82+ } else if l.MCP.Disabled {
83+ iconColor = t.FgMuted
84+ description = t.S().Subtle.Render("disabled")
85+ }
86+
87+ mcpList = append(mcpList,
88+ core.Status(
89+ core.StatusOpts{
90+ IconColor: iconColor,
91+ Title: l.Name,
92+ Description: description,
93+ ExtraContent: extraContent,
94+ },
95+ opts.MaxWidth,
96+ ),
97+ )
98+ }
99+
100+ return mcpList
101+}
102+
103+// RenderMCPBlock renders a complete MCP block with optional truncation indicator.
104+func RenderMCPBlock(opts RenderOptions, showTruncationIndicator bool) string {
105+ t := styles.CurrentTheme()
106+ mcpList := RenderMCPList(opts)
107+
108+ // Add truncation indicator if needed
109+ if showTruncationIndicator && opts.MaxItems > 0 {
110+ mcps := config.Get().MCP.Sorted()
111+ if len(mcps) > opts.MaxItems {
112+ remaining := len(mcps) - opts.MaxItems
113+ if remaining == 1 {
114+ mcpList = append(mcpList, t.S().Base.Foreground(t.FgMuted).Render("…"))
115+ } else {
116+ mcpList = append(mcpList,
117+ t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
118+ )
119+ }
120+ }
121+ }
122+
123+ content := lipgloss.JoinVertical(lipgloss.Left, mcpList...)
124+ if opts.MaxWidth > 0 {
125+ return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content)
126+ }
127+ return content
128+}