on main help sign in
fix: correctly show mcp and lsp states
commit4739ba93436327408a4e320463a5ab2fa0710ee8 authorKujtim Hoxha <kujtimii.h@gmail.com> dateMon 4 Aug 2025, 11:59 (1 year ago) parent1eabdb4
11 files changed+795−464
internal/app/app.go+2 internal/app/lsp.go+11−1 internal/app/lsp_events.go+104 internal/llm/agent/mcp-tools.go+131−2 internal/lsp/client.go+18−1 internal/lsp/handlers.go+12−2 internal/tui/components/chat/sidebar/sidebar.go+76−411 internal/tui/components/chat/splash/splash.go+11−47 internal/tui/components/files/files.go+142 internal/tui/components/lsp/lsp.go+160 internal/tui/components/mcp/mcp.go+128
internal/app/app.go+2
@@ -207,6 +207,8 @@ func (app *App) setupEvents() {
207207 setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
208208 setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events)
209209 setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
210+ setupSubscriber(ctx, app.serviceEventsWG, "mcp", agent.SubscribeMCPEvents, app.events)
211+ setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events)
210212 cleanupFunc := func() {
211213 cancel()
212214 app.serviceEventsWG.Wait()
internal/app/lsp.go+11−1
@@ -22,13 +22,20 @@ func (app *App) initLSPClients(ctx context.Context) {
2222func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) {
2323 slog.Info("Creating LSP client", "name", name, "command", command, "args", args)
2424
25+ // Update state to starting
26+ updateLSPState(name, lsp.StateStarting, nil, nil, 0)
27+
2528 // Create LSP client.
26− lspClient, err := lsp.NewClient(ctx, command, args...)
29+ lspClient, err := lsp.NewClient(ctx, name, command, args...)
2730 if err != nil {
2831 slog.Error("Failed to create LSP client for", name, err)
32+ updateLSPState(name, lsp.StateError, err, nil, 0)
2933 return
3034 }
3135
36+ // Set diagnostics callback
37+ lspClient.SetDiagnosticsCallback(updateLSPDiagnostics)
38+
3239 // Increase initialization timeout as some servers take more time to start.
3340 initCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
3441 defer cancel()
@@ -37,6 +44,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
3744 _, err = lspClient.InitializeLSPClient(initCtx, app.config.WorkingDir())
3845 if err != nil {
3946 slog.Error("Initialize failed", "name", name, "error", err)
47+ updateLSPState(name, lsp.StateError, err, lspClient, 0)
4048 lspClient.Close()
4149 return
4250 }
@@ -47,10 +55,12 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
4755 // Server never reached a ready state, but let's continue anyway, as
4856 // some functionality might still work.
4957 lspClient.SetServerState(lsp.StateError)
58+ updateLSPState(name, lsp.StateError, err, lspClient, 0)
5059 } else {
5160 // Server reached a ready state scuccessfully.
5261 slog.Info("LSP server is ready", "name", name)
5362 lspClient.SetServerState(lsp.StateReady)
63+ updateLSPState(name, lsp.StateReady, nil, lspClient, 0)
5464 }
5565
5666 slog.Info("LSP client initialized", "name", name)
internal/app/lsp_events.gonew file+104
@@ -0,0 +1,104 @@
1+package app
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/charmbracelet/crush/internal/csync"
9+ "github.com/charmbracelet/crush/internal/lsp"
10+ "github.com/charmbracelet/crush/internal/pubsub"
11+)
12+
13+// LSPEventType represents the type of LSP event
14+type LSPEventType string
15+
16+const (
17+ LSPEventStateChanged LSPEventType = "state_changed"
18+ LSPEventDiagnosticsChanged LSPEventType = "diagnostics_changed"
19+)
20+
21+// LSPEvent represents an event in the LSP system
22+type LSPEvent struct {
23+ Type LSPEventType
24+ Name string
25+ State lsp.ServerState
26+ Error error
27+ DiagnosticCount int
28+}
29+
30+// LSPClientInfo holds information about an LSP client's state
31+type LSPClientInfo struct {
32+ Name string
33+ State lsp.ServerState
34+ Error error
35+ Client *lsp.Client
36+ DiagnosticCount int
37+ ConnectedAt time.Time
38+}
39+
40+var (
41+ lspStates = csync.NewMap[string, LSPClientInfo]()
42+ lspBroker = pubsub.NewBroker[LSPEvent]()
43+ lspMutex sync.RWMutex
44+)
45+
46+// SubscribeLSPEvents returns a channel for LSP events
47+func SubscribeLSPEvents(ctx context.Context) <-chan pubsub.Event[LSPEvent] {
48+ return lspBroker.Subscribe(ctx)
49+}
50+
51+// GetLSPStates returns the current state of all LSP clients
52+func GetLSPStates() map[string]LSPClientInfo {
53+ states := make(map[string]LSPClientInfo)
54+ for name, info := range lspStates.Seq2() {
55+ states[name] = info
56+ }
57+ return states
58+}
59+
60+// GetLSPState returns the state of a specific LSP client
61+func GetLSPState(name string) (LSPClientInfo, bool) {
62+ return lspStates.Get(name)
63+}
64+
65+// updateLSPState updates the state of an LSP client and publishes an event
66+func updateLSPState(name string, state lsp.ServerState, err error, client *lsp.Client, diagnosticCount int) {
67+ info := LSPClientInfo{
68+ Name: name,
69+ State: state,
70+ Error: err,
71+ Client: client,
72+ DiagnosticCount: diagnosticCount,
73+ }
74+ if state == lsp.StateReady {
75+ info.ConnectedAt = time.Now()
76+ }
77+ lspStates.Set(name, info)
78+
79+ // Publish state change event
80+ lspBroker.Publish(pubsub.UpdatedEvent, LSPEvent{
81+ Type: LSPEventStateChanged,
82+ Name: name,
83+ State: state,
84+ Error: err,
85+ DiagnosticCount: diagnosticCount,
86+ })
87+}
88+
89+// updateLSPDiagnostics updates the diagnostic count for an LSP client and publishes an event
90+func updateLSPDiagnostics(name string, diagnosticCount int) {
91+ if info, exists := lspStates.Get(name); exists {
92+ info.DiagnosticCount = diagnosticCount
93+ lspStates.Set(name, info)
94+
95+ // Publish diagnostics change event
96+ lspBroker.Publish(pubsub.UpdatedEvent, LSPEvent{
97+ Type: LSPEventDiagnosticsChanged,
98+ Name: name,
99+ State: info.State,
100+ Error: info.Error,
101+ DiagnosticCount: diagnosticCount,
102+ })
103+ }
104+}
internal/llm/agent/mcp-tools.go+131−2
@@ -7,10 +7,12 @@ import (
77 "log/slog"
88 "slices"
99 "sync"
10+ "time"
1011
1112 "github.com/charmbracelet/crush/internal/config"
1213 "github.com/charmbracelet/crush/internal/csync"
1314 "github.com/charmbracelet/crush/internal/llm/tools"
15+ "github.com/charmbracelet/crush/internal/pubsub"
1416 "github.com/charmbracelet/crush/internal/version"
1517
1618 "github.com/charmbracelet/crush/internal/permission"
@@ -20,10 +22,63 @@ import (
2022 "github.com/mark3labs/mcp-go/mcp"
2123)
2224
25+// MCPState represents the current state of an MCP client
26+type MCPState int
27+
28+const (
29+ MCPStateDisabled MCPState = iota
30+ MCPStateStarting
31+ MCPStateConnected
32+ MCPStateError
33+)
34+
35+func (s MCPState) String() string {
36+ switch s {
37+ case MCPStateDisabled:
38+ return "disabled"
39+ case MCPStateStarting:
40+ return "starting"
41+ case MCPStateConnected:
42+ return "connected"
43+ case MCPStateError:
44+ return "error"
45+ default:
46+ return "unknown"
47+ }
48+}
49+
50+// MCPEventType represents the type of MCP event
51+type MCPEventType string
52+
53+const (
54+ MCPEventStateChanged MCPEventType = "state_changed"
55+)
56+
57+// MCPEvent represents an event in the MCP system
58+type MCPEvent struct {
59+ Type MCPEventType
60+ Name string
61+ State MCPState
62+ Error error
63+ ToolCount int
64+}
65+
66+// MCPClientInfo holds information about an MCP client's state
67+type MCPClientInfo struct {
68+ Name string
69+ State MCPState
70+ Error error
71+ Client *client.Client
72+ ToolCount int
73+ ConnectedAt time.Time
74+}
75+
2376var (
2477 mcpToolsOnce sync.Once
2578 mcpTools []tools.BaseTool
2679 mcpClients = csync.NewMap[string, *client.Client]()
80+ mcpStates = csync.NewMap[string, MCPClientInfo]()
81+ mcpBroker = pubsub.NewBroker[MCPEvent]()
2782)
2883
2984type McpTool struct {
@@ -109,6 +164,7 @@ func getTools(ctx context.Context, name string, permissions permission.Service,
109164 result, err := c.ListTools(ctx, mcp.ListToolsRequest{})
110165 if err != nil {
111166 slog.Error("error listing tools", "error", err)
167+ updateMCPState(name, MCPStateError, err, nil, 0)
112168 c.Close()
113169 mcpClients.Del(name)
114170 return nil
@@ -125,11 +181,55 @@ func getTools(ctx context.Context, name string, permissions permission.Service,
125181 return mcpTools
126182}
127183
184+// SubscribeMCPEvents returns a channel for MCP events
185+func SubscribeMCPEvents(ctx context.Context) <-chan pubsub.Event[MCPEvent] {
186+ return mcpBroker.Subscribe(ctx)
187+}
188+
189+// GetMCPStates returns the current state of all MCP clients
190+func GetMCPStates() map[string]MCPClientInfo {
191+ states := make(map[string]MCPClientInfo)
192+ for name, info := range mcpStates.Seq2() {
193+ states[name] = info
194+ }
195+ return states
196+}
197+
198+// GetMCPState returns the state of a specific MCP client
199+func GetMCPState(name string) (MCPClientInfo, bool) {
200+ return mcpStates.Get(name)
201+}
202+
203+// updateMCPState updates the state of an MCP client and publishes an event
204+func updateMCPState(name string, state MCPState, err error, client *client.Client, toolCount int) {
205+ info := MCPClientInfo{
206+ Name: name,
207+ State: state,
208+ Error: err,
209+ Client: client,
210+ ToolCount: toolCount,
211+ }
212+ if state == MCPStateConnected {
213+ info.ConnectedAt = time.Now()
214+ }
215+ mcpStates.Set(name, info)
216+
217+ // Publish state change event
218+ mcpBroker.Publish(pubsub.UpdatedEvent, MCPEvent{
219+ Type: MCPEventStateChanged,
220+ Name: name,
221+ State: state,
222+ Error: err,
223+ ToolCount: toolCount,
224+ })
225+}
226+
128227// CloseMCPClients closes all MCP clients. This should be called during application shutdown.
129228func CloseMCPClients() {
130229 for c := range mcpClients.Seq() {
131230 _ = c.Close()
132231 }
232+ mcpBroker.Shutdown()
133233}
134234
135235var mcpInitRequest = mcp.InitializeRequest{
@@ -145,25 +245,51 @@ var mcpInitRequest = mcp.InitializeRequest{
145245func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) []tools.BaseTool {
146246 var wg sync.WaitGroup
147247 result := csync.NewSlice[tools.BaseTool]()
248+
249+ // Initialize states for all configured MCPs
148250 for name, m := range cfg.MCP {
149251 if m.Disabled {
252+ updateMCPState(name, MCPStateDisabled, nil, nil, 0)
150253 slog.Debug("skipping disabled mcp", "name", name)
151254 continue
152255 }
256+
257+ // Set initial starting state
258+ updateMCPState(name, MCPStateStarting, nil, nil, 0)
259+
153260 wg.Add(1)
154261 go func(name string, m config.MCPConfig) {
155− defer wg.Done()
262+ defer func() {
263+ wg.Done()
264+ if r := recover(); r != nil {
265+ var err error
266+ switch v := r.(type) {
267+ case error:
268+ err = v
269+ case string:
270+ err = fmt.Errorf("panic: %s", v)
271+ default:
272+ err = fmt.Errorf("panic: %v", v)
273+ }
274+ updateMCPState(name, MCPStateError, err, nil, 0)
275+ slog.Error("panic in mcp client initialization", "error", err, "name", name)
276+ }
277+ }()
278+
156279 c, err := createMcpClient(m)
157280 if err != nil {
281+ updateMCPState(name, MCPStateError, err, nil, 0)
158282 slog.Error("error creating mcp client", "error", err, "name", name)
159283 return
160284 }
161285 if err := c.Start(ctx); err != nil {
286+ updateMCPState(name, MCPStateError, err, nil, 0)
162287 slog.Error("error starting mcp client", "error", err, "name", name)
163288 _ = c.Close()
164289 return
165290 }
166291 if _, err := c.Initialize(ctx, mcpInitRequest); err != nil {
292+ updateMCPState(name, MCPStateError, err, nil, 0)
167293 slog.Error("error initializing mcp client", "error", err, "name", name)
168294 _ = c.Close()
169295 return
@@ -172,7 +298,10 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con
172298 slog.Info("Initialized mcp client", "name", name)
173299 mcpClients.Set(name, c)
174300
175− result.Append(getTools(ctx, name, permissions, c, cfg.WorkingDir())...)
301+ tools := getTools(ctx, name, permissions, c, cfg.WorkingDir())
302+ toolCount := len(tools)
303+ updateMCPState(name, MCPStateConnected, nil, c, toolCount)
304+ result.Append(tools...)
176305 }(name, m)
177306 }
178307 wg.Wait()
internal/lsp/client.go+18−1
@@ -26,6 +26,12 @@ type Client struct {
2626 stdout *bufio.Reader
2727 stderr io.ReadCloser
2828
29+ // Client name for identification
30+ name string
31+
32+ // Diagnostic change callback
33+ onDiagnosticsChanged func(name string, count int)
34+
2935 // Request ID counter
3036 nextID atomic.Int32
3137
@@ -53,7 +59,7 @@ type Client struct {
5359 serverState atomic.Value
5460}
5561
56−func NewClient(ctx context.Context, command string, args ...string) (*Client, error) {
62+func NewClient(ctx context.Context, name, command string, args ...string) (*Client, error) {
5763 cmd := exec.CommandContext(ctx, command, args...)
5864 // Copy env
5965 cmd.Env = os.Environ()
@@ -75,6 +81,7 @@ func NewClient(ctx context.Context, command string, args ...string) (*Client, er
7581
7682 client := &Client{
7783 Cmd: cmd,
84+ name: name,
7885 stdin: stdin,
7986 stdout: bufio.NewReader(stdout),
8087 stderr: stderr,
@@ -284,6 +291,16 @@ func (c *Client) SetServerState(state ServerState) {
284291 c.serverState.Store(state)
285292}
286293
294+// GetName returns the name of the LSP client
295+func (c *Client) GetName() string {
296+ return c.name
297+}
298+
299+// SetDiagnosticsCallback sets the callback function for diagnostic changes
300+func (c *Client) SetDiagnosticsCallback(callback func(name string, count int)) {
301+ c.onDiagnosticsChanged = callback
302+}
303+
287304// WaitForServerReady waits for the server to be ready by polling the server
288305// with a simple request until it responds successfully or times out
289306func (c *Client) WaitForServerReady(ctx context.Context) error {
internal/lsp/handlers.go+12−2
@@ -103,7 +103,17 @@ func HandleDiagnostics(client *Client, params json.RawMessage) {
103103 }
104104
105105 client.diagnosticsMu.Lock()
106− defer client.diagnosticsMu.Unlock()
107−
108106 client.diagnostics[diagParams.URI] = diagParams.Diagnostics
107+
108+ // Calculate total diagnostic count
109+ totalCount := 0
110+ for _, diagnostics := range client.diagnostics {
111+ totalCount += len(diagnostics)
112+ }
113+ client.diagnosticsMu.Unlock()
114+
115+ // Trigger callback if set
116+ if client.onDiagnosticsChanged != nil {
117+ client.onDiagnosticsChanged(client.name, totalCount)
118+ }
109119}
internal/tui/components/chat/sidebar/sidebar.go+76−411
@@ -5,7 +5,6 @@ import (
55 "fmt"
66 "os"
77 "slices"
8− "sort"
98 "strings"
109
1110 tea "github.com/charmbracelet/bubbletea/v2"
@@ -13,21 +12,21 @@ import (
1312 "github.com/charmbracelet/crush/internal/config"
1413 "github.com/charmbracelet/crush/internal/csync"
1514 "github.com/charmbracelet/crush/internal/diff"
16− "github.com/charmbracelet/crush/internal/fsext"
1715 "github.com/charmbracelet/crush/internal/history"
1816 "github.com/charmbracelet/crush/internal/lsp"
19− "github.com/charmbracelet/crush/internal/lsp/protocol"
2017 "github.com/charmbracelet/crush/internal/pubsub"
2118 "github.com/charmbracelet/crush/internal/session"
2219 "github.com/charmbracelet/crush/internal/tui/components/chat"
2320 "github.com/charmbracelet/crush/internal/tui/components/core"
2421 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
22+ "github.com/charmbracelet/crush/internal/tui/components/files"
2523 "github.com/charmbracelet/crush/internal/tui/components/logo"
24+ lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
25+ "github.com/charmbracelet/crush/internal/tui/components/mcp"
2626 "github.com/charmbracelet/crush/internal/tui/styles"
2727 "github.com/charmbracelet/crush/internal/tui/util"
2828 "github.com/charmbracelet/crush/internal/version"
2929 "github.com/charmbracelet/lipgloss/v2"
30− "github.com/charmbracelet/x/ansi"
3130 "golang.org/x/text/cases"
3231 "golang.org/x/text/language"
3332)
@@ -382,459 +381,125 @@ func (m *sidebarCmp) renderSectionsHorizontal() string {
382381
383382// filesBlockCompact renders the files block with limited width and height for horizontal layout
384383func (m *sidebarCmp) filesBlockCompact(maxWidth int) string {
385− t := styles.CurrentTheme()
386−
387− section := t.S().Subtle.Render("Modified Files")
388−
389− files := slices.Collect(m.files.Seq())
390−
391− if len(files) == 0 {
392− content := lipgloss.JoinVertical(
393− lipgloss.Left,
394− section,
395− "",
396− t.S().Base.Foreground(t.Border).Render("None"),
397− )
398− return lipgloss.NewStyle().Width(maxWidth).Render(content)
384+ // Convert map to slice and handle type conversion
385+ sessionFiles := slices.Collect(m.files.Seq())
386+ fileSlice := make([]files.SessionFile, len(sessionFiles))
387+ for i, sf := range sessionFiles {
388+ fileSlice[i] = files.SessionFile{
389+ History: files.FileHistory{
390+ InitialVersion: sf.History.initialVersion,
391+ LatestVersion: sf.History.latestVersion,
392+ },
393+ FilePath: sf.FilePath,
394+ Additions: sf.Additions,
395+ Deletions: sf.Deletions,
396+ }
399397 }
400398
401− fileList := []string{section, ""}
402− sort.Slice(files, func(i, j int) bool {
403− return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
404− })
405−
406− // Limit items for horizontal layout - use less space
407− maxItems := min(5, len(files))
399+ // Limit items for horizontal layout
400+ maxItems := min(5, len(fileSlice))
408401 availableHeight := m.height - 8 // Reserve space for header and other content
409402 if availableHeight > 0 {
410403 maxItems = min(maxItems, availableHeight)
411404 }
412405
413− filesShown := 0
414− for _, file := range files {
415− if file.Additions == 0 && file.Deletions == 0 {
416− continue
417− }
418− if filesShown >= maxItems {
419− break
420− }
421−
422− var statusParts []string
423− if file.Additions > 0 {
424− statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
425− }
426− if file.Deletions > 0 {
427− statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
428− }
429−
430− extraContent := strings.Join(statusParts, " ")
431− cwd := config.Get().WorkingDir() + string(os.PathSeparator)
432− filePath := file.FilePath
433− filePath = strings.TrimPrefix(filePath, cwd)
434− filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
435− filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…")
436−
437− fileList = append(fileList,
438− core.Status(
439− core.StatusOpts{
440− IconColor: t.FgMuted,
441− NoIcon: true,
442− Title: filePath,
443− ExtraContent: extraContent,
444− },
445− maxWidth,
446− ),
447− )
448− filesShown++
449− }
450−
451− // Add "..." indicator if there are more files
452− totalFilesWithChanges := 0
453− for _, file := range files {
454− if file.Additions > 0 || file.Deletions > 0 {
455− totalFilesWithChanges++
456− }
457− }
458− if totalFilesWithChanges > maxItems {
459− fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("…"))
460− }
461−
462− content := lipgloss.JoinVertical(lipgloss.Left, fileList...)
463− return lipgloss.NewStyle().Width(maxWidth).Render(content)
406+ return files.RenderFileBlock(fileSlice, files.RenderOptions{
407+ MaxWidth: maxWidth,
408+ MaxItems: maxItems,
409+ ShowSection: true,
410+ SectionName: "Modified Files",
411+ }, true)
464412}
465413
466414// lspBlockCompact renders the LSP block with limited width and height for horizontal layout
467415func (m *sidebarCmp) lspBlockCompact(maxWidth int) string {
468− t := styles.CurrentTheme()
469−
470− section := t.S().Subtle.Render("LSPs")
471−
472− lspList := []string{section, ""}
473−
474− lsp := config.Get().LSP.Sorted()
475− if len(lsp) == 0 {
476− content := lipgloss.JoinVertical(
477− lipgloss.Left,
478− section,
479− "",
480− t.S().Base.Foreground(t.Border).Render("None"),
481− )
482− return lipgloss.NewStyle().Width(maxWidth).Render(content)
483− }
484−
485416 // Limit items for horizontal layout
486− maxItems := min(5, len(lsp))
417+ lspConfigs := config.Get().LSP.Sorted()
418+ maxItems := min(5, len(lspConfigs))
487419 availableHeight := m.height - 8
488420 if availableHeight > 0 {
489421 maxItems = min(maxItems, availableHeight)
490422 }
491423
492− for i, l := range lsp {
493− if i >= maxItems {
494− break
495− }
496−
497− iconColor := t.Success
498− if l.LSP.Disabled {
499− iconColor = t.FgMuted
500− }
501−
502− lspErrs := map[protocol.DiagnosticSeverity]int{
503− protocol.SeverityError: 0,
504− protocol.SeverityWarning: 0,
505− protocol.SeverityHint: 0,
506− protocol.SeverityInformation: 0,
507− }
508− if client, ok := m.lspClients[l.Name]; ok {
509− for _, diagnostics := range client.GetDiagnostics() {
510− for _, diagnostic := range diagnostics {
511− if severity, ok := lspErrs[diagnostic.Severity]; ok {
512− lspErrs[diagnostic.Severity] = severity + 1
513− }
514− }
515− }
516− }
517−
518− errs := []string{}
519− if lspErrs[protocol.SeverityError] > 0 {
520− errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
521− }
522− if lspErrs[protocol.SeverityWarning] > 0 {
523− errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
524− }
525− if lspErrs[protocol.SeverityHint] > 0 {
526− errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
527− }
528− if lspErrs[protocol.SeverityInformation] > 0 {
529− errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
530− }
531−
532− lspList = append(lspList,
533− core.Status(
534− core.StatusOpts{
535− IconColor: iconColor,
536− Title: l.Name,
537− Description: l.LSP.Command,
538− ExtraContent: strings.Join(errs, " "),
539− },
540− maxWidth,
541− ),
542− )
543− }
544−
545− // Add "..." indicator if there are more LSPs
546− if len(lsp) > maxItems {
547− lspList = append(lspList, t.S().Base.Foreground(t.FgMuted).Render("…"))
548− }
549−
550− content := lipgloss.JoinVertical(lipgloss.Left, lspList...)
551− return lipgloss.NewStyle().Width(maxWidth).Render(content)
424+ return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
425+ MaxWidth: maxWidth,
426+ MaxItems: maxItems,
427+ ShowSection: true,
428+ SectionName: "LSPs",
429+ }, true)
552430}
553431
554432// mcpBlockCompact renders the MCP block with limited width and height for horizontal layout
555433func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
556− t := styles.CurrentTheme()
557−
558− section := t.S().Subtle.Render("MCPs")
559−
560− mcpList := []string{section, ""}
561−
562− mcps := config.Get().MCP.Sorted()
563− if len(mcps) == 0 {
564− content := lipgloss.JoinVertical(
565− lipgloss.Left,
566− section,
567− "",
568− t.S().Base.Foreground(t.Border).Render("None"),
569− )
570− return lipgloss.NewStyle().Width(maxWidth).Render(content)
571− }
572−
573434 // Limit items for horizontal layout
574− maxItems := min(5, len(mcps))
435+ maxItems := min(5, len(config.Get().MCP.Sorted()))
575436 availableHeight := m.height - 8
576437 if availableHeight > 0 {
577438 maxItems = min(maxItems, availableHeight)
578439 }
579440
580− for i, l := range mcps {
581− if i >= maxItems {
582− break
583− }
584−
585− iconColor := t.Success
586− if l.MCP.Disabled {
587− iconColor = t.FgMuted
588− }
589−
590− mcpList = append(mcpList,
591− core.Status(
592− core.StatusOpts{
593− IconColor: iconColor,
594− Title: l.Name,
595− Description: l.MCP.Command,
596− },
597− maxWidth,
598− ),
599− )
600− }
601−
602− // Add "..." indicator if there are more MCPs
603− if len(mcps) > maxItems {
604− mcpList = append(mcpList, t.S().Base.Foreground(t.FgMuted).Render("…"))
605− }
606−
607− content := lipgloss.JoinVertical(lipgloss.Left, mcpList...)
608− return lipgloss.NewStyle().Width(maxWidth).Render(content)
441+ return mcp.RenderMCPBlock(mcp.RenderOptions{
442+ MaxWidth: maxWidth,
443+ MaxItems: maxItems,
444+ ShowSection: true,
445+ SectionName: "MCPs",
446+ }, true)
609447}
610448
611449func (m *sidebarCmp) filesBlock() string {
612− t := styles.CurrentTheme()
613−
614− section := t.S().Subtle.Render(
615− core.Section("Modified Files", m.getMaxWidth()),
616− )
617−
618− files := slices.Collect(m.files.Seq())
619− if len(files) == 0 {
620− return lipgloss.JoinVertical(
621− lipgloss.Left,
622− section,
623− "",
624− t.S().Base.Foreground(t.Border).Render("None"),
625− )
450+ // Convert map to slice and handle type conversion
451+ sessionFiles := slices.Collect(m.files.Seq())
452+ fileSlice := make([]files.SessionFile, len(sessionFiles))
453+ for i, sf := range sessionFiles {
454+ fileSlice[i] = files.SessionFile{
455+ History: files.FileHistory{
456+ InitialVersion: sf.History.initialVersion,
457+ LatestVersion: sf.History.latestVersion,
458+ },
459+ FilePath: sf.FilePath,
460+ Additions: sf.Additions,
461+ Deletions: sf.Deletions,
462+ }
626463 }
627464
628− fileList := []string{section, ""}
629− // order files by the latest version's created time
630− sort.Slice(files, func(i, j int) bool {
631− return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
632− })
633−
634465 // Limit the number of files shown
635466 maxFiles, _, _ := m.getDynamicLimits()
636− maxFiles = min(len(files), maxFiles)
637− filesShown := 0
638−
639− for _, file := range files {
640− if file.Additions == 0 && file.Deletions == 0 {
641− continue // skip files with no changes
642− }
643− if filesShown >= maxFiles {
644− break
645− }
646−
647− var statusParts []string
648− if file.Additions > 0 {
649− statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
650− }
651− if file.Deletions > 0 {
652− statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
653− }
654−
655− extraContent := strings.Join(statusParts, " ")
656− cwd := config.Get().WorkingDir() + string(os.PathSeparator)
657− filePath := file.FilePath
658− filePath = strings.TrimPrefix(filePath, cwd)
659− filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
660− filePath = ansi.Truncate(filePath, m.getMaxWidth()-lipgloss.Width(extraContent)-2, "…")
661− fileList = append(fileList,
662− core.Status(
663− core.StatusOpts{
664− IconColor: t.FgMuted,
665− NoIcon: true,
666− Title: filePath,
667− ExtraContent: extraContent,
668− },
669− m.getMaxWidth(),
670− ),
671− )
672− filesShown++
673− }
674−
675− // Add indicator if there are more files
676− totalFilesWithChanges := 0
677− for _, file := range files {
678− if file.Additions > 0 || file.Deletions > 0 {
679− totalFilesWithChanges++
680− }
681− }
682− if totalFilesWithChanges > maxFiles {
683− remaining := totalFilesWithChanges - maxFiles
684− fileList = append(fileList,
685− t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
686− )
687− }
688−
689− return lipgloss.JoinVertical(
690− lipgloss.Left,
691− fileList...,
692− )
467+ maxFiles = min(len(fileSlice), maxFiles)
468+
469+ return files.RenderFileBlock(fileSlice, files.RenderOptions{
470+ MaxWidth: m.getMaxWidth(),
471+ MaxItems: maxFiles,
472+ ShowSection: true,
473+ SectionName: core.Section("Modified Files", m.getMaxWidth()),
474+ }, true)
693475}
694476
695477func (m *sidebarCmp) lspBlock() string {
696− t := styles.CurrentTheme()
697−
698− section := t.S().Subtle.Render(
699− core.Section("LSPs", m.getMaxWidth()),
700− )
701−
702− lspList := []string{section, ""}
703−
704− lsp := config.Get().LSP.Sorted()
705− if len(lsp) == 0 {
706− return lipgloss.JoinVertical(
707− lipgloss.Left,
708− section,
709− "",
710− t.S().Base.Foreground(t.Border).Render("None"),
711− )
712− }
713−
714478 // Limit the number of LSPs shown
715479 _, maxLSPs, _ := m.getDynamicLimits()
716− maxLSPs = min(len(lsp), maxLSPs)
717− for i, l := range lsp {
718− if i >= maxLSPs {
719− break
720− }
721−
722− iconColor := t.Success
723− if l.LSP.Disabled {
724− iconColor = t.FgMuted
725− }
726− lspErrs := map[protocol.DiagnosticSeverity]int{
727− protocol.SeverityError: 0,
728− protocol.SeverityWarning: 0,
729− protocol.SeverityHint: 0,
730− protocol.SeverityInformation: 0,
731− }
732− if client, ok := m.lspClients[l.Name]; ok {
733− for _, diagnostics := range client.GetDiagnostics() {
734− for _, diagnostic := range diagnostics {
735− if severity, ok := lspErrs[diagnostic.Severity]; ok {
736− lspErrs[diagnostic.Severity] = severity + 1
737− }
738− }
739− }
740− }
741−
742− errs := []string{}
743− if lspErrs[protocol.SeverityError] > 0 {
744− errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
745− }
746− if lspErrs[protocol.SeverityWarning] > 0 {
747− errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
748− }
749− if lspErrs[protocol.SeverityHint] > 0 {
750− errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
751− }
752− if lspErrs[protocol.SeverityInformation] > 0 {
753− errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
754− }
755−
756− lspList = append(lspList,
757− core.Status(
758− core.StatusOpts{
759− IconColor: iconColor,
760− Title: l.Name,
761− Description: l.LSP.Command,
762− ExtraContent: strings.Join(errs, " "),
763− },
764− m.getMaxWidth(),
765− ),
766− )
767− }
768−
769− // Add indicator if there are more LSPs
770− if len(lsp) > maxLSPs {
771− remaining := len(lsp) - maxLSPs
772− lspList = append(lspList,
773− t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
774− )
775− }
776−
777− return lipgloss.JoinVertical(
778− lipgloss.Left,
779− lspList...,
780− )
480+ lspConfigs := config.Get().LSP.Sorted()
481+ maxLSPs = min(len(lspConfigs), maxLSPs)
482+
483+ return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
484+ MaxWidth: m.getMaxWidth(),
485+ MaxItems: maxLSPs,
486+ ShowSection: true,
487+ SectionName: core.Section("LSPs", m.getMaxWidth()),
488+ }, true)
781489}
782490
783491func (m *sidebarCmp) mcpBlock() string {
784− t := styles.CurrentTheme()
785−
786− section := t.S().Subtle.Render(
787− core.Section("MCPs", m.getMaxWidth()),
788− )
789−
790− mcpList := []string{section, ""}
791−
792− mcps := config.Get().MCP.Sorted()
793− if len(mcps) == 0 {
794− return lipgloss.JoinVertical(
795− lipgloss.Left,
796− section,
797− "",
798− t.S().Base.Foreground(t.Border).Render("None"),
799− )
800− }
801−
802492 // Limit the number of MCPs shown
803493 _, _, maxMCPs := m.getDynamicLimits()
494+ mcps := config.Get().MCP.Sorted()
804495 maxMCPs = min(len(mcps), maxMCPs)
805− for i, l := range mcps {
806− if i >= maxMCPs {
807− break
808− }
809−
810− iconColor := t.Success
811− if l.MCP.Disabled {
812− iconColor = t.FgMuted
813− }
814− mcpList = append(mcpList,
815− core.Status(
816− core.StatusOpts{
817− IconColor: iconColor,
818− Title: l.Name,
819− Description: l.MCP.Command,
820− },
821− m.getMaxWidth(),
822− ),
823− )
824− }
825−
826− // Add indicator if there are more MCPs
827− if len(mcps) > maxMCPs {
828− remaining := len(mcps) - maxMCPs
829− mcpList = append(mcpList,
830− t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
831− )
832− }
833496
834− return lipgloss.JoinVertical(
835− lipgloss.Left,
836− mcpList...,
837− )
497+ return mcp.RenderMCPBlock(mcp.RenderOptions{
498+ MaxWidth: m.getMaxWidth(),
499+ MaxItems: maxMCPs,
500+ ShowSection: true,
501+ SectionName: core.Section("MCPs", m.getMaxWidth()),
502+ }, true)
838503}
839504
840505func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
internal/tui/components/chat/splash/splash.go+11−47
@@ -18,6 +18,8 @@ import (
1818 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
1919 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
2020 "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"
2123 "github.com/charmbracelet/crush/internal/tui/exp/list"
2224 "github.com/charmbracelet/crush/internal/tui/styles"
2325 "github.com/charmbracelet/crush/internal/tui/util"
@@ -655,7 +657,7 @@ func (s *splashCmp) Bindings() []key.Binding {
655657}
656658
657659func (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
659661}
660662
661663func (s *splashCmp) cwd() string {
@@ -670,29 +672,10 @@ func (s *splashCmp) cwd() string {
670672}
671673
672674func LSPList(maxWidth int) []string {
673− t := styles.CurrentTheme()
674− lspList := []string{}
675− lsp := config.Get().LSP.Sorted()
676− if len(lsp) == 0 {
677− return []string{t.S().Base.Foreground(t.Border).Render("None")}
678− }
679− for _, l := range lsp {
680− iconColor := t.Success
681− if l.LSP.Disabled {
682− iconColor = t.FgMuted
683− }
684− lspList = append(lspList,
685− core.Status(
686− core.StatusOpts{
687− IconColor: iconColor,
688− Title: l.Name,
689− Description: l.LSP.Command,
690− },
691− maxWidth,
692− ),
693− )
694− }
695− return lspList
675+ return lspcomponent.RenderLSPList(nil, lspcomponent.RenderOptions{
676+ MaxWidth: maxWidth,
677+ ShowSection: false,
678+ })
696679}
697680
698681func (s *splashCmp) lspBlock() string {
@@ -709,29 +692,10 @@ func (s *splashCmp) lspBlock() string {
709692}
710693
711694func MCPList(maxWidth int) []string {
712− t := styles.CurrentTheme()
713− mcpList := []string{}
714− mcps := config.Get().MCP.Sorted()
715− if len(mcps) == 0 {
716− return []string{t.S().Base.Foreground(t.Border).Render("None")}
717− }
718− for _, l := range mcps {
719− iconColor := t.Success
720− if l.MCP.Disabled {
721− iconColor = t.FgMuted
722− }
723− mcpList = append(mcpList,
724− core.Status(
725− core.StatusOpts{
726− IconColor: iconColor,
727− Title: l.Name,
728− Description: l.MCP.Command,
729− },
730− maxWidth,
731− ),
732− )
733− }
734− return mcpList
695+ return mcp.RenderMCPList(mcp.RenderOptions{
696+ MaxWidth: maxWidth,
697+ ShowSection: false,
698+ })
735699}
736700
737701func (s *splashCmp) mcpBlock() string {
internal/tui/components/files/files.gonew file+142
@@ -0,0 +1,142 @@
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+ return fileSlice[i].History.LatestVersion.CreatedAt > fileSlice[j].History.LatestVersion.CreatedAt
63+ })
64+
65+ // Determine how many items to show
66+ maxItems := len(fileSlice)
67+ if opts.MaxItems > 0 {
68+ maxItems = min(opts.MaxItems, len(fileSlice))
69+ }
70+
71+ filesShown := 0
72+ for _, file := range fileSlice {
73+ if file.Additions == 0 && file.Deletions == 0 {
74+ continue // skip files with no changes
75+ }
76+ if filesShown >= maxItems {
77+ break
78+ }
79+
80+ var statusParts []string
81+ if file.Additions > 0 {
82+ statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
83+ }
84+ if file.Deletions > 0 {
85+ statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
86+ }
87+
88+ extraContent := strings.Join(statusParts, " ")
89+ cwd := config.Get().WorkingDir() + string(os.PathSeparator)
90+ filePath := file.FilePath
91+ filePath = strings.TrimPrefix(filePath, cwd)
92+ filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
93+ filePath = ansi.Truncate(filePath, opts.MaxWidth-lipgloss.Width(extraContent)-2, "…")
94+
95+ fileList = append(fileList,
96+ core.Status(
97+ core.StatusOpts{
98+ IconColor: t.FgMuted,
99+ NoIcon: true,
100+ Title: filePath,
101+ ExtraContent: extraContent,
102+ },
103+ opts.MaxWidth,
104+ ),
105+ )
106+ filesShown++
107+ }
108+
109+ return fileList
110+}
111+
112+// RenderFileBlock renders a complete file block with optional truncation indicator.
113+func RenderFileBlock(fileSlice []SessionFile, opts RenderOptions, showTruncationIndicator bool) string {
114+ t := styles.CurrentTheme()
115+ fileList := RenderFileList(fileSlice, opts)
116+
117+ // Add truncation indicator if needed
118+ if showTruncationIndicator && opts.MaxItems > 0 {
119+ totalFilesWithChanges := 0
120+ for _, file := range fileSlice {
121+ if file.Additions > 0 || file.Deletions > 0 {
122+ totalFilesWithChanges++
123+ }
124+ }
125+ if totalFilesWithChanges > opts.MaxItems {
126+ remaining := totalFilesWithChanges - opts.MaxItems
127+ if remaining == 1 {
128+ fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("…"))
129+ } else {
130+ fileList = append(fileList,
131+ t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)),
132+ )
133+ }
134+ }
135+ }
136+
137+ content := lipgloss.JoinVertical(lipgloss.Left, fileList...)
138+ if opts.MaxWidth > 0 {
139+ return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content)
140+ }
141+ return content
142+}
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+}