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