fix: correctly show mcp and lsp states

Kujtim Hoxha created

Change summary

internal/app/app.go                             |   2 
internal/app/lsp.go                             |  12 
internal/app/lsp_events.go                      | 104 ++++
internal/llm/agent/mcp-tools.go                 | 133 +++++
internal/lsp/client.go                          |  19 
internal/lsp/handlers.go                        |  14 
internal/tui/components/chat/sidebar/sidebar.go | 487 ++----------------
internal/tui/components/chat/splash/splash.go   |  58 -
internal/tui/components/files/files.go          | 142 +++++
internal/tui/components/lsp/lsp.go              | 160 ++++++
internal/tui/components/mcp/mcp.go              | 128 ++++
11 files changed, 795 insertions(+), 464 deletions(-)

Detailed changes

internal/app/app.go πŸ”—

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

internal/app/lsp.go πŸ”—

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

internal/app/lsp_events.go πŸ”—

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

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

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

internal/lsp/client.go πŸ”—

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

internal/lsp/handlers.go πŸ”—

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

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

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

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

@@ -18,6 +18,8 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
 	"github.com/charmbracelet/crush/internal/tui/components/logo"
+	lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
+	"github.com/charmbracelet/crush/internal/tui/components/mcp"
 	"github.com/charmbracelet/crush/internal/tui/exp/list"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
@@ -655,7 +657,7 @@ func (s *splashCmp) Bindings() []key.Binding {
 }
 
 func (s *splashCmp) getMaxInfoWidth() int {
-	return min(s.width-2, 40) // 2 for left padding
+	return min(s.width-2, 90) // 2 for left padding
 }
 
 func (s *splashCmp) cwd() string {
@@ -670,29 +672,10 @@ func (s *splashCmp) cwd() string {
 }
 
 func LSPList(maxWidth int) []string {
-	t := styles.CurrentTheme()
-	lspList := []string{}
-	lsp := config.Get().LSP.Sorted()
-	if len(lsp) == 0 {
-		return []string{t.S().Base.Foreground(t.Border).Render("None")}
-	}
-	for _, l := range lsp {
-		iconColor := t.Success
-		if l.LSP.Disabled {
-			iconColor = t.FgMuted
-		}
-		lspList = append(lspList,
-			core.Status(
-				core.StatusOpts{
-					IconColor:   iconColor,
-					Title:       l.Name,
-					Description: l.LSP.Command,
-				},
-				maxWidth,
-			),
-		)
-	}
-	return lspList
+	return lspcomponent.RenderLSPList(nil, lspcomponent.RenderOptions{
+		MaxWidth:    maxWidth,
+		ShowSection: false,
+	})
 }
 
 func (s *splashCmp) lspBlock() string {
@@ -709,29 +692,10 @@ func (s *splashCmp) lspBlock() string {
 }
 
 func MCPList(maxWidth int) []string {
-	t := styles.CurrentTheme()
-	mcpList := []string{}
-	mcps := config.Get().MCP.Sorted()
-	if len(mcps) == 0 {
-		return []string{t.S().Base.Foreground(t.Border).Render("None")}
-	}
-	for _, l := range mcps {
-		iconColor := t.Success
-		if l.MCP.Disabled {
-			iconColor = t.FgMuted
-		}
-		mcpList = append(mcpList,
-			core.Status(
-				core.StatusOpts{
-					IconColor:   iconColor,
-					Title:       l.Name,
-					Description: l.MCP.Command,
-				},
-				maxWidth,
-			),
-		)
-	}
-	return mcpList
+	return mcp.RenderMCPList(mcp.RenderOptions{
+		MaxWidth:    maxWidth,
+		ShowSection: false,
+	})
 }
 
 func (s *splashCmp) mcpBlock() string {

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

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

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

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

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

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