diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 02420bbeaced0abb3362e1de30be8c3c8c75f0d8..4468e149dcc1fbe1bca6eca30d1dc26eb6aed584 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,7 +9,7 @@ updates: time: "05:00" timezone: "America/New_York" labels: - - "dependencies" + - "area: dependencies" commit-message: prefix: "chore" include: "scope" @@ -22,7 +22,7 @@ updates: time: "05:00" timezone: "America/New_York" labels: - - "dependencies" + - "area: dependencies" commit-message: prefix: "chore" include: "scope" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9f3a73db7fe4ac7581dce6d38cc72c7ec7e55ec1..2c76be8f61850571541f6ba79a5481292d850ef7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,7 +23,9 @@ jobs: with: github-token: "${{ secrets.GITHUB_TOKEN }}" - run: | + echo "Approving..." gh pr review --approve "$PR_URL" + echo "Merging..." gh pr merge --squash --auto "$PR_URL" env: PR_URL: ${{github.event.pull_request.html_url}} diff --git a/.goreleaser.yml b/.goreleaser.yml index 87063217a1dc848b63729c0b2e299a36c02af6fe..bc01806460920d9aa1bd77e044ed7b821bf3467d 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -166,6 +166,12 @@ brews: fish_completion.install "completions/{{ .ProjectName }}.fish" man1.install "manpages/{{ .ProjectName }}.1.gz" +scoops: + - repository: + owner: charmbracelet + name: scoop-bucket + token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" + npms: - name: "@charmland/crush" repository: "git+https://github.com/charmbracelet/crush.git" diff --git a/go.mod b/go.mod index 6f12853c9f7f70b78544049f950b1264af918c68..bfb48ec0f016c9d12623d5c71a000f5e98df0026 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/JohannesKaufmann/html-to-markdown v1.6.0 github.com/MakeNowJust/heredoc v1.0.0 github.com/PuerkitoBio/goquery v1.9.2 - github.com/alecthomas/chroma/v2 v2.15.0 + github.com/alecthomas/chroma/v2 v2.20.0 github.com/anthropics/anthropic-sdk-go v1.6.2 github.com/atotto/clipboard v0.1.4 github.com/aymanbagabas/go-udiff v0.3.1 @@ -93,7 +93,7 @@ require ( github.com/charmbracelet/x/windows v0.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/disintegration/gift v1.1.2 // indirect - github.com/dlclark/regexp2 v1.11.4 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect @@ -144,7 +144,7 @@ require ( golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.34.0 golang.org/x/term v0.32.0 // indirect - golang.org/x/text v0.25.0 + golang.org/x/text v0.27.0 google.golang.org/genai v1.3.0 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect google.golang.org/grpc v1.71.0 // indirect diff --git a/go.sum b/go.sum index 7913df3dee8435f583171fc1d4ec404ca9ab5134..329fa7670eb98bb2a20716c7b83632809ee0e13f 100644 --- a/go.sum +++ b/go.sum @@ -22,10 +22,10 @@ github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4 github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc= -github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio= -github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= -github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/anthropics/anthropic-sdk-go v1.6.2 h1:oORA212y0/zAxe7OPvdgIbflnn/x5PGk5uwjF60GqXM= @@ -118,8 +118,8 @@ github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4G github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI= github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg= github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk= -github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= -github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -371,8 +371,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/app/app.go b/internal/app/app.go index 849e4fcc6418580ab35832235b9541c1748f8339..c8f6fe75ed2db719fa7ace6d9507f46fd2b441f3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -207,6 +207,8 @@ func (app *App) setupEvents() { setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events) setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events) setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events) + setupSubscriber(ctx, app.serviceEventsWG, "mcp", agent.SubscribeMCPEvents, app.events) + setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events) cleanupFunc := func() { cancel() app.serviceEventsWG.Wait() diff --git a/internal/app/lsp.go b/internal/app/lsp.go index afe76a68460d262a3f57f214ad3c0c153ddbd807..e5b16d3c5e8efb4f7569e426bda6e30dceb127c5 100644 --- a/internal/app/lsp.go +++ b/internal/app/lsp.go @@ -22,13 +22,20 @@ func (app *App) initLSPClients(ctx context.Context) { func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) { slog.Info("Creating LSP client", "name", name, "command", command, "args", args) + // Update state to starting + updateLSPState(name, lsp.StateStarting, nil, nil, 0) + // Create LSP client. - lspClient, err := lsp.NewClient(ctx, command, args...) + lspClient, err := lsp.NewClient(ctx, name, command, args...) if err != nil { slog.Error("Failed to create LSP client for", name, err) + updateLSPState(name, lsp.StateError, err, nil, 0) return } + // Set diagnostics callback + lspClient.SetDiagnosticsCallback(updateLSPDiagnostics) + // Increase initialization timeout as some servers take more time to start. initCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() @@ -37,6 +44,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman _, err = lspClient.InitializeLSPClient(initCtx, app.config.WorkingDir()) if err != nil { slog.Error("Initialize failed", "name", name, "error", err) + updateLSPState(name, lsp.StateError, err, lspClient, 0) lspClient.Close() return } @@ -47,10 +55,12 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman // Server never reached a ready state, but let's continue anyway, as // some functionality might still work. lspClient.SetServerState(lsp.StateError) + updateLSPState(name, lsp.StateError, err, lspClient, 0) } else { // Server reached a ready state scuccessfully. slog.Info("LSP server is ready", "name", name) lspClient.SetServerState(lsp.StateReady) + updateLSPState(name, lsp.StateReady, nil, lspClient, 0) } slog.Info("LSP client initialized", "name", name) diff --git a/internal/app/lsp_events.go b/internal/app/lsp_events.go new file mode 100644 index 0000000000000000000000000000000000000000..5961ec5c13e05fc42ff4eab7fbee744224a49694 --- /dev/null +++ b/internal/app/lsp_events.go @@ -0,0 +1,102 @@ +package app + +import ( + "context" + "time" + + "github.com/charmbracelet/crush/internal/csync" + "github.com/charmbracelet/crush/internal/lsp" + "github.com/charmbracelet/crush/internal/pubsub" +) + +// LSPEventType represents the type of LSP event +type LSPEventType string + +const ( + LSPEventStateChanged LSPEventType = "state_changed" + LSPEventDiagnosticsChanged LSPEventType = "diagnostics_changed" +) + +// LSPEvent represents an event in the LSP system +type LSPEvent struct { + Type LSPEventType + Name string + State lsp.ServerState + Error error + DiagnosticCount int +} + +// LSPClientInfo holds information about an LSP client's state +type LSPClientInfo struct { + Name string + State lsp.ServerState + Error error + Client *lsp.Client + DiagnosticCount int + ConnectedAt time.Time +} + +var ( + lspStates = csync.NewMap[string, LSPClientInfo]() + lspBroker = pubsub.NewBroker[LSPEvent]() +) + +// SubscribeLSPEvents returns a channel for LSP events +func SubscribeLSPEvents(ctx context.Context) <-chan pubsub.Event[LSPEvent] { + return lspBroker.Subscribe(ctx) +} + +// GetLSPStates returns the current state of all LSP clients +func GetLSPStates() map[string]LSPClientInfo { + states := make(map[string]LSPClientInfo) + for name, info := range lspStates.Seq2() { + states[name] = info + } + return states +} + +// GetLSPState returns the state of a specific LSP client +func GetLSPState(name string) (LSPClientInfo, bool) { + return lspStates.Get(name) +} + +// updateLSPState updates the state of an LSP client and publishes an event +func updateLSPState(name string, state lsp.ServerState, err error, client *lsp.Client, diagnosticCount int) { + info := LSPClientInfo{ + Name: name, + State: state, + Error: err, + Client: client, + DiagnosticCount: diagnosticCount, + } + if state == lsp.StateReady { + info.ConnectedAt = time.Now() + } + lspStates.Set(name, info) + + // Publish state change event + lspBroker.Publish(pubsub.UpdatedEvent, LSPEvent{ + Type: LSPEventStateChanged, + Name: name, + State: state, + Error: err, + DiagnosticCount: diagnosticCount, + }) +} + +// updateLSPDiagnostics updates the diagnostic count for an LSP client and publishes an event +func updateLSPDiagnostics(name string, diagnosticCount int) { + if info, exists := lspStates.Get(name); exists { + info.DiagnosticCount = diagnosticCount + lspStates.Set(name, info) + + // Publish diagnostics change event + lspBroker.Publish(pubsub.UpdatedEvent, LSPEvent{ + Type: LSPEventDiagnosticsChanged, + Name: name, + State: info.State, + Error: info.Error, + DiagnosticCount: diagnosticCount, + }) + } +} diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index 80a7095ed381985b5155c8a29ac933156eb85ffd..57c30f41078f9a577d2f7bdbca5303da0eb83724 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -159,11 +159,12 @@ func NewAgent( if err != nil { return nil, err } + summarizeOpts := []provider.ProviderClientOption{ - provider.WithModel(config.SelectedModelTypeSmall), - provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, smallModelProviderCfg.ID)), + provider.WithModel(config.SelectedModelTypeLarge), + provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, providerCfg.ID)), } - summarizeProvider, err := provider.NewProvider(*smallModelProviderCfg, summarizeOpts...) + summarizeProvider, err := provider.NewProvider(*providerCfg, summarizeOpts...) if err != nil { return nil, err } @@ -224,7 +225,7 @@ func NewAgent( sessions: sessions, titleProvider: titleProvider, summarizeProvider: summarizeProvider, - summarizeProviderID: string(smallModelProviderCfg.ID), + summarizeProviderID: string(providerCfg.ID), activeRequests: csync.NewMap[string, context.CancelFunc](), tools: csync.NewLazySlice(toolFn), }, nil @@ -904,54 +905,59 @@ func (a *agent) UpdateModel() error { a.providerID = string(currentProviderCfg.ID) } - // Check if small model provider has changed (affects title and summarize providers) + // Check if providers have changed for title (small) and summarize (large) smallModelCfg := cfg.Models[config.SelectedModelTypeSmall] var smallModelProviderCfg config.ProviderConfig - for p := range cfg.Providers.Seq() { if p.ID == smallModelCfg.Provider { smallModelProviderCfg = p break } } - if smallModelProviderCfg.ID == "" { return fmt.Errorf("provider %s not found in config", smallModelCfg.Provider) } - // Check if summarize provider has changed - if string(smallModelProviderCfg.ID) != a.summarizeProviderID { - smallModel := cfg.GetModelByType(config.SelectedModelTypeSmall) - if smallModel == nil { - return fmt.Errorf("model %s not found in provider %s", smallModelCfg.Model, smallModelProviderCfg.ID) + largeModelCfg := cfg.Models[config.SelectedModelTypeLarge] + var largeModelProviderCfg config.ProviderConfig + for p := range cfg.Providers.Seq() { + if p.ID == largeModelCfg.Provider { + largeModelProviderCfg = p + break } + } + if largeModelProviderCfg.ID == "" { + return fmt.Errorf("provider %s not found in config", largeModelCfg.Provider) + } - // Recreate title provider - titleOpts := []provider.ProviderClientOption{ - provider.WithModel(config.SelectedModelTypeSmall), - provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptTitle, smallModelProviderCfg.ID)), - // We want the title to be short, so we limit the max tokens - provider.WithMaxTokens(40), - } - newTitleProvider, err := provider.NewProvider(smallModelProviderCfg, titleOpts...) - if err != nil { - return fmt.Errorf("failed to create new title provider: %w", err) - } + // Recreate title provider + titleOpts := []provider.ProviderClientOption{ + provider.WithModel(config.SelectedModelTypeSmall), + provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptTitle, smallModelProviderCfg.ID)), + provider.WithMaxTokens(40), + } + newTitleProvider, err := provider.NewProvider(smallModelProviderCfg, titleOpts...) + if err != nil { + return fmt.Errorf("failed to create new title provider: %w", err) + } + a.titleProvider = newTitleProvider - // Recreate summarize provider + // Recreate summarize provider if provider changed (now large model) + if string(largeModelProviderCfg.ID) != a.summarizeProviderID { + largeModel := cfg.GetModelByType(config.SelectedModelTypeLarge) + if largeModel == nil { + return fmt.Errorf("model %s not found in provider %s", largeModelCfg.Model, largeModelProviderCfg.ID) + } summarizeOpts := []provider.ProviderClientOption{ - provider.WithModel(config.SelectedModelTypeSmall), - provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, smallModelProviderCfg.ID)), + provider.WithModel(config.SelectedModelTypeLarge), + provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, largeModelProviderCfg.ID)), } - newSummarizeProvider, err := provider.NewProvider(smallModelProviderCfg, summarizeOpts...) + newSummarizeProvider, err := provider.NewProvider(largeModelProviderCfg, summarizeOpts...) if err != nil { return fmt.Errorf("failed to create new summarize provider: %w", err) } - - // Update the providers and provider ID - a.titleProvider = newTitleProvider a.summarizeProvider = newSummarizeProvider - a.summarizeProviderID = string(smallModelProviderCfg.ID) + a.summarizeProviderID = string(largeModelProviderCfg.ID) } return nil diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 8df78e52452385a44a06fd07feb62d3b9892388f..72b57ec07ff00ba94e188b9f00ed08698a1de028 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -7,21 +7,76 @@ import ( "log/slog" "slices" "sync" + "time" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/llm/tools" "github.com/charmbracelet/crush/internal/permission" + "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/version" "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/client/transport" "github.com/mark3labs/mcp-go/mcp" ) +// MCPState represents the current state of an MCP client +type MCPState int + +const ( + MCPStateDisabled MCPState = iota + MCPStateStarting + MCPStateConnected + MCPStateError +) + +func (s MCPState) String() string { + switch s { + case MCPStateDisabled: + return "disabled" + case MCPStateStarting: + return "starting" + case MCPStateConnected: + return "connected" + case MCPStateError: + return "error" + default: + return "unknown" + } +} + +// MCPEventType represents the type of MCP event +type MCPEventType string + +const ( + MCPEventStateChanged MCPEventType = "state_changed" +) + +// MCPEvent represents an event in the MCP system +type MCPEvent struct { + Type MCPEventType + Name string + State MCPState + Error error + ToolCount int +} + +// MCPClientInfo holds information about an MCP client's state +type MCPClientInfo struct { + Name string + State MCPState + Error error + Client *client.Client + ToolCount int + ConnectedAt time.Time +} + var ( mcpToolsOnce sync.Once mcpTools []tools.BaseTool mcpClients = csync.NewMap[string, *client.Client]() + mcpStates = csync.NewMap[string, MCPClientInfo]() + mcpBroker = pubsub.NewBroker[MCPEvent]() ) type McpTool struct { @@ -107,6 +162,7 @@ func getTools(ctx context.Context, name string, permissions permission.Service, result, err := c.ListTools(ctx, mcp.ListToolsRequest{}) if err != nil { slog.Error("error listing tools", "error", err) + updateMCPState(name, MCPStateError, err, nil, 0) c.Close() mcpClients.Del(name) return nil @@ -123,11 +179,55 @@ func getTools(ctx context.Context, name string, permissions permission.Service, return mcpTools } +// SubscribeMCPEvents returns a channel for MCP events +func SubscribeMCPEvents(ctx context.Context) <-chan pubsub.Event[MCPEvent] { + return mcpBroker.Subscribe(ctx) +} + +// GetMCPStates returns the current state of all MCP clients +func GetMCPStates() map[string]MCPClientInfo { + states := make(map[string]MCPClientInfo) + for name, info := range mcpStates.Seq2() { + states[name] = info + } + return states +} + +// GetMCPState returns the state of a specific MCP client +func GetMCPState(name string) (MCPClientInfo, bool) { + return mcpStates.Get(name) +} + +// updateMCPState updates the state of an MCP client and publishes an event +func updateMCPState(name string, state MCPState, err error, client *client.Client, toolCount int) { + info := MCPClientInfo{ + Name: name, + State: state, + Error: err, + Client: client, + ToolCount: toolCount, + } + if state == MCPStateConnected { + info.ConnectedAt = time.Now() + } + mcpStates.Set(name, info) + + // Publish state change event + mcpBroker.Publish(pubsub.UpdatedEvent, MCPEvent{ + Type: MCPEventStateChanged, + Name: name, + State: state, + Error: err, + ToolCount: toolCount, + }) +} + // CloseMCPClients closes all MCP clients. This should be called during application shutdown. func CloseMCPClients() { for c := range mcpClients.Seq() { _ = c.Close() } + mcpBroker.Shutdown() } var mcpInitRequest = mcp.InitializeRequest{ @@ -143,25 +243,51 @@ var mcpInitRequest = mcp.InitializeRequest{ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) []tools.BaseTool { var wg sync.WaitGroup result := csync.NewSlice[tools.BaseTool]() + + // Initialize states for all configured MCPs for name, m := range cfg.MCP { if m.Disabled { + updateMCPState(name, MCPStateDisabled, nil, nil, 0) slog.Debug("skipping disabled mcp", "name", name) continue } + + // Set initial starting state + updateMCPState(name, MCPStateStarting, nil, nil, 0) + wg.Add(1) go func(name string, m config.MCPConfig) { - defer wg.Done() + defer func() { + wg.Done() + if r := recover(); r != nil { + var err error + switch v := r.(type) { + case error: + err = v + case string: + err = fmt.Errorf("panic: %s", v) + default: + err = fmt.Errorf("panic: %v", v) + } + updateMCPState(name, MCPStateError, err, nil, 0) + slog.Error("panic in mcp client initialization", "error", err, "name", name) + } + }() + c, err := createMcpClient(m) if err != nil { + updateMCPState(name, MCPStateError, err, nil, 0) slog.Error("error creating mcp client", "error", err, "name", name) return } if err := c.Start(ctx); err != nil { + updateMCPState(name, MCPStateError, err, nil, 0) slog.Error("error starting mcp client", "error", err, "name", name) _ = c.Close() return } if _, err := c.Initialize(ctx, mcpInitRequest); err != nil { + updateMCPState(name, MCPStateError, err, nil, 0) slog.Error("error initializing mcp client", "error", err, "name", name) _ = c.Close() return @@ -170,7 +296,9 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con slog.Info("Initialized mcp client", "name", name) mcpClients.Set(name, c) - result.Append(getTools(ctx, name, permissions, c, cfg.WorkingDir())...) + tools := getTools(ctx, name, permissions, c, cfg.WorkingDir()) + updateMCPState(name, MCPStateConnected, nil, c, len(tools)) + result.Append(tools...) }(name, m) } wg.Wait() diff --git a/internal/llm/tools/tools.go b/internal/llm/tools/tools.go index 41c0515616032b117f3c09a0056cac9e86b62c66..d8eb9b30c10378c06700d82a584eab19294f99ae 100644 --- a/internal/llm/tools/tools.go +++ b/internal/llm/tools/tools.go @@ -3,6 +3,8 @@ package tools import ( "context" "encoding/json" + "fmt" + "strings" ) type ToolInfo struct { @@ -25,6 +27,10 @@ const ( SessionIDContextKey sessionIDContextKey = "session_id" MessageIDContextKey messageIDContextKey = "message_id" + + maxResponseWidth = 3000 + maxResponseHeight = 5000 + maxResponseChars = 50000 ) type ToolResponse struct { @@ -37,10 +43,77 @@ type ToolResponse struct { func NewTextResponse(content string) ToolResponse { return ToolResponse{ Type: ToolResponseTypeText, - Content: content, + Content: truncateContent(content), } } +func truncateContent(content string) string { + if len(content) <= maxResponseChars { + return truncateWidthAndHeight(content) + } + + truncated := content[:maxResponseChars] + + if lastNewline := strings.LastIndex(truncated, "\n"); lastNewline > maxResponseChars/2 { + truncated = truncated[:lastNewline] + } + + truncated += "\n\n... [Content truncated due to length] ..." + + return truncateWidthAndHeight(truncated) +} + +func truncateWidthAndHeight(content string) string { + lines := strings.Split(content, "\n") + + heightTruncated := false + if len(lines) > maxResponseHeight { + keepLines := maxResponseHeight - 3 + firstHalf := keepLines / 2 + secondHalf := keepLines - firstHalf + + truncatedLines := make([]string, 0, maxResponseHeight) + truncatedLines = append(truncatedLines, lines[:firstHalf]...) + truncatedLines = append(truncatedLines, "") + truncatedLines = append(truncatedLines, fmt.Sprintf("... [%d lines truncated] ...", len(lines)-keepLines)) + truncatedLines = append(truncatedLines, "") + truncatedLines = append(truncatedLines, lines[len(lines)-secondHalf:]...) + + lines = truncatedLines + heightTruncated = true + } + + widthTruncated := false + for i, line := range lines { + if len(line) > maxResponseWidth { + if maxResponseWidth > 20 { + keepChars := maxResponseWidth - 10 + firstHalf := keepChars / 2 + secondHalf := keepChars - firstHalf + lines[i] = line[:firstHalf] + " ... " + line[len(line)-secondHalf:] + } else { + lines[i] = line[:maxResponseWidth] + } + widthTruncated = true + } + } + + result := strings.Join(lines, "\n") + + if heightTruncated || widthTruncated { + notices := make([]string, 0, 2) + if heightTruncated { + notices = append(notices, "height") + } + if widthTruncated { + notices = append(notices, "width") + } + result += fmt.Sprintf("\n\n[Note: Content truncated by %s to fit response limits]", strings.Join(notices, " and ")) + } + + return result +} + func WithResponseMetadata(response ToolResponse, metadata any) ToolResponse { if metadata != nil { metadataBytes, err := json.Marshal(metadata) diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 219a5df5fb87197f0490f218cddc24ab3b138371..279ec1feb80b79ef093fc8d1395022d4949756d7 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -26,6 +26,12 @@ type Client struct { stdout *bufio.Reader stderr io.ReadCloser + // Client name for identification + name string + + // Diagnostic change callback + onDiagnosticsChanged func(name string, count int) + // Request ID counter nextID atomic.Int32 @@ -53,7 +59,7 @@ type Client struct { serverState atomic.Value } -func NewClient(ctx context.Context, command string, args ...string) (*Client, error) { +func NewClient(ctx context.Context, name, command string, args ...string) (*Client, error) { cmd := exec.CommandContext(ctx, command, args...) // Copy env cmd.Env = os.Environ() @@ -75,6 +81,7 @@ func NewClient(ctx context.Context, command string, args ...string) (*Client, er client := &Client{ Cmd: cmd, + name: name, stdin: stdin, stdout: bufio.NewReader(stdout), stderr: stderr, @@ -284,6 +291,16 @@ func (c *Client) SetServerState(state ServerState) { c.serverState.Store(state) } +// GetName returns the name of the LSP client +func (c *Client) GetName() string { + return c.name +} + +// SetDiagnosticsCallback sets the callback function for diagnostic changes +func (c *Client) SetDiagnosticsCallback(callback func(name string, count int)) { + c.onDiagnosticsChanged = callback +} + // WaitForServerReady waits for the server to be ready by polling the server // with a simple request until it responds successfully or times out func (c *Client) WaitForServerReady(ctx context.Context) error { diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go index 725d3c3c77ffba465b3e644a9948a1ce56c3eeaa..72f3018b3da969000672e5b4ba47f73f2b72df97 100644 --- a/internal/lsp/handlers.go +++ b/internal/lsp/handlers.go @@ -103,7 +103,17 @@ func HandleDiagnostics(client *Client, params json.RawMessage) { } client.diagnosticsMu.Lock() - defer client.diagnosticsMu.Unlock() - client.diagnostics[diagParams.URI] = diagParams.Diagnostics + + // Calculate total diagnostic count + totalCount := 0 + for _, diagnostics := range client.diagnostics { + totalCount += len(diagnostics) + } + client.diagnosticsMu.Unlock() + + // Trigger callback if set + if client.onDiagnosticsChanged != nil { + client.onDiagnosticsChanged(client.name, totalCount) + } } diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go index 1f5fd2a672e3d643efbed4ca35b08ed88c55d2eb..edec996e32558fadb6112ef9781a26413182a06a 100644 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ b/internal/tui/components/chat/sidebar/sidebar.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "slices" - "sort" "strings" tea "github.com/charmbracelet/bubbletea/v2" @@ -13,21 +12,21 @@ import ( "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/diff" - "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/history" "github.com/charmbracelet/crush/internal/lsp" - "github.com/charmbracelet/crush/internal/lsp/protocol" "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/tui/components/chat" "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/components/core/layout" + "github.com/charmbracelet/crush/internal/tui/components/files" "github.com/charmbracelet/crush/internal/tui/components/logo" + lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp" + "github.com/charmbracelet/crush/internal/tui/components/mcp" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/crush/internal/version" "github.com/charmbracelet/lipgloss/v2" - "github.com/charmbracelet/x/ansi" "golang.org/x/text/cases" "golang.org/x/text/language" ) @@ -382,459 +381,125 @@ func (m *sidebarCmp) renderSectionsHorizontal() string { // filesBlockCompact renders the files block with limited width and height for horizontal layout func (m *sidebarCmp) filesBlockCompact(maxWidth int) string { - t := styles.CurrentTheme() - - section := t.S().Subtle.Render("Modified Files") - - files := slices.Collect(m.files.Seq()) - - if len(files) == 0 { - content := lipgloss.JoinVertical( - lipgloss.Left, - section, - "", - t.S().Base.Foreground(t.Border).Render("None"), - ) - return lipgloss.NewStyle().Width(maxWidth).Render(content) + // Convert map to slice and handle type conversion + sessionFiles := slices.Collect(m.files.Seq()) + fileSlice := make([]files.SessionFile, len(sessionFiles)) + for i, sf := range sessionFiles { + fileSlice[i] = files.SessionFile{ + History: files.FileHistory{ + InitialVersion: sf.History.initialVersion, + LatestVersion: sf.History.latestVersion, + }, + FilePath: sf.FilePath, + Additions: sf.Additions, + Deletions: sf.Deletions, + } } - fileList := []string{section, ""} - sort.Slice(files, func(i, j int) bool { - return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt - }) - - // Limit items for horizontal layout - use less space - maxItems := min(5, len(files)) + // Limit items for horizontal layout + maxItems := min(5, len(fileSlice)) availableHeight := m.height - 8 // Reserve space for header and other content if availableHeight > 0 { maxItems = min(maxItems, availableHeight) } - filesShown := 0 - for _, file := range files { - if file.Additions == 0 && file.Deletions == 0 { - continue - } - if filesShown >= maxItems { - break - } - - var statusParts []string - if file.Additions > 0 { - statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions))) - } - if file.Deletions > 0 { - statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions))) - } - - extraContent := strings.Join(statusParts, " ") - cwd := config.Get().WorkingDir() + string(os.PathSeparator) - filePath := file.FilePath - filePath = strings.TrimPrefix(filePath, cwd) - filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2) - filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…") - - fileList = append(fileList, - core.Status( - core.StatusOpts{ - IconColor: t.FgMuted, - NoIcon: true, - Title: filePath, - ExtraContent: extraContent, - }, - maxWidth, - ), - ) - filesShown++ - } - - // Add "..." indicator if there are more files - totalFilesWithChanges := 0 - for _, file := range files { - if file.Additions > 0 || file.Deletions > 0 { - totalFilesWithChanges++ - } - } - if totalFilesWithChanges > maxItems { - fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("…")) - } - - content := lipgloss.JoinVertical(lipgloss.Left, fileList...) - return lipgloss.NewStyle().Width(maxWidth).Render(content) + return files.RenderFileBlock(fileSlice, files.RenderOptions{ + MaxWidth: maxWidth, + MaxItems: maxItems, + ShowSection: true, + SectionName: "Modified Files", + }, true) } // lspBlockCompact renders the LSP block with limited width and height for horizontal layout func (m *sidebarCmp) lspBlockCompact(maxWidth int) string { - t := styles.CurrentTheme() - - section := t.S().Subtle.Render("LSPs") - - lspList := []string{section, ""} - - lsp := config.Get().LSP.Sorted() - if len(lsp) == 0 { - content := lipgloss.JoinVertical( - lipgloss.Left, - section, - "", - t.S().Base.Foreground(t.Border).Render("None"), - ) - return lipgloss.NewStyle().Width(maxWidth).Render(content) - } - // Limit items for horizontal layout - maxItems := min(5, len(lsp)) + lspConfigs := config.Get().LSP.Sorted() + maxItems := min(5, len(lspConfigs)) availableHeight := m.height - 8 if availableHeight > 0 { maxItems = min(maxItems, availableHeight) } - for i, l := range lsp { - if i >= maxItems { - break - } - - iconColor := t.Success - if l.LSP.Disabled { - iconColor = t.FgMuted - } - - lspErrs := map[protocol.DiagnosticSeverity]int{ - protocol.SeverityError: 0, - protocol.SeverityWarning: 0, - protocol.SeverityHint: 0, - protocol.SeverityInformation: 0, - } - if client, ok := m.lspClients[l.Name]; ok { - for _, diagnostics := range client.GetDiagnostics() { - for _, diagnostic := range diagnostics { - if severity, ok := lspErrs[diagnostic.Severity]; ok { - lspErrs[diagnostic.Severity] = severity + 1 - } - } - } - } - - errs := []string{} - if lspErrs[protocol.SeverityError] > 0 { - errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError]))) - } - if lspErrs[protocol.SeverityWarning] > 0 { - errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning]))) - } - if lspErrs[protocol.SeverityHint] > 0 { - errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint]))) - } - if lspErrs[protocol.SeverityInformation] > 0 { - errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation]))) - } - - lspList = append(lspList, - core.Status( - core.StatusOpts{ - IconColor: iconColor, - Title: l.Name, - Description: l.LSP.Command, - ExtraContent: strings.Join(errs, " "), - }, - maxWidth, - ), - ) - } - - // Add "..." indicator if there are more LSPs - if len(lsp) > maxItems { - lspList = append(lspList, t.S().Base.Foreground(t.FgMuted).Render("…")) - } - - content := lipgloss.JoinVertical(lipgloss.Left, lspList...) - return lipgloss.NewStyle().Width(maxWidth).Render(content) + return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{ + MaxWidth: maxWidth, + MaxItems: maxItems, + ShowSection: true, + SectionName: "LSPs", + }, true) } // mcpBlockCompact renders the MCP block with limited width and height for horizontal layout func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string { - t := styles.CurrentTheme() - - section := t.S().Subtle.Render("MCPs") - - mcpList := []string{section, ""} - - mcps := config.Get().MCP.Sorted() - if len(mcps) == 0 { - content := lipgloss.JoinVertical( - lipgloss.Left, - section, - "", - t.S().Base.Foreground(t.Border).Render("None"), - ) - return lipgloss.NewStyle().Width(maxWidth).Render(content) - } - // Limit items for horizontal layout - maxItems := min(5, len(mcps)) + maxItems := min(5, len(config.Get().MCP.Sorted())) availableHeight := m.height - 8 if availableHeight > 0 { maxItems = min(maxItems, availableHeight) } - for i, l := range mcps { - if i >= maxItems { - break - } - - iconColor := t.Success - if l.MCP.Disabled { - iconColor = t.FgMuted - } - - mcpList = append(mcpList, - core.Status( - core.StatusOpts{ - IconColor: iconColor, - Title: l.Name, - Description: l.MCP.Command, - }, - maxWidth, - ), - ) - } - - // Add "..." indicator if there are more MCPs - if len(mcps) > maxItems { - mcpList = append(mcpList, t.S().Base.Foreground(t.FgMuted).Render("…")) - } - - content := lipgloss.JoinVertical(lipgloss.Left, mcpList...) - return lipgloss.NewStyle().Width(maxWidth).Render(content) + return mcp.RenderMCPBlock(mcp.RenderOptions{ + MaxWidth: maxWidth, + MaxItems: maxItems, + ShowSection: true, + SectionName: "MCPs", + }, true) } func (m *sidebarCmp) filesBlock() string { - t := styles.CurrentTheme() - - section := t.S().Subtle.Render( - core.Section("Modified Files", m.getMaxWidth()), - ) - - files := slices.Collect(m.files.Seq()) - if len(files) == 0 { - return lipgloss.JoinVertical( - lipgloss.Left, - section, - "", - t.S().Base.Foreground(t.Border).Render("None"), - ) + // Convert map to slice and handle type conversion + sessionFiles := slices.Collect(m.files.Seq()) + fileSlice := make([]files.SessionFile, len(sessionFiles)) + for i, sf := range sessionFiles { + fileSlice[i] = files.SessionFile{ + History: files.FileHistory{ + InitialVersion: sf.History.initialVersion, + LatestVersion: sf.History.latestVersion, + }, + FilePath: sf.FilePath, + Additions: sf.Additions, + Deletions: sf.Deletions, + } } - fileList := []string{section, ""} - // order files by the latest version's created time - sort.Slice(files, func(i, j int) bool { - return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt - }) - // Limit the number of files shown maxFiles, _, _ := m.getDynamicLimits() - maxFiles = min(len(files), maxFiles) - filesShown := 0 - - for _, file := range files { - if file.Additions == 0 && file.Deletions == 0 { - continue // skip files with no changes - } - if filesShown >= maxFiles { - break - } - - var statusParts []string - if file.Additions > 0 { - statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions))) - } - if file.Deletions > 0 { - statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions))) - } - - extraContent := strings.Join(statusParts, " ") - cwd := config.Get().WorkingDir() + string(os.PathSeparator) - filePath := file.FilePath - filePath = strings.TrimPrefix(filePath, cwd) - filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2) - filePath = ansi.Truncate(filePath, m.getMaxWidth()-lipgloss.Width(extraContent)-2, "…") - fileList = append(fileList, - core.Status( - core.StatusOpts{ - IconColor: t.FgMuted, - NoIcon: true, - Title: filePath, - ExtraContent: extraContent, - }, - m.getMaxWidth(), - ), - ) - filesShown++ - } - - // Add indicator if there are more files - totalFilesWithChanges := 0 - for _, file := range files { - if file.Additions > 0 || file.Deletions > 0 { - totalFilesWithChanges++ - } - } - if totalFilesWithChanges > maxFiles { - remaining := totalFilesWithChanges - maxFiles - fileList = append(fileList, - t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)), - ) - } - - return lipgloss.JoinVertical( - lipgloss.Left, - fileList..., - ) + maxFiles = min(len(fileSlice), maxFiles) + + return files.RenderFileBlock(fileSlice, files.RenderOptions{ + MaxWidth: m.getMaxWidth(), + MaxItems: maxFiles, + ShowSection: true, + SectionName: core.Section("Modified Files", m.getMaxWidth()), + }, true) } func (m *sidebarCmp) lspBlock() string { - t := styles.CurrentTheme() - - section := t.S().Subtle.Render( - core.Section("LSPs", m.getMaxWidth()), - ) - - lspList := []string{section, ""} - - lsp := config.Get().LSP.Sorted() - if len(lsp) == 0 { - return lipgloss.JoinVertical( - lipgloss.Left, - section, - "", - t.S().Base.Foreground(t.Border).Render("None"), - ) - } - // Limit the number of LSPs shown _, maxLSPs, _ := m.getDynamicLimits() - maxLSPs = min(len(lsp), maxLSPs) - for i, l := range lsp { - if i >= maxLSPs { - break - } - - iconColor := t.Success - if l.LSP.Disabled { - iconColor = t.FgMuted - } - lspErrs := map[protocol.DiagnosticSeverity]int{ - protocol.SeverityError: 0, - protocol.SeverityWarning: 0, - protocol.SeverityHint: 0, - protocol.SeverityInformation: 0, - } - if client, ok := m.lspClients[l.Name]; ok { - for _, diagnostics := range client.GetDiagnostics() { - for _, diagnostic := range diagnostics { - if severity, ok := lspErrs[diagnostic.Severity]; ok { - lspErrs[diagnostic.Severity] = severity + 1 - } - } - } - } - - errs := []string{} - if lspErrs[protocol.SeverityError] > 0 { - errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError]))) - } - if lspErrs[protocol.SeverityWarning] > 0 { - errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning]))) - } - if lspErrs[protocol.SeverityHint] > 0 { - errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint]))) - } - if lspErrs[protocol.SeverityInformation] > 0 { - errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation]))) - } - - lspList = append(lspList, - core.Status( - core.StatusOpts{ - IconColor: iconColor, - Title: l.Name, - Description: l.LSP.Command, - ExtraContent: strings.Join(errs, " "), - }, - m.getMaxWidth(), - ), - ) - } - - // Add indicator if there are more LSPs - if len(lsp) > maxLSPs { - remaining := len(lsp) - maxLSPs - lspList = append(lspList, - t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)), - ) - } - - return lipgloss.JoinVertical( - lipgloss.Left, - lspList..., - ) + lspConfigs := config.Get().LSP.Sorted() + maxLSPs = min(len(lspConfigs), maxLSPs) + + return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{ + MaxWidth: m.getMaxWidth(), + MaxItems: maxLSPs, + ShowSection: true, + SectionName: core.Section("LSPs", m.getMaxWidth()), + }, true) } func (m *sidebarCmp) mcpBlock() string { - t := styles.CurrentTheme() - - section := t.S().Subtle.Render( - core.Section("MCPs", m.getMaxWidth()), - ) - - mcpList := []string{section, ""} - - mcps := config.Get().MCP.Sorted() - if len(mcps) == 0 { - return lipgloss.JoinVertical( - lipgloss.Left, - section, - "", - t.S().Base.Foreground(t.Border).Render("None"), - ) - } - // Limit the number of MCPs shown _, _, maxMCPs := m.getDynamicLimits() + mcps := config.Get().MCP.Sorted() maxMCPs = min(len(mcps), maxMCPs) - for i, l := range mcps { - if i >= maxMCPs { - break - } - - iconColor := t.Success - if l.MCP.Disabled { - iconColor = t.FgMuted - } - mcpList = append(mcpList, - core.Status( - core.StatusOpts{ - IconColor: iconColor, - Title: l.Name, - Description: l.MCP.Command, - }, - m.getMaxWidth(), - ), - ) - } - - // Add indicator if there are more MCPs - if len(mcps) > maxMCPs { - remaining := len(mcps) - maxMCPs - mcpList = append(mcpList, - t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)), - ) - } - return lipgloss.JoinVertical( - lipgloss.Left, - mcpList..., - ) + return mcp.RenderMCPBlock(mcp.RenderOptions{ + MaxWidth: m.getMaxWidth(), + MaxItems: maxMCPs, + ShowSection: true, + SectionName: core.Section("MCPs", m.getMaxWidth()), + }, true) } func formatTokensAndCost(tokens, contextWindow int64, cost float64) string { diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index b0681c798631ee8cf3960fda26bfc2416aff2692..bc38bcbc615164e703dad55d66f3b77911ce267b 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -17,6 +17,8 @@ import ( "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/components/dialogs/models" "github.com/charmbracelet/crush/internal/tui/components/logo" + lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp" + "github.com/charmbracelet/crush/internal/tui/components/mcp" "github.com/charmbracelet/crush/internal/tui/exp/list" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" @@ -633,7 +635,7 @@ func (s *splashCmp) Bindings() []key.Binding { } func (s *splashCmp) getMaxInfoWidth() int { - return min(s.width-2, 40) // 2 for left padding + return min(s.width-2, 90) // 2 for left padding } func (s *splashCmp) cwd() string { @@ -648,29 +650,10 @@ func (s *splashCmp) cwd() string { } func LSPList(maxWidth int) []string { - t := styles.CurrentTheme() - lspList := []string{} - lsp := config.Get().LSP.Sorted() - if len(lsp) == 0 { - return []string{t.S().Base.Foreground(t.Border).Render("None")} - } - for _, l := range lsp { - iconColor := t.Success - if l.LSP.Disabled { - iconColor = t.FgMuted - } - lspList = append(lspList, - core.Status( - core.StatusOpts{ - IconColor: iconColor, - Title: l.Name, - Description: l.LSP.Command, - }, - maxWidth, - ), - ) - } - return lspList + return lspcomponent.RenderLSPList(nil, lspcomponent.RenderOptions{ + MaxWidth: maxWidth, + ShowSection: false, + }) } func (s *splashCmp) lspBlock() string { @@ -687,29 +670,10 @@ func (s *splashCmp) lspBlock() string { } func MCPList(maxWidth int) []string { - t := styles.CurrentTheme() - mcpList := []string{} - mcps := config.Get().MCP.Sorted() - if len(mcps) == 0 { - return []string{t.S().Base.Foreground(t.Border).Render("None")} - } - for _, l := range mcps { - iconColor := t.Success - if l.MCP.Disabled { - iconColor = t.FgMuted - } - mcpList = append(mcpList, - core.Status( - core.StatusOpts{ - IconColor: iconColor, - Title: l.Name, - Description: l.MCP.Command, - }, - maxWidth, - ), - ) - } - return mcpList + return mcp.RenderMCPList(mcp.RenderOptions{ + MaxWidth: maxWidth, + ShowSection: false, + }) } func (s *splashCmp) mcpBlock() string { diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go index 6c292ce7fd16eb671abc02bf577c6fc420dbd283..139ec1ea5ac0461b0c4fa8de65c61c7293b8ac50 100644 --- a/internal/tui/components/dialogs/commands/commands.go +++ b/internal/tui/components/dialogs/commands/commands.go @@ -119,7 +119,10 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: c.wWidth = msg.Width c.wHeight = msg.Height - return c, c.commandList.SetSize(c.listWidth(), c.listHeight()) + return c, tea.Batch( + c.SetCommandType(c.commandType), + c.commandList.SetSize(c.listWidth(), c.listHeight()), + ) case tea.KeyPressMsg: switch { case key.Matches(msg, c.keyMap.Select): @@ -318,7 +321,6 @@ func (c *commandDialogCmp) defaultCommands() []Command { }) } } - // Only show toggle compact mode command if window width is larger than compact breakpoint (90) if c.wWidth > 120 && c.sessionID != "" { commands = append(commands, Command{ diff --git a/internal/tui/components/files/files.go b/internal/tui/components/files/files.go new file mode 100644 index 0000000000000000000000000000000000000000..234a75fd4e06431018eab1fbf37e90e562da4083 --- /dev/null +++ b/internal/tui/components/files/files.go @@ -0,0 +1,145 @@ +package files + +import ( + "fmt" + "os" + "sort" + "strings" + + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/history" + "github.com/charmbracelet/crush/internal/tui/components/core" + "github.com/charmbracelet/crush/internal/tui/styles" +) + +// FileHistory represents a file history with initial and latest versions. +type FileHistory struct { + InitialVersion history.File + LatestVersion history.File +} + +// SessionFile represents a file with its history information. +type SessionFile struct { + History FileHistory + FilePath string + Additions int + Deletions int +} + +// RenderOptions contains options for rendering file lists. +type RenderOptions struct { + MaxWidth int + MaxItems int + ShowSection bool + SectionName string +} + +// RenderFileList renders a list of file status items with the given options. +func RenderFileList(fileSlice []SessionFile, opts RenderOptions) []string { + t := styles.CurrentTheme() + fileList := []string{} + + if opts.ShowSection { + sectionName := opts.SectionName + if sectionName == "" { + sectionName = "Modified Files" + } + section := t.S().Subtle.Render(sectionName) + fileList = append(fileList, section, "") + } + + if len(fileSlice) == 0 { + fileList = append(fileList, t.S().Base.Foreground(t.Border).Render("None")) + return fileList + } + + // Sort files by the latest version's created time + sort.Slice(fileSlice, func(i, j int) bool { + if fileSlice[i].History.LatestVersion.CreatedAt == fileSlice[j].History.LatestVersion.CreatedAt { + return strings.Compare(fileSlice[i].FilePath, fileSlice[j].FilePath) < 0 + } + return fileSlice[i].History.LatestVersion.CreatedAt > fileSlice[j].History.LatestVersion.CreatedAt + }) + + // Determine how many items to show + maxItems := len(fileSlice) + if opts.MaxItems > 0 { + maxItems = min(opts.MaxItems, len(fileSlice)) + } + + filesShown := 0 + for _, file := range fileSlice { + if file.Additions == 0 && file.Deletions == 0 { + continue // skip files with no changes + } + if filesShown >= maxItems { + break + } + + var statusParts []string + if file.Additions > 0 { + statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions))) + } + if file.Deletions > 0 { + statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions))) + } + + extraContent := strings.Join(statusParts, " ") + cwd := config.Get().WorkingDir() + string(os.PathSeparator) + filePath := file.FilePath + filePath = strings.TrimPrefix(filePath, cwd) + filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2) + filePath = ansi.Truncate(filePath, opts.MaxWidth-lipgloss.Width(extraContent)-2, "…") + + fileList = append(fileList, + core.Status( + core.StatusOpts{ + IconColor: t.FgMuted, + NoIcon: true, + Title: filePath, + ExtraContent: extraContent, + }, + opts.MaxWidth, + ), + ) + filesShown++ + } + + return fileList +} + +// RenderFileBlock renders a complete file block with optional truncation indicator. +func RenderFileBlock(fileSlice []SessionFile, opts RenderOptions, showTruncationIndicator bool) string { + t := styles.CurrentTheme() + fileList := RenderFileList(fileSlice, opts) + + // Add truncation indicator if needed + if showTruncationIndicator && opts.MaxItems > 0 { + totalFilesWithChanges := 0 + for _, file := range fileSlice { + if file.Additions > 0 || file.Deletions > 0 { + totalFilesWithChanges++ + } + } + if totalFilesWithChanges > opts.MaxItems { + remaining := totalFilesWithChanges - opts.MaxItems + if remaining == 1 { + fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("…")) + } else { + fileList = append(fileList, + t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)), + ) + } + } + } + + content := lipgloss.JoinVertical(lipgloss.Left, fileList...) + if opts.MaxWidth > 0 { + return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content) + } + return content +} diff --git a/internal/tui/components/lsp/lsp.go b/internal/tui/components/lsp/lsp.go new file mode 100644 index 0000000000000000000000000000000000000000..10d9f42198a6996e966d01305131e734fa54a614 --- /dev/null +++ b/internal/tui/components/lsp/lsp.go @@ -0,0 +1,160 @@ +package lsp + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss/v2" + + "github.com/charmbracelet/crush/internal/app" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/lsp" + "github.com/charmbracelet/crush/internal/lsp/protocol" + "github.com/charmbracelet/crush/internal/tui/components/core" + "github.com/charmbracelet/crush/internal/tui/styles" +) + +// RenderOptions contains options for rendering LSP lists. +type RenderOptions struct { + MaxWidth int + MaxItems int + ShowSection bool + SectionName string +} + +// RenderLSPList renders a list of LSP status items with the given options. +func RenderLSPList(lspClients map[string]*lsp.Client, opts RenderOptions) []string { + t := styles.CurrentTheme() + lspList := []string{} + + if opts.ShowSection { + sectionName := opts.SectionName + if sectionName == "" { + sectionName = "LSPs" + } + section := t.S().Subtle.Render(sectionName) + lspList = append(lspList, section, "") + } + + lspConfigs := config.Get().LSP.Sorted() + if len(lspConfigs) == 0 { + lspList = append(lspList, t.S().Base.Foreground(t.Border).Render("None")) + return lspList + } + + // Get LSP states + lspStates := app.GetLSPStates() + + // Determine how many items to show + maxItems := len(lspConfigs) + if opts.MaxItems > 0 { + maxItems = min(opts.MaxItems, len(lspConfigs)) + } + + for i, l := range lspConfigs { + if i >= maxItems { + break + } + + // Determine icon color and description based on state + iconColor := t.FgMuted + description := l.LSP.Command + + if l.LSP.Disabled { + iconColor = t.FgMuted + description = t.S().Subtle.Render("disabled") + } else if state, exists := lspStates[l.Name]; exists { + switch state.State { + case lsp.StateStarting: + iconColor = t.Yellow + description = t.S().Subtle.Render("starting...") + case lsp.StateReady: + iconColor = t.Success + description = l.LSP.Command + case lsp.StateError: + iconColor = t.Red + if state.Error != nil { + description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error())) + } else { + description = t.S().Subtle.Render("error") + } + } + } + + // Calculate diagnostic counts if we have LSP clients + var extraContent string + if lspClients != nil { + lspErrs := map[protocol.DiagnosticSeverity]int{ + protocol.SeverityError: 0, + protocol.SeverityWarning: 0, + protocol.SeverityHint: 0, + protocol.SeverityInformation: 0, + } + if client, ok := lspClients[l.Name]; ok { + for _, diagnostics := range client.GetDiagnostics() { + for _, diagnostic := range diagnostics { + if severity, ok := lspErrs[diagnostic.Severity]; ok { + lspErrs[diagnostic.Severity] = severity + 1 + } + } + } + } + + errs := []string{} + if lspErrs[protocol.SeverityError] > 0 { + errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError]))) + } + if lspErrs[protocol.SeverityWarning] > 0 { + errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning]))) + } + if lspErrs[protocol.SeverityHint] > 0 { + errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint]))) + } + if lspErrs[protocol.SeverityInformation] > 0 { + errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation]))) + } + extraContent = strings.Join(errs, " ") + } + + lspList = append(lspList, + core.Status( + core.StatusOpts{ + IconColor: iconColor, + Title: l.Name, + Description: description, + ExtraContent: extraContent, + }, + opts.MaxWidth, + ), + ) + } + + return lspList +} + +// RenderLSPBlock renders a complete LSP block with optional truncation indicator. +func RenderLSPBlock(lspClients map[string]*lsp.Client, opts RenderOptions, showTruncationIndicator bool) string { + t := styles.CurrentTheme() + lspList := RenderLSPList(lspClients, opts) + + // Add truncation indicator if needed + if showTruncationIndicator && opts.MaxItems > 0 { + lspConfigs := config.Get().LSP.Sorted() + if len(lspConfigs) > opts.MaxItems { + remaining := len(lspConfigs) - opts.MaxItems + if remaining == 1 { + lspList = append(lspList, t.S().Base.Foreground(t.FgMuted).Render("…")) + } else { + lspList = append(lspList, + t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)), + ) + } + } + } + + content := lipgloss.JoinVertical(lipgloss.Left, lspList...) + if opts.MaxWidth > 0 { + return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content) + } + return content +} diff --git a/internal/tui/components/mcp/mcp.go b/internal/tui/components/mcp/mcp.go new file mode 100644 index 0000000000000000000000000000000000000000..93f2dcb230721ab95c3ea2f4937647ff7ccf5bda --- /dev/null +++ b/internal/tui/components/mcp/mcp.go @@ -0,0 +1,128 @@ +package mcp + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss/v2" + + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/llm/agent" + "github.com/charmbracelet/crush/internal/tui/components/core" + "github.com/charmbracelet/crush/internal/tui/styles" +) + +// RenderOptions contains options for rendering MCP lists. +type RenderOptions struct { + MaxWidth int + MaxItems int + ShowSection bool + SectionName string +} + +// RenderMCPList renders a list of MCP status items with the given options. +func RenderMCPList(opts RenderOptions) []string { + t := styles.CurrentTheme() + mcpList := []string{} + + if opts.ShowSection { + sectionName := opts.SectionName + if sectionName == "" { + sectionName = "MCPs" + } + section := t.S().Subtle.Render(sectionName) + mcpList = append(mcpList, section, "") + } + + mcps := config.Get().MCP.Sorted() + if len(mcps) == 0 { + mcpList = append(mcpList, t.S().Base.Foreground(t.Border).Render("None")) + return mcpList + } + + // Get MCP states + mcpStates := agent.GetMCPStates() + + // Determine how many items to show + maxItems := len(mcps) + if opts.MaxItems > 0 { + maxItems = min(opts.MaxItems, len(mcps)) + } + + for i, l := range mcps { + if i >= maxItems { + break + } + + // Determine icon and color based on state + iconColor := t.FgMuted + description := l.MCP.Command + extraContent := "" + + if state, exists := mcpStates[l.Name]; exists { + switch state.State { + case agent.MCPStateDisabled: + iconColor = t.FgMuted + description = t.S().Subtle.Render("disabled") + case agent.MCPStateStarting: + iconColor = t.Yellow + description = t.S().Subtle.Render("starting...") + case agent.MCPStateConnected: + iconColor = t.Success + if state.ToolCount > 0 { + extraContent = t.S().Subtle.Render(fmt.Sprintf("(%d tools)", state.ToolCount)) + } + case agent.MCPStateError: + iconColor = t.Red + if state.Error != nil { + description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error())) + } else { + description = t.S().Subtle.Render("error") + } + } + } else if l.MCP.Disabled { + iconColor = t.FgMuted + description = t.S().Subtle.Render("disabled") + } + + mcpList = append(mcpList, + core.Status( + core.StatusOpts{ + IconColor: iconColor, + Title: l.Name, + Description: description, + ExtraContent: extraContent, + }, + opts.MaxWidth, + ), + ) + } + + return mcpList +} + +// RenderMCPBlock renders a complete MCP block with optional truncation indicator. +func RenderMCPBlock(opts RenderOptions, showTruncationIndicator bool) string { + t := styles.CurrentTheme() + mcpList := RenderMCPList(opts) + + // Add truncation indicator if needed + if showTruncationIndicator && opts.MaxItems > 0 { + mcps := config.Get().MCP.Sorted() + if len(mcps) > opts.MaxItems { + remaining := len(mcps) - opts.MaxItems + if remaining == 1 { + mcpList = append(mcpList, t.S().Base.Foreground(t.FgMuted).Render("…")) + } else { + mcpList = append(mcpList, + t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)), + ) + } + } + } + + content := lipgloss.JoinVertical(lipgloss.Left, mcpList...) + if opts.MaxWidth > 0 { + return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content) + } + return content +} diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index 44a849fcf6027813feb49be5a68c401f4253eeb6..4bf8b2dbbc4ffde261465c8ebd655a26f2344852 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -3,6 +3,7 @@ package list import ( "slices" "strings" + "sync" "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" @@ -90,6 +91,7 @@ type list[T Item] struct { renderedItems *csync.Map[string, renderedItem] + renderMu sync.Mutex rendered string movingByItem bool @@ -328,7 +330,9 @@ func (l *list[T]) render() tea.Cmd { // we are not rendering the first time if l.rendered != "" { // rerender everything will mostly hit cache + l.renderMu.Lock() l.rendered, _ = l.renderIterator(0, false, "") + l.renderMu.Unlock() if l.direction == DirectionBackward { l.recalculateItemPositions() } @@ -338,9 +342,10 @@ func (l *list[T]) render() tea.Cmd { } return focusChangeCmd } + l.renderMu.Lock() rendered, finishIndex := l.renderIterator(0, true, "") l.rendered = rendered - + l.renderMu.Unlock() // recalculate for the initial items if l.direction == DirectionBackward { l.recalculateItemPositions() @@ -348,7 +353,10 @@ func (l *list[T]) render() tea.Cmd { renderCmd := func() tea.Msg { l.offset = 0 // render the rest + + l.renderMu.Lock() l.rendered, _ = l.renderIterator(finishIndex, false, l.rendered) + l.renderMu.Unlock() // needed for backwards if l.direction == DirectionBackward { l.recalculateItemPositions() @@ -357,7 +365,6 @@ func (l *list[T]) render() tea.Cmd { if l.focused { l.scrollToSelection() } - return nil } return tea.Batch(focusChangeCmd, renderCmd)