diff --git a/internal/app/app.go b/internal/app/app.go index 849e4fcc6418580ab35832235b9541c1748f8339..c8f6fe75ed2db719fa7ace6d9507f46fd2b441f3 100644 --- a/internal/app/app.go +++ b/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() diff --git a/internal/app/lsp.go b/internal/app/lsp.go index afe76a68460d262a3f57f214ad3c0c153ddbd807..e5b16d3c5e8efb4f7569e426bda6e30dceb127c5 100644 --- a/internal/app/lsp.go +++ b/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) diff --git a/internal/app/lsp_events.go b/internal/app/lsp_events.go new file mode 100644 index 0000000000000000000000000000000000000000..5961ec5c13e05fc42ff4eab7fbee744224a49694 --- /dev/null +++ b/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, + }) + } +} diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 8df78e52452385a44a06fd07feb62d3b9892388f..72b57ec07ff00ba94e188b9f00ed08698a1de028 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/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() diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 219a5df5fb87197f0490f218cddc24ab3b138371..279ec1feb80b79ef093fc8d1395022d4949756d7 100644 --- a/internal/lsp/client.go +++ b/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 { diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go index 725d3c3c77ffba465b3e644a9948a1ce56c3eeaa..72f3018b3da969000672e5b4ba47f73f2b72df97 100644 --- a/internal/lsp/handlers.go +++ b/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) + } } diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go index 1f5fd2a672e3d643efbed4ca35b08ed88c55d2eb..edec996e32558fadb6112ef9781a26413182a06a 100644 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ b/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 { diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 9a74e79b30bbdcc9e0049f9fea55c23607fbc00a..acaf2740c9fb30fb4fc80ddea36b71e10c61b1e2 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/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 { diff --git a/internal/tui/components/files/files.go b/internal/tui/components/files/files.go new file mode 100644 index 0000000000000000000000000000000000000000..9ddced4c908ecae59452ecc999facfcaf52443b3 --- /dev/null +++ b/internal/tui/components/files/files.go @@ -0,0 +1,142 @@ +package files + +import ( + "fmt" + "os" + "sort" + "strings" + + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/history" + "github.com/charmbracelet/crush/internal/tui/components/core" + "github.com/charmbracelet/crush/internal/tui/styles" +) + +// FileHistory represents a file history with initial and latest versions. +type FileHistory struct { + InitialVersion history.File + LatestVersion history.File +} + +// SessionFile represents a file with its history information. +type SessionFile struct { + History FileHistory + FilePath string + Additions int + Deletions int +} + +// RenderOptions contains options for rendering file lists. +type RenderOptions struct { + MaxWidth int + MaxItems int + ShowSection bool + SectionName string +} + +// RenderFileList renders a list of file status items with the given options. +func RenderFileList(fileSlice []SessionFile, opts RenderOptions) []string { + t := styles.CurrentTheme() + fileList := []string{} + + if opts.ShowSection { + sectionName := opts.SectionName + if sectionName == "" { + sectionName = "Modified Files" + } + section := t.S().Subtle.Render(sectionName) + fileList = append(fileList, section, "") + } + + if len(fileSlice) == 0 { + fileList = append(fileList, t.S().Base.Foreground(t.Border).Render("None")) + return fileList + } + + // Sort files by the latest version's created time + sort.Slice(fileSlice, func(i, j int) bool { + return fileSlice[i].History.LatestVersion.CreatedAt > fileSlice[j].History.LatestVersion.CreatedAt + }) + + // Determine how many items to show + maxItems := len(fileSlice) + if opts.MaxItems > 0 { + maxItems = min(opts.MaxItems, len(fileSlice)) + } + + filesShown := 0 + for _, file := range fileSlice { + if file.Additions == 0 && file.Deletions == 0 { + continue // skip files with no changes + } + if filesShown >= maxItems { + break + } + + var statusParts []string + if file.Additions > 0 { + statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions))) + } + if file.Deletions > 0 { + statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions))) + } + + extraContent := strings.Join(statusParts, " ") + cwd := config.Get().WorkingDir() + string(os.PathSeparator) + filePath := file.FilePath + filePath = strings.TrimPrefix(filePath, cwd) + filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2) + filePath = ansi.Truncate(filePath, opts.MaxWidth-lipgloss.Width(extraContent)-2, "…") + + fileList = append(fileList, + core.Status( + core.StatusOpts{ + IconColor: t.FgMuted, + NoIcon: true, + Title: filePath, + ExtraContent: extraContent, + }, + opts.MaxWidth, + ), + ) + filesShown++ + } + + return fileList +} + +// RenderFileBlock renders a complete file block with optional truncation indicator. +func RenderFileBlock(fileSlice []SessionFile, opts RenderOptions, showTruncationIndicator bool) string { + t := styles.CurrentTheme() + fileList := RenderFileList(fileSlice, opts) + + // Add truncation indicator if needed + if showTruncationIndicator && opts.MaxItems > 0 { + totalFilesWithChanges := 0 + for _, file := range fileSlice { + if file.Additions > 0 || file.Deletions > 0 { + totalFilesWithChanges++ + } + } + if totalFilesWithChanges > opts.MaxItems { + remaining := totalFilesWithChanges - opts.MaxItems + if remaining == 1 { + fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("…")) + } else { + fileList = append(fileList, + t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)), + ) + } + } + } + + content := lipgloss.JoinVertical(lipgloss.Left, fileList...) + if opts.MaxWidth > 0 { + return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content) + } + return content +} diff --git a/internal/tui/components/lsp/lsp.go b/internal/tui/components/lsp/lsp.go new file mode 100644 index 0000000000000000000000000000000000000000..10d9f42198a6996e966d01305131e734fa54a614 --- /dev/null +++ b/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 +} diff --git a/internal/tui/components/mcp/mcp.go b/internal/tui/components/mcp/mcp.go new file mode 100644 index 0000000000000000000000000000000000000000..93f2dcb230721ab95c3ea2f4937647ff7ccf5bda --- /dev/null +++ b/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 +}