Merge remote-tracking branch 'origin/main' into fix-openai-provider

Kujtim Hoxha created

Change summary

.github/dependabot.yml                               |   4 
.github/workflows/build.yml                          |   2 
.goreleaser.yml                                      |   6 
go.mod                                               |   6 
go.sum                                               |  16 
internal/app/app.go                                  |   2 
internal/app/lsp.go                                  |  12 
internal/app/lsp_events.go                           | 102 ++
internal/llm/agent/agent.go                          |  68 +
internal/llm/agent/mcp-tools.go                      | 132 +++
internal/llm/tools/tools.go                          |  75 ++
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/dialogs/commands/commands.go |   6 
internal/tui/components/files/files.go               | 145 ++++
internal/tui/components/lsp/lsp.go                   | 160 ++++
internal/tui/components/mcp/mcp.go                   | 128 +++
internal/tui/exp/list/list.go                        |  11 
20 files changed, 940 insertions(+), 513 deletions(-)

Detailed changes

.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
@@ -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=
@@ -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/agent.go πŸ”—

@@ -159,11 +159,12 @@ func NewAgent(
 	if err != nil {
 		return nil, err
 	}
+
 	summarizeOpts := []provider.ProviderClientOption{
-		provider.WithModel(config.SelectedModelTypeSmall),
-		provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, smallModelProviderCfg.ID)),
+		provider.WithModel(config.SelectedModelTypeLarge),
+		provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, providerCfg.ID)),
 	}
-	summarizeProvider, err := provider.NewProvider(*smallModelProviderCfg, summarizeOpts...)
+	summarizeProvider, err := provider.NewProvider(*providerCfg, summarizeOpts...)
 	if err != nil {
 		return nil, err
 	}
@@ -224,7 +225,7 @@ func NewAgent(
 		sessions:            sessions,
 		titleProvider:       titleProvider,
 		summarizeProvider:   summarizeProvider,
-		summarizeProviderID: string(smallModelProviderCfg.ID),
+		summarizeProviderID: string(providerCfg.ID),
 		activeRequests:      csync.NewMap[string, context.CancelFunc](),
 		tools:               csync.NewLazySlice(toolFn),
 	}, nil
@@ -904,54 +905,59 @@ func (a *agent) UpdateModel() error {
 		a.providerID = string(currentProviderCfg.ID)
 	}
 
-	// Check if small model provider has changed (affects title and summarize providers)
+	// Check if providers have changed for title (small) and summarize (large)
 	smallModelCfg := cfg.Models[config.SelectedModelTypeSmall]
 	var smallModelProviderCfg config.ProviderConfig
-
 	for p := range cfg.Providers.Seq() {
 		if p.ID == smallModelCfg.Provider {
 			smallModelProviderCfg = p
 			break
 		}
 	}
-
 	if smallModelProviderCfg.ID == "" {
 		return fmt.Errorf("provider %s not found in config", smallModelCfg.Provider)
 	}
 
-	// Check if summarize provider has changed
-	if string(smallModelProviderCfg.ID) != a.summarizeProviderID {
-		smallModel := cfg.GetModelByType(config.SelectedModelTypeSmall)
-		if smallModel == nil {
-			return fmt.Errorf("model %s not found in provider %s", smallModelCfg.Model, smallModelProviderCfg.ID)
+	largeModelCfg := cfg.Models[config.SelectedModelTypeLarge]
+	var largeModelProviderCfg config.ProviderConfig
+	for p := range cfg.Providers.Seq() {
+		if p.ID == largeModelCfg.Provider {
+			largeModelProviderCfg = p
+			break
 		}
+	}
+	if largeModelProviderCfg.ID == "" {
+		return fmt.Errorf("provider %s not found in config", largeModelCfg.Provider)
+	}
 
-		// Recreate title provider
-		titleOpts := []provider.ProviderClientOption{
-			provider.WithModel(config.SelectedModelTypeSmall),
-			provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptTitle, smallModelProviderCfg.ID)),
-			// We want the title to be short, so we limit the max tokens
-			provider.WithMaxTokens(40),
-		}
-		newTitleProvider, err := provider.NewProvider(smallModelProviderCfg, titleOpts...)
-		if err != nil {
-			return fmt.Errorf("failed to create new title provider: %w", err)
-		}
+	// Recreate title provider
+	titleOpts := []provider.ProviderClientOption{
+		provider.WithModel(config.SelectedModelTypeSmall),
+		provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptTitle, smallModelProviderCfg.ID)),
+		provider.WithMaxTokens(40),
+	}
+	newTitleProvider, err := provider.NewProvider(smallModelProviderCfg, titleOpts...)
+	if err != nil {
+		return fmt.Errorf("failed to create new title provider: %w", err)
+	}
+	a.titleProvider = newTitleProvider
 
-		// Recreate summarize provider
+	// Recreate summarize provider if provider changed (now large model)
+	if string(largeModelProviderCfg.ID) != a.summarizeProviderID {
+		largeModel := cfg.GetModelByType(config.SelectedModelTypeLarge)
+		if largeModel == nil {
+			return fmt.Errorf("model %s not found in provider %s", largeModelCfg.Model, largeModelProviderCfg.ID)
+		}
 		summarizeOpts := []provider.ProviderClientOption{
-			provider.WithModel(config.SelectedModelTypeSmall),
-			provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, smallModelProviderCfg.ID)),
+			provider.WithModel(config.SelectedModelTypeLarge),
+			provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, largeModelProviderCfg.ID)),
 		}
-		newSummarizeProvider, err := provider.NewProvider(smallModelProviderCfg, summarizeOpts...)
+		newSummarizeProvider, err := provider.NewProvider(largeModelProviderCfg, summarizeOpts...)
 		if err != nil {
 			return fmt.Errorf("failed to create new summarize provider: %w", err)
 		}
-
-		// Update the providers and provider ID
-		a.titleProvider = newTitleProvider
 		a.summarizeProvider = newSummarizeProvider
-		a.summarizeProviderID = string(smallModelProviderCfg.ID)
+		a.summarizeProviderID = string(largeModelProviderCfg.ID)
 	}
 
 	return nil

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

@@ -7,21 +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/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 {
@@ -107,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
@@ -123,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{
@@ -143,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
@@ -170,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()

internal/llm/tools/tools.go πŸ”—

@@ -3,6 +3,8 @@ package tools
 import (
 	"context"
 	"encoding/json"
+	"fmt"
+	"strings"
 )
 
 type ToolInfo struct {
@@ -25,6 +27,10 @@ const (
 
 	SessionIDContextKey sessionIDContextKey = "session_id"
 	MessageIDContextKey messageIDContextKey = "message_id"
+
+	maxResponseWidth  = 3000
+	maxResponseHeight = 5000
+	maxResponseChars  = 50000
 )
 
 type ToolResponse struct {
@@ -37,10 +43,77 @@ type ToolResponse struct {
 func NewTextResponse(content string) ToolResponse {
 	return ToolResponse{
 		Type:    ToolResponseTypeText,
-		Content: content,
+		Content: truncateContent(content),
 	}
 }
 
+func truncateContent(content string) string {
+	if len(content) <= maxResponseChars {
+		return truncateWidthAndHeight(content)
+	}
+
+	truncated := content[:maxResponseChars]
+
+	if lastNewline := strings.LastIndex(truncated, "\n"); lastNewline > maxResponseChars/2 {
+		truncated = truncated[:lastNewline]
+	}
+
+	truncated += "\n\n... [Content truncated due to length] ..."
+
+	return truncateWidthAndHeight(truncated)
+}
+
+func truncateWidthAndHeight(content string) string {
+	lines := strings.Split(content, "\n")
+
+	heightTruncated := false
+	if len(lines) > maxResponseHeight {
+		keepLines := maxResponseHeight - 3
+		firstHalf := keepLines / 2
+		secondHalf := keepLines - firstHalf
+
+		truncatedLines := make([]string, 0, maxResponseHeight)
+		truncatedLines = append(truncatedLines, lines[:firstHalf]...)
+		truncatedLines = append(truncatedLines, "")
+		truncatedLines = append(truncatedLines, fmt.Sprintf("... [%d lines truncated] ...", len(lines)-keepLines))
+		truncatedLines = append(truncatedLines, "")
+		truncatedLines = append(truncatedLines, lines[len(lines)-secondHalf:]...)
+
+		lines = truncatedLines
+		heightTruncated = true
+	}
+
+	widthTruncated := false
+	for i, line := range lines {
+		if len(line) > maxResponseWidth {
+			if maxResponseWidth > 20 {
+				keepChars := maxResponseWidth - 10
+				firstHalf := keepChars / 2
+				secondHalf := keepChars - firstHalf
+				lines[i] = line[:firstHalf] + " ... " + line[len(line)-secondHalf:]
+			} else {
+				lines[i] = line[:maxResponseWidth]
+			}
+			widthTruncated = true
+		}
+	}
+
+	result := strings.Join(lines, "\n")
+
+	if heightTruncated || widthTruncated {
+		notices := make([]string, 0, 2)
+		if heightTruncated {
+			notices = append(notices, "height")
+		}
+		if widthTruncated {
+			notices = append(notices, "width")
+		}
+		result += fmt.Sprintf("\n\n[Note: Content truncated by %s to fit response limits]", strings.Join(notices, " and "))
+	}
+
+	return result
+}
+
 func WithResponseMetadata(response ToolResponse, metadata any) ToolResponse {
 	if metadata != nil {
 		metadataBytes, err := json.Marshal(metadata)

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 πŸ”—

@@ -17,6 +17,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"
@@ -633,7 +635,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 {
@@ -648,29 +650,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 {
@@ -687,29 +670,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/dialogs/commands/commands.go πŸ”—

@@ -119,7 +119,10 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.WindowSizeMsg:
 		c.wWidth = msg.Width
 		c.wHeight = msg.Height
-		return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
+		return c, tea.Batch(
+			c.SetCommandType(c.commandType),
+			c.commandList.SetSize(c.listWidth(), c.listHeight()),
+		)
 	case tea.KeyPressMsg:
 		switch {
 		case key.Matches(msg, c.keyMap.Select):
@@ -318,7 +321,6 @@ func (c *commandDialogCmp) defaultCommands() []Command {
 			})
 		}
 	}
-
 	// Only show toggle compact mode command if window width is larger than compact breakpoint (90)
 	if c.wWidth > 120 && c.sessionID != "" {
 		commands = append(commands, Command{

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
+}

internal/tui/exp/list/list.go πŸ”—

@@ -3,6 +3,7 @@ package list
 import (
 	"slices"
 	"strings"
+	"sync"
 
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
@@ -90,6 +91,7 @@ type list[T Item] struct {
 
 	renderedItems *csync.Map[string, renderedItem]
 
+	renderMu sync.Mutex
 	rendered string
 
 	movingByItem bool
@@ -328,7 +330,9 @@ func (l *list[T]) render() tea.Cmd {
 	// we are not rendering the first time
 	if l.rendered != "" {
 		// rerender everything will mostly hit cache
+		l.renderMu.Lock()
 		l.rendered, _ = l.renderIterator(0, false, "")
+		l.renderMu.Unlock()
 		if l.direction == DirectionBackward {
 			l.recalculateItemPositions()
 		}
@@ -338,9 +342,10 @@ func (l *list[T]) render() tea.Cmd {
 		}
 		return focusChangeCmd
 	}
+	l.renderMu.Lock()
 	rendered, finishIndex := l.renderIterator(0, true, "")
 	l.rendered = rendered
-
+	l.renderMu.Unlock()
 	// recalculate for the initial items
 	if l.direction == DirectionBackward {
 		l.recalculateItemPositions()
@@ -348,7 +353,10 @@ func (l *list[T]) render() tea.Cmd {
 	renderCmd := func() tea.Msg {
 		l.offset = 0
 		// render the rest
+
+		l.renderMu.Lock()
 		l.rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
+		l.renderMu.Unlock()
 		// needed for backwards
 		if l.direction == DirectionBackward {
 			l.recalculateItemPositions()
@@ -357,7 +365,6 @@ func (l *list[T]) render() tea.Cmd {
 		if l.focused {
 			l.scrollToSelection()
 		}
-
 		return nil
 	}
 	return tea.Batch(focusChangeCmd, renderCmd)