Detailed changes
@@ -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"
@@ -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}}
@@ -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"
@@ -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
@@ -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=
@@ -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,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,
+ })
+ }
+}
@@ -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
@@ -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()
@@ -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)
@@ -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 {
@@ -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 {
@@ -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{
@@ -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
+}
@@ -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
+}
@@ -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)