Merge remote-tracking branch 'origin/main' into fix-rendering

Kujtim Hoxha created

Change summary

.github/cla-signatures.json                     |  24 
.github/dependabot.yml                          |   4 
.github/workflows/build.yml                     |   2 
.goreleaser.yml                                 |   6 
go.mod                                          |   8 
go.sum                                          |  20 
internal/app/app.go                             |   2 
internal/app/lsp.go                             |  12 
internal/app/lsp_events.go                      | 102 +++
internal/llm/agent/mcp-tools.go                 | 143 +++++
internal/lsp/client.go                          |  19 
internal/lsp/handlers.go                        |  14 
internal/tui/components/chat/sidebar/sidebar.go | 493 ++----------------
internal/tui/components/chat/splash/splash.go   |  58 -
internal/tui/components/files/files.go          | 145 +++++
internal/tui/components/lsp/lsp.go              | 160 ++++++
internal/tui/components/mcp/mcp.go              | 128 ++++
17 files changed, 851 insertions(+), 489 deletions(-)

Detailed changes

.github/cla-signatures.json πŸ”—

@@ -199,6 +199,30 @@
       "created_at": "2025-08-03T04:07:16Z",
       "repoId": 987670088,
       "pullRequestNo": 519
+    },
+    {
+      "name": "jooray",
+      "id": 1028688,
+      "comment_id": 3148713433,
+      "created_at": "2025-08-03T21:35:15Z",
+      "repoId": 987670088,
+      "pullRequestNo": 527
+    },
+    {
+      "name": "Ed4ward",
+      "id": 153800328,
+      "comment_id": 3150375016,
+      "created_at": "2025-08-04T12:16:16Z",
+      "repoId": 987670088,
+      "pullRequestNo": 539
+    },
+    {
+      "name": "ngnhng",
+      "id": 51743767,
+      "comment_id": 3150846779,
+      "created_at": "2025-08-04T14:01:30Z",
+      "repoId": 987670088,
+      "pullRequestNo": 546
     }
   ]
 }

.github/dependabot.yml πŸ”—

@@ -9,7 +9,7 @@ updates:
       time: "05:00"
       timezone: "America/New_York"
     labels:
-      - "dependencies"
+      - "area: dependencies"
     commit-message:
       prefix: "chore"
       include: "scope"
@@ -22,7 +22,7 @@ updates:
       time: "05:00"
       timezone: "America/New_York"
     labels:
-      - "dependencies"
+      - "area: dependencies"
     commit-message:
       prefix: "chore"
       include: "scope"

.github/workflows/build.yml πŸ”—

@@ -23,7 +23,9 @@ jobs:
         with:
           github-token: "${{ secrets.GITHUB_TOKEN }}"
       - run: |
+          echo "Approving..."
           gh pr review --approve "$PR_URL"
+          echo "Merging..."
           gh pr merge --squash --auto "$PR_URL"
         env:
           PR_URL: ${{github.event.pull_request.html_url}}

.goreleaser.yml πŸ”—

@@ -166,6 +166,12 @@ brews:
       fish_completion.install "completions/{{ .ProjectName }}.fish"
       man1.install "manpages/{{ .ProjectName }}.1.gz"
 
+scoops:
+  - repository:
+      owner: charmbracelet
+      name: scoop-bucket
+      token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
+
 npms:
   - name: "@charmland/crush"
     repository: "git+https://github.com/charmbracelet/crush.git"

go.mod πŸ”—

@@ -6,7 +6,7 @@ require (
 	github.com/JohannesKaufmann/html-to-markdown v1.6.0
 	github.com/MakeNowJust/heredoc v1.0.0
 	github.com/PuerkitoBio/goquery v1.9.2
-	github.com/alecthomas/chroma/v2 v2.15.0
+	github.com/alecthomas/chroma/v2 v2.20.0
 	github.com/anthropics/anthropic-sdk-go v1.6.2
 	github.com/atotto/clipboard v0.1.4
 	github.com/aymanbagabas/go-udiff v0.3.1
@@ -27,7 +27,7 @@ require (
 	github.com/google/uuid v1.6.0
 	github.com/invopop/jsonschema v0.13.0
 	github.com/joho/godotenv v1.5.1
-	github.com/mark3labs/mcp-go v0.34.0
+	github.com/mark3labs/mcp-go v0.36.0
 	github.com/muesli/termenv v0.16.0
 	github.com/ncruces/go-sqlite3 v0.25.0
 	github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
@@ -93,7 +93,7 @@ require (
 	github.com/charmbracelet/x/windows v0.2.1 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/disintegration/gift v1.1.2 // indirect
-	github.com/dlclark/regexp2 v1.11.4 // indirect
+	github.com/dlclark/regexp2 v1.11.5 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect
 	github.com/go-logfmt/logfmt v0.6.0 // indirect
@@ -144,7 +144,7 @@ require (
 	golang.org/x/sync v0.16.0 // indirect
 	golang.org/x/sys v0.34.0
 	golang.org/x/term v0.32.0 // indirect
-	golang.org/x/text v0.25.0
+	golang.org/x/text v0.27.0
 	google.golang.org/genai v1.3.0
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
 	google.golang.org/grpc v1.71.0 // indirect

go.sum πŸ”—

@@ -22,10 +22,10 @@ github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4
 github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
-github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
-github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
-github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
-github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
+github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
+github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
+github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
+github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
 github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
 github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
 github.com/anthropics/anthropic-sdk-go v1.6.2 h1:oORA212y0/zAxe7OPvdgIbflnn/x5PGk5uwjF60GqXM=
@@ -118,8 +118,8 @@ github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4G
 github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
-github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
-github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
+github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -180,8 +180,8 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69
 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
-github.com/mark3labs/mcp-go v0.34.0 h1:eWy7WBGvhk6EyAAyVzivTCprE52iXJwNtvHV6Cv3bR0=
-github.com/mark3labs/mcp-go v0.34.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
+github.com/mark3labs/mcp-go v0.36.0 h1:rIZaijrRYPeSbJG8/qNDe0hWlGrCJ7FWHNMz2SQpTis=
+github.com/mark3labs/mcp-go v0.36.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
@@ -371,8 +371,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
-golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
+golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
+golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
 golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
 golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

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,102 @@
+package app
+
+import (
+	"context"
+	"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]()
+)
+
+// 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,23 +7,76 @@ 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/version"
-
 	"github.com/charmbracelet/crush/internal/permission"
-
+	"github.com/charmbracelet/crush/internal/pubsub"
+	"github.com/charmbracelet/crush/internal/version"
 	"github.com/mark3labs/mcp-go/client"
 	"github.com/mark3labs/mcp-go/client/transport"
 	"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 +162,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 +179,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 +243,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 +296,9 @@ 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())
+			updateMCPState(name, MCPStateConnected, nil, c, len(tools))
+			result.Append(tools...)
 		}(name, m)
 	}
 	wg.Wait()
@@ -191,6 +317,7 @@ func createMcpClient(m config.MCPConfig) (*client.Client, error) {
 		return client.NewStreamableHttpClient(
 			m.URL,
 			transport.WithHTTPHeaders(m.ResolvedHeaders()),
+			transport.WithLogger(mcpHTTPLogger{}),
 		)
 	case config.MCPSse:
 		return client.NewSSEMCPClient(
@@ -201,3 +328,9 @@ func createMcpClient(m config.MCPConfig) (*client.Client, error) {
 		return nil, fmt.Errorf("unsupported mcp type: %s", m.Type)
 	}
 }
+
+// for MCP's HTTP client.
+type mcpHTTPLogger struct{}
+
+func (l mcpHTTPLogger) Errorf(format string, v ...any) { slog.Error(fmt.Sprintf(format, v...)) }
+func (l mcpHTTPLogger) Infof(format string, v ...any)  { slog.Info(fmt.Sprintf(format, v...)) }

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,465 +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)
-	}
-
-	fileList := []string{section, ""}
-	sort.Slice(files, func(i, j int) bool {
-		if files[i].History.latestVersion.CreatedAt == files[j].History.latestVersion.CreatedAt {
-			return files[i].FilePath < files[j].FilePath
+	// 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,
 		}
-		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"),
-		)
-	}
-
-	fileList := []string{section, ""}
-	// order files by the latest version's created time, then by path for stability
-	sort.Slice(files, func(i, j int) bool {
-		if files[i].History.latestVersion.CreatedAt == files[j].History.latestVersion.CreatedAt {
-			return files[i].FilePath < files[j].FilePath
+	// 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,
 		}
-		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,145 @@
+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 {
+		if fileSlice[i].History.LatestVersion.CreatedAt == fileSlice[j].History.LatestVersion.CreatedAt {
+			return strings.Compare(fileSlice[i].FilePath, fileSlice[j].FilePath) < 0
+		}
+		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
+}