From fcbe9666bddf20c6c996b04891a962d374687226 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 31 Jul 2025 10:49:07 +0200 Subject: [PATCH 01/39] chore: limit tool output --- internal/llm/tools/tools.go | 75 ++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/internal/llm/tools/tools.go b/internal/llm/tools/tools.go index 41c0515616032b117f3c09a0056cac9e86b62c66..1759c3d33ef4e685be82c868d82e914c501174aa 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 := []string{} + 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) From 7bfd0469acc8bb5cdbed13787f7c04fcaa12f660 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 1 Aug 2025 08:06:25 +0200 Subject: [PATCH 02/39] chore: make consts private --- internal/llm/tools/tools.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/internal/llm/tools/tools.go b/internal/llm/tools/tools.go index 1759c3d33ef4e685be82c868d82e914c501174aa..641794cd74fd662c645c0f4cd634961c28cf915c 100644 --- a/internal/llm/tools/tools.go +++ b/internal/llm/tools/tools.go @@ -28,9 +28,9 @@ const ( SessionIDContextKey sessionIDContextKey = "session_id" MessageIDContextKey messageIDContextKey = "message_id" - MaxResponseWidth = 3000 - MaxResponseHeight = 5000 - MaxResponseChars = 50000 + maxResponseWidth = 3000 + maxResponseHeight = 5000 + maxResponseChars = 50000 ) type ToolResponse struct { @@ -48,13 +48,13 @@ func NewTextResponse(content string) ToolResponse { } func truncateContent(content string) string { - if len(content) <= MaxResponseChars { + if len(content) <= maxResponseChars { return truncateWidthAndHeight(content) } - truncated := content[:MaxResponseChars] + truncated := content[:maxResponseChars] - if lastNewline := strings.LastIndex(truncated, "\n"); lastNewline > MaxResponseChars/2 { + if lastNewline := strings.LastIndex(truncated, "\n"); lastNewline > maxResponseChars/2 { truncated = truncated[:lastNewline] } @@ -67,12 +67,12 @@ func truncateWidthAndHeight(content string) string { lines := strings.Split(content, "\n") heightTruncated := false - if len(lines) > MaxResponseHeight { - keepLines := MaxResponseHeight - 3 + if len(lines) > maxResponseHeight { + keepLines := maxResponseHeight - 3 firstHalf := keepLines / 2 secondHalf := keepLines - firstHalf - truncatedLines := make([]string, 0, MaxResponseHeight) + truncatedLines := make([]string, 0, maxResponseHeight) truncatedLines = append(truncatedLines, lines[:firstHalf]...) truncatedLines = append(truncatedLines, "") truncatedLines = append(truncatedLines, fmt.Sprintf("... [%d lines truncated] ...", len(lines)-keepLines)) @@ -85,14 +85,14 @@ func truncateWidthAndHeight(content string) string { widthTruncated := false for i, line := range lines { - if len(line) > MaxResponseWidth { - if MaxResponseWidth > 20 { - keepChars := MaxResponseWidth - 10 + 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] + lines[i] = line[:maxResponseWidth] } widthTruncated = true } From cb6642b5fb1a67cfe5feb8052df36c7aa55f1c40 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Sun, 3 Aug 2025 22:56:26 +0200 Subject: [PATCH 03/39] fix: sidebar files jumping --- internal/tui/components/chat/sidebar/sidebar.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go index 1f5fd2a672e3d643efbed4ca35b08ed88c55d2eb..ccbc9330f979acd0750279a278ebb8748e5a32cf 100644 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ b/internal/tui/components/chat/sidebar/sidebar.go @@ -400,6 +400,9 @@ func (m *sidebarCmp) filesBlockCompact(maxWidth int) string { fileList := []string{section, ""} sort.Slice(files, func(i, j int) bool { + if files[i].History.latestVersion.CreatedAt == files[j].History.latestVersion.CreatedAt { + return files[i].FilePath < files[j].FilePath + } return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt }) @@ -626,8 +629,11 @@ func (m *sidebarCmp) filesBlock() string { } fileList := []string{section, ""} - // order files by the latest version's created time + // order files by the latest version's created time, then by path for stability sort.Slice(files, func(i, j int) bool { + if files[i].History.latestVersion.CreatedAt == files[j].History.latestVersion.CreatedAt { + return files[i].FilePath < files[j].FilePath + } return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt }) From dbb998c76e336b6e57a57a11a5478726a3ad7c93 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Sun, 3 Aug 2025 22:56:42 +0200 Subject: [PATCH 04/39] fix: width related commands not showing --- internal/tui/components/dialogs/commands/commands.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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{ From cf72cb88b53588d77725e3af4e49524479b885b2 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Sun, 3 Aug 2025 22:57:04 +0200 Subject: [PATCH 05/39] fix: make sure to make rendered string concurrency sage --- internal/tui/exp/list/list.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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) From 7c6e39d83a885da2beb82e8f0308693021192079 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Sun, 3 Aug 2025 23:23:32 +0200 Subject: [PATCH 06/39] fix: summarize provider should be large --- internal/llm/agent/agent.go | 68 ++++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 31 deletions(-) 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 From 1eabdb4bb87f8e793a23730896fb7c7877c18de9 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Sun, 3 Aug 2025 18:35:23 -0300 Subject: [PATCH 07/39] chore(legal): @jooray has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 9fd0e1b175feaffd9f136d51cdca2115a5a4b86d..f2a774e00890cc5d2059133563c44664862fcbd6 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -199,6 +199,14 @@ "created_at": "2025-08-03T04:07:16Z", "repoId": 987670088, "pullRequestNo": 519 + }, + { + "name": "jooray", + "id": 1028688, + "comment_id": 3148713433, + "created_at": "2025-08-03T21:35:15Z", + "repoId": 987670088, + "pullRequestNo": 527 } ] } \ No newline at end of file From e130e0385682a0ede0a63630e527328a4d187e49 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 4 Aug 2025 10:38:11 +0200 Subject: [PATCH 08/39] chore: set notices size --- internal/llm/tools/tools.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/llm/tools/tools.go b/internal/llm/tools/tools.go index 641794cd74fd662c645c0f4cd634961c28cf915c..d8eb9b30c10378c06700d82a584eab19294f99ae 100644 --- a/internal/llm/tools/tools.go +++ b/internal/llm/tools/tools.go @@ -101,7 +101,7 @@ func truncateWidthAndHeight(content string) string { result := strings.Join(lines, "\n") if heightTruncated || widthTruncated { - notices := []string{} + notices := make([]string, 0, 2) if heightTruncated { notices = append(notices, "height") } From 4739ba93436327408a4e320463a5ab2fa0710ee8 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 4 Aug 2025 11:59:44 +0200 Subject: [PATCH 09/39] fix: correctly show mcp and lsp states --- internal/app/app.go | 2 + internal/app/lsp.go | 12 +- internal/app/lsp_events.go | 104 ++++ internal/llm/agent/mcp-tools.go | 133 ++++- internal/lsp/client.go | 19 +- internal/lsp/handlers.go | 14 +- .../tui/components/chat/sidebar/sidebar.go | 487 +++--------------- internal/tui/components/chat/splash/splash.go | 58 +-- internal/tui/components/files/files.go | 142 +++++ internal/tui/components/lsp/lsp.go | 160 ++++++ internal/tui/components/mcp/mcp.go | 128 +++++ 11 files changed, 795 insertions(+), 464 deletions(-) create mode 100644 internal/app/lsp_events.go create mode 100644 internal/tui/components/files/files.go create mode 100644 internal/tui/components/lsp/lsp.go create mode 100644 internal/tui/components/mcp/mcp.go 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..9af42eced75e01937285a1e8e2b9696ccd04039d --- /dev/null +++ b/internal/app/lsp_events.go @@ -0,0 +1,104 @@ +package app + +import ( + "context" + "sync" + "time" + + "github.com/charmbracelet/crush/internal/csync" + "github.com/charmbracelet/crush/internal/lsp" + "github.com/charmbracelet/crush/internal/pubsub" +) + +// LSPEventType represents the type of LSP event +type LSPEventType string + +const ( + LSPEventStateChanged LSPEventType = "state_changed" + LSPEventDiagnosticsChanged LSPEventType = "diagnostics_changed" +) + +// LSPEvent represents an event in the LSP system +type LSPEvent struct { + Type LSPEventType + Name string + State lsp.ServerState + Error error + DiagnosticCount int +} + +// LSPClientInfo holds information about an LSP client's state +type LSPClientInfo struct { + Name string + State lsp.ServerState + Error error + Client *lsp.Client + DiagnosticCount int + ConnectedAt time.Time +} + +var ( + lspStates = csync.NewMap[string, LSPClientInfo]() + lspBroker = pubsub.NewBroker[LSPEvent]() + lspMutex sync.RWMutex +) + +// SubscribeLSPEvents returns a channel for LSP events +func SubscribeLSPEvents(ctx context.Context) <-chan pubsub.Event[LSPEvent] { + return lspBroker.Subscribe(ctx) +} + +// GetLSPStates returns the current state of all LSP clients +func GetLSPStates() map[string]LSPClientInfo { + states := make(map[string]LSPClientInfo) + for name, info := range lspStates.Seq2() { + states[name] = info + } + return states +} + +// GetLSPState returns the state of a specific LSP client +func GetLSPState(name string) (LSPClientInfo, bool) { + return lspStates.Get(name) +} + +// updateLSPState updates the state of an LSP client and publishes an event +func updateLSPState(name string, state lsp.ServerState, err error, client *lsp.Client, diagnosticCount int) { + info := LSPClientInfo{ + Name: name, + State: state, + Error: err, + Client: client, + DiagnosticCount: diagnosticCount, + } + if state == lsp.StateReady { + info.ConnectedAt = time.Now() + } + lspStates.Set(name, info) + + // Publish state change event + lspBroker.Publish(pubsub.UpdatedEvent, LSPEvent{ + Type: LSPEventStateChanged, + Name: name, + State: state, + Error: err, + DiagnosticCount: diagnosticCount, + }) +} + +// updateLSPDiagnostics updates the diagnostic count for an LSP client and publishes an event +func updateLSPDiagnostics(name string, diagnosticCount int) { + if info, exists := lspStates.Get(name); exists { + info.DiagnosticCount = diagnosticCount + lspStates.Set(name, info) + + // Publish diagnostics change event + lspBroker.Publish(pubsub.UpdatedEvent, LSPEvent{ + Type: LSPEventDiagnosticsChanged, + Name: name, + State: info.State, + Error: info.Error, + DiagnosticCount: diagnosticCount, + }) + } +} diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 97f00c92d156b5d50c2a6a84d1f853a0db1246aa..7affe74bd393ac88b0422cc24dd8a91c4d169e73 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -7,10 +7,12 @@ import ( "log/slog" "slices" "sync" + "time" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/llm/tools" + "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/version" "github.com/charmbracelet/crush/internal/permission" @@ -20,10 +22,63 @@ import ( "github.com/mark3labs/mcp-go/mcp" ) +// MCPState represents the current state of an MCP client +type MCPState int + +const ( + MCPStateDisabled MCPState = iota + MCPStateStarting + MCPStateConnected + MCPStateError +) + +func (s MCPState) String() string { + switch s { + case MCPStateDisabled: + return "disabled" + case MCPStateStarting: + return "starting" + case MCPStateConnected: + return "connected" + case MCPStateError: + return "error" + default: + return "unknown" + } +} + +// MCPEventType represents the type of MCP event +type MCPEventType string + +const ( + MCPEventStateChanged MCPEventType = "state_changed" +) + +// MCPEvent represents an event in the MCP system +type MCPEvent struct { + Type MCPEventType + Name string + State MCPState + Error error + ToolCount int +} + +// MCPClientInfo holds information about an MCP client's state +type MCPClientInfo struct { + Name string + State MCPState + Error error + Client *client.Client + ToolCount int + ConnectedAt time.Time +} + var ( mcpToolsOnce sync.Once mcpTools []tools.BaseTool mcpClients = csync.NewMap[string, *client.Client]() + mcpStates = csync.NewMap[string, MCPClientInfo]() + mcpBroker = pubsub.NewBroker[MCPEvent]() ) type McpTool struct { @@ -109,6 +164,7 @@ func getTools(ctx context.Context, name string, permissions permission.Service, result, err := c.ListTools(ctx, mcp.ListToolsRequest{}) if err != nil { slog.Error("error listing tools", "error", err) + updateMCPState(name, MCPStateError, err, nil, 0) c.Close() mcpClients.Del(name) return nil @@ -125,11 +181,55 @@ func getTools(ctx context.Context, name string, permissions permission.Service, return mcpTools } +// SubscribeMCPEvents returns a channel for MCP events +func SubscribeMCPEvents(ctx context.Context) <-chan pubsub.Event[MCPEvent] { + return mcpBroker.Subscribe(ctx) +} + +// GetMCPStates returns the current state of all MCP clients +func GetMCPStates() map[string]MCPClientInfo { + states := make(map[string]MCPClientInfo) + for name, info := range mcpStates.Seq2() { + states[name] = info + } + return states +} + +// GetMCPState returns the state of a specific MCP client +func GetMCPState(name string) (MCPClientInfo, bool) { + return mcpStates.Get(name) +} + +// updateMCPState updates the state of an MCP client and publishes an event +func updateMCPState(name string, state MCPState, err error, client *client.Client, toolCount int) { + info := MCPClientInfo{ + Name: name, + State: state, + Error: err, + Client: client, + ToolCount: toolCount, + } + if state == MCPStateConnected { + info.ConnectedAt = time.Now() + } + mcpStates.Set(name, info) + + // Publish state change event + mcpBroker.Publish(pubsub.UpdatedEvent, MCPEvent{ + Type: MCPEventStateChanged, + Name: name, + State: state, + Error: err, + ToolCount: toolCount, + }) +} + // CloseMCPClients closes all MCP clients. This should be called during application shutdown. func CloseMCPClients() { for c := range mcpClients.Seq() { _ = c.Close() } + mcpBroker.Shutdown() } var mcpInitRequest = mcp.InitializeRequest{ @@ -145,25 +245,51 @@ var mcpInitRequest = mcp.InitializeRequest{ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) []tools.BaseTool { var wg sync.WaitGroup result := csync.NewSlice[tools.BaseTool]() + + // Initialize states for all configured MCPs for name, m := range cfg.MCP { if m.Disabled { + updateMCPState(name, MCPStateDisabled, nil, nil, 0) slog.Debug("skipping disabled mcp", "name", name) continue } + + // Set initial starting state + updateMCPState(name, MCPStateStarting, nil, nil, 0) + wg.Add(1) go func(name string, m config.MCPConfig) { - defer wg.Done() + defer func() { + wg.Done() + if r := recover(); r != nil { + var err error + switch v := r.(type) { + case error: + err = v + case string: + err = fmt.Errorf("panic: %s", v) + default: + err = fmt.Errorf("panic: %v", v) + } + updateMCPState(name, MCPStateError, err, nil, 0) + slog.Error("panic in mcp client initialization", "error", err, "name", name) + } + }() + c, err := createMcpClient(m) if err != nil { + updateMCPState(name, MCPStateError, err, nil, 0) slog.Error("error creating mcp client", "error", err, "name", name) return } if err := c.Start(ctx); err != nil { + updateMCPState(name, MCPStateError, err, nil, 0) slog.Error("error starting mcp client", "error", err, "name", name) _ = c.Close() return } if _, err := c.Initialize(ctx, mcpInitRequest); err != nil { + updateMCPState(name, MCPStateError, err, nil, 0) slog.Error("error initializing mcp client", "error", err, "name", name) _ = c.Close() return @@ -172,7 +298,10 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con slog.Info("Initialized mcp client", "name", name) mcpClients.Set(name, c) - result.Append(getTools(ctx, name, permissions, c, cfg.WorkingDir())...) + tools := getTools(ctx, name, permissions, c, cfg.WorkingDir()) + toolCount := len(tools) + updateMCPState(name, MCPStateConnected, nil, c, toolCount) + result.Append(tools...) }(name, m) } wg.Wait() diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 219a5df5fb87197f0490f218cddc24ab3b138371..279ec1feb80b79ef093fc8d1395022d4949756d7 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -26,6 +26,12 @@ type Client struct { stdout *bufio.Reader stderr io.ReadCloser + // Client name for identification + name string + + // Diagnostic change callback + onDiagnosticsChanged func(name string, count int) + // Request ID counter nextID atomic.Int32 @@ -53,7 +59,7 @@ type Client struct { serverState atomic.Value } -func NewClient(ctx context.Context, command string, args ...string) (*Client, error) { +func NewClient(ctx context.Context, name, command string, args ...string) (*Client, error) { cmd := exec.CommandContext(ctx, command, args...) // Copy env cmd.Env = os.Environ() @@ -75,6 +81,7 @@ func NewClient(ctx context.Context, command string, args ...string) (*Client, er client := &Client{ Cmd: cmd, + name: name, stdin: stdin, stdout: bufio.NewReader(stdout), stderr: stderr, @@ -284,6 +291,16 @@ func (c *Client) SetServerState(state ServerState) { c.serverState.Store(state) } +// GetName returns the name of the LSP client +func (c *Client) GetName() string { + return c.name +} + +// SetDiagnosticsCallback sets the callback function for diagnostic changes +func (c *Client) SetDiagnosticsCallback(callback func(name string, count int)) { + c.onDiagnosticsChanged = callback +} + // WaitForServerReady waits for the server to be ready by polling the server // with a simple request until it responds successfully or times out func (c *Client) WaitForServerReady(ctx context.Context) error { diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go index 725d3c3c77ffba465b3e644a9948a1ce56c3eeaa..72f3018b3da969000672e5b4ba47f73f2b72df97 100644 --- a/internal/lsp/handlers.go +++ b/internal/lsp/handlers.go @@ -103,7 +103,17 @@ func HandleDiagnostics(client *Client, params json.RawMessage) { } client.diagnosticsMu.Lock() - defer client.diagnosticsMu.Unlock() - client.diagnostics[diagParams.URI] = diagParams.Diagnostics + + // Calculate total diagnostic count + totalCount := 0 + for _, diagnostics := range client.diagnostics { + totalCount += len(diagnostics) + } + client.diagnosticsMu.Unlock() + + // Trigger callback if set + if client.onDiagnosticsChanged != nil { + client.onDiagnosticsChanged(client.name, totalCount) + } } diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go index 1f5fd2a672e3d643efbed4ca35b08ed88c55d2eb..edec996e32558fadb6112ef9781a26413182a06a 100644 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ b/internal/tui/components/chat/sidebar/sidebar.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "slices" - "sort" "strings" tea "github.com/charmbracelet/bubbletea/v2" @@ -13,21 +12,21 @@ import ( "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/diff" - "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/history" "github.com/charmbracelet/crush/internal/lsp" - "github.com/charmbracelet/crush/internal/lsp/protocol" "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/tui/components/chat" "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/components/core/layout" + "github.com/charmbracelet/crush/internal/tui/components/files" "github.com/charmbracelet/crush/internal/tui/components/logo" + lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp" + "github.com/charmbracelet/crush/internal/tui/components/mcp" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/crush/internal/version" "github.com/charmbracelet/lipgloss/v2" - "github.com/charmbracelet/x/ansi" "golang.org/x/text/cases" "golang.org/x/text/language" ) @@ -382,459 +381,125 @@ func (m *sidebarCmp) renderSectionsHorizontal() string { // filesBlockCompact renders the files block with limited width and height for horizontal layout func (m *sidebarCmp) filesBlockCompact(maxWidth int) string { - t := styles.CurrentTheme() - - section := t.S().Subtle.Render("Modified Files") - - files := slices.Collect(m.files.Seq()) - - if len(files) == 0 { - content := lipgloss.JoinVertical( - lipgloss.Left, - section, - "", - t.S().Base.Foreground(t.Border).Render("None"), - ) - return lipgloss.NewStyle().Width(maxWidth).Render(content) + // Convert map to slice and handle type conversion + sessionFiles := slices.Collect(m.files.Seq()) + fileSlice := make([]files.SessionFile, len(sessionFiles)) + for i, sf := range sessionFiles { + fileSlice[i] = files.SessionFile{ + History: files.FileHistory{ + InitialVersion: sf.History.initialVersion, + LatestVersion: sf.History.latestVersion, + }, + FilePath: sf.FilePath, + Additions: sf.Additions, + Deletions: sf.Deletions, + } } - fileList := []string{section, ""} - sort.Slice(files, func(i, j int) bool { - return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt - }) - - // Limit items for horizontal layout - use less space - maxItems := min(5, len(files)) + // Limit items for horizontal layout + maxItems := min(5, len(fileSlice)) availableHeight := m.height - 8 // Reserve space for header and other content if availableHeight > 0 { maxItems = min(maxItems, availableHeight) } - filesShown := 0 - for _, file := range files { - if file.Additions == 0 && file.Deletions == 0 { - continue - } - if filesShown >= maxItems { - break - } - - var statusParts []string - if file.Additions > 0 { - statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions))) - } - if file.Deletions > 0 { - statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions))) - } - - extraContent := strings.Join(statusParts, " ") - cwd := config.Get().WorkingDir() + string(os.PathSeparator) - filePath := file.FilePath - filePath = strings.TrimPrefix(filePath, cwd) - filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2) - filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…") - - fileList = append(fileList, - core.Status( - core.StatusOpts{ - IconColor: t.FgMuted, - NoIcon: true, - Title: filePath, - ExtraContent: extraContent, - }, - maxWidth, - ), - ) - filesShown++ - } - - // Add "..." indicator if there are more files - totalFilesWithChanges := 0 - for _, file := range files { - if file.Additions > 0 || file.Deletions > 0 { - totalFilesWithChanges++ - } - } - if totalFilesWithChanges > maxItems { - fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("…")) - } - - content := lipgloss.JoinVertical(lipgloss.Left, fileList...) - return lipgloss.NewStyle().Width(maxWidth).Render(content) + return files.RenderFileBlock(fileSlice, files.RenderOptions{ + MaxWidth: maxWidth, + MaxItems: maxItems, + ShowSection: true, + SectionName: "Modified Files", + }, true) } // lspBlockCompact renders the LSP block with limited width and height for horizontal layout func (m *sidebarCmp) lspBlockCompact(maxWidth int) string { - t := styles.CurrentTheme() - - section := t.S().Subtle.Render("LSPs") - - lspList := []string{section, ""} - - lsp := config.Get().LSP.Sorted() - if len(lsp) == 0 { - content := lipgloss.JoinVertical( - lipgloss.Left, - section, - "", - t.S().Base.Foreground(t.Border).Render("None"), - ) - return lipgloss.NewStyle().Width(maxWidth).Render(content) - } - // Limit items for horizontal layout - maxItems := min(5, len(lsp)) + lspConfigs := config.Get().LSP.Sorted() + maxItems := min(5, len(lspConfigs)) availableHeight := m.height - 8 if availableHeight > 0 { maxItems = min(maxItems, availableHeight) } - for i, l := range lsp { - if i >= maxItems { - break - } - - iconColor := t.Success - if l.LSP.Disabled { - iconColor = t.FgMuted - } - - lspErrs := map[protocol.DiagnosticSeverity]int{ - protocol.SeverityError: 0, - protocol.SeverityWarning: 0, - protocol.SeverityHint: 0, - protocol.SeverityInformation: 0, - } - if client, ok := m.lspClients[l.Name]; ok { - for _, diagnostics := range client.GetDiagnostics() { - for _, diagnostic := range diagnostics { - if severity, ok := lspErrs[diagnostic.Severity]; ok { - lspErrs[diagnostic.Severity] = severity + 1 - } - } - } - } - - errs := []string{} - if lspErrs[protocol.SeverityError] > 0 { - errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError]))) - } - if lspErrs[protocol.SeverityWarning] > 0 { - errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning]))) - } - if lspErrs[protocol.SeverityHint] > 0 { - errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint]))) - } - if lspErrs[protocol.SeverityInformation] > 0 { - errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation]))) - } - - lspList = append(lspList, - core.Status( - core.StatusOpts{ - IconColor: iconColor, - Title: l.Name, - Description: l.LSP.Command, - ExtraContent: strings.Join(errs, " "), - }, - maxWidth, - ), - ) - } - - // Add "..." indicator if there are more LSPs - if len(lsp) > maxItems { - lspList = append(lspList, t.S().Base.Foreground(t.FgMuted).Render("…")) - } - - content := lipgloss.JoinVertical(lipgloss.Left, lspList...) - return lipgloss.NewStyle().Width(maxWidth).Render(content) + return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{ + MaxWidth: maxWidth, + MaxItems: maxItems, + ShowSection: true, + SectionName: "LSPs", + }, true) } // mcpBlockCompact renders the MCP block with limited width and height for horizontal layout func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string { - t := styles.CurrentTheme() - - section := t.S().Subtle.Render("MCPs") - - mcpList := []string{section, ""} - - mcps := config.Get().MCP.Sorted() - if len(mcps) == 0 { - content := lipgloss.JoinVertical( - lipgloss.Left, - section, - "", - t.S().Base.Foreground(t.Border).Render("None"), - ) - return lipgloss.NewStyle().Width(maxWidth).Render(content) - } - // Limit items for horizontal layout - maxItems := min(5, len(mcps)) + maxItems := min(5, len(config.Get().MCP.Sorted())) availableHeight := m.height - 8 if availableHeight > 0 { maxItems = min(maxItems, availableHeight) } - for i, l := range mcps { - if i >= maxItems { - break - } - - iconColor := t.Success - if l.MCP.Disabled { - iconColor = t.FgMuted - } - - mcpList = append(mcpList, - core.Status( - core.StatusOpts{ - IconColor: iconColor, - Title: l.Name, - Description: l.MCP.Command, - }, - maxWidth, - ), - ) - } - - // Add "..." indicator if there are more MCPs - if len(mcps) > maxItems { - mcpList = append(mcpList, t.S().Base.Foreground(t.FgMuted).Render("…")) - } - - content := lipgloss.JoinVertical(lipgloss.Left, mcpList...) - return lipgloss.NewStyle().Width(maxWidth).Render(content) + return mcp.RenderMCPBlock(mcp.RenderOptions{ + MaxWidth: maxWidth, + MaxItems: maxItems, + ShowSection: true, + SectionName: "MCPs", + }, true) } func (m *sidebarCmp) filesBlock() string { - t := styles.CurrentTheme() - - section := t.S().Subtle.Render( - core.Section("Modified Files", m.getMaxWidth()), - ) - - files := slices.Collect(m.files.Seq()) - if len(files) == 0 { - return lipgloss.JoinVertical( - lipgloss.Left, - section, - "", - t.S().Base.Foreground(t.Border).Render("None"), - ) + // Convert map to slice and handle type conversion + sessionFiles := slices.Collect(m.files.Seq()) + fileSlice := make([]files.SessionFile, len(sessionFiles)) + for i, sf := range sessionFiles { + fileSlice[i] = files.SessionFile{ + History: files.FileHistory{ + InitialVersion: sf.History.initialVersion, + LatestVersion: sf.History.latestVersion, + }, + FilePath: sf.FilePath, + Additions: sf.Additions, + Deletions: sf.Deletions, + } } - fileList := []string{section, ""} - // order files by the latest version's created time - sort.Slice(files, func(i, j int) bool { - return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt - }) - // Limit the number of files shown maxFiles, _, _ := m.getDynamicLimits() - maxFiles = min(len(files), maxFiles) - filesShown := 0 - - for _, file := range files { - if file.Additions == 0 && file.Deletions == 0 { - continue // skip files with no changes - } - if filesShown >= maxFiles { - break - } - - var statusParts []string - if file.Additions > 0 { - statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions))) - } - if file.Deletions > 0 { - statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions))) - } - - extraContent := strings.Join(statusParts, " ") - cwd := config.Get().WorkingDir() + string(os.PathSeparator) - filePath := file.FilePath - filePath = strings.TrimPrefix(filePath, cwd) - filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2) - filePath = ansi.Truncate(filePath, m.getMaxWidth()-lipgloss.Width(extraContent)-2, "…") - fileList = append(fileList, - core.Status( - core.StatusOpts{ - IconColor: t.FgMuted, - NoIcon: true, - Title: filePath, - ExtraContent: extraContent, - }, - m.getMaxWidth(), - ), - ) - filesShown++ - } - - // Add indicator if there are more files - totalFilesWithChanges := 0 - for _, file := range files { - if file.Additions > 0 || file.Deletions > 0 { - totalFilesWithChanges++ - } - } - if totalFilesWithChanges > maxFiles { - remaining := totalFilesWithChanges - maxFiles - fileList = append(fileList, - t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)), - ) - } - - return lipgloss.JoinVertical( - lipgloss.Left, - fileList..., - ) + maxFiles = min(len(fileSlice), maxFiles) + + return files.RenderFileBlock(fileSlice, files.RenderOptions{ + MaxWidth: m.getMaxWidth(), + MaxItems: maxFiles, + ShowSection: true, + SectionName: core.Section("Modified Files", m.getMaxWidth()), + }, true) } func (m *sidebarCmp) lspBlock() string { - t := styles.CurrentTheme() - - section := t.S().Subtle.Render( - core.Section("LSPs", m.getMaxWidth()), - ) - - lspList := []string{section, ""} - - lsp := config.Get().LSP.Sorted() - if len(lsp) == 0 { - return lipgloss.JoinVertical( - lipgloss.Left, - section, - "", - t.S().Base.Foreground(t.Border).Render("None"), - ) - } - // Limit the number of LSPs shown _, maxLSPs, _ := m.getDynamicLimits() - maxLSPs = min(len(lsp), maxLSPs) - for i, l := range lsp { - if i >= maxLSPs { - break - } - - iconColor := t.Success - if l.LSP.Disabled { - iconColor = t.FgMuted - } - lspErrs := map[protocol.DiagnosticSeverity]int{ - protocol.SeverityError: 0, - protocol.SeverityWarning: 0, - protocol.SeverityHint: 0, - protocol.SeverityInformation: 0, - } - if client, ok := m.lspClients[l.Name]; ok { - for _, diagnostics := range client.GetDiagnostics() { - for _, diagnostic := range diagnostics { - if severity, ok := lspErrs[diagnostic.Severity]; ok { - lspErrs[diagnostic.Severity] = severity + 1 - } - } - } - } - - errs := []string{} - if lspErrs[protocol.SeverityError] > 0 { - errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError]))) - } - if lspErrs[protocol.SeverityWarning] > 0 { - errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning]))) - } - if lspErrs[protocol.SeverityHint] > 0 { - errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint]))) - } - if lspErrs[protocol.SeverityInformation] > 0 { - errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation]))) - } - - lspList = append(lspList, - core.Status( - core.StatusOpts{ - IconColor: iconColor, - Title: l.Name, - Description: l.LSP.Command, - ExtraContent: strings.Join(errs, " "), - }, - m.getMaxWidth(), - ), - ) - } - - // Add indicator if there are more LSPs - if len(lsp) > maxLSPs { - remaining := len(lsp) - maxLSPs - lspList = append(lspList, - t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)), - ) - } - - return lipgloss.JoinVertical( - lipgloss.Left, - lspList..., - ) + lspConfigs := config.Get().LSP.Sorted() + maxLSPs = min(len(lspConfigs), maxLSPs) + + return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{ + MaxWidth: m.getMaxWidth(), + MaxItems: maxLSPs, + ShowSection: true, + SectionName: core.Section("LSPs", m.getMaxWidth()), + }, true) } func (m *sidebarCmp) mcpBlock() string { - t := styles.CurrentTheme() - - section := t.S().Subtle.Render( - core.Section("MCPs", m.getMaxWidth()), - ) - - mcpList := []string{section, ""} - - mcps := config.Get().MCP.Sorted() - if len(mcps) == 0 { - return lipgloss.JoinVertical( - lipgloss.Left, - section, - "", - t.S().Base.Foreground(t.Border).Render("None"), - ) - } - // Limit the number of MCPs shown _, _, maxMCPs := m.getDynamicLimits() + mcps := config.Get().MCP.Sorted() maxMCPs = min(len(mcps), maxMCPs) - for i, l := range mcps { - if i >= maxMCPs { - break - } - - iconColor := t.Success - if l.MCP.Disabled { - iconColor = t.FgMuted - } - mcpList = append(mcpList, - core.Status( - core.StatusOpts{ - IconColor: iconColor, - Title: l.Name, - Description: l.MCP.Command, - }, - m.getMaxWidth(), - ), - ) - } - - // Add indicator if there are more MCPs - if len(mcps) > maxMCPs { - remaining := len(mcps) - maxMCPs - mcpList = append(mcpList, - t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)), - ) - } - return lipgloss.JoinVertical( - lipgloss.Left, - mcpList..., - ) + return mcp.RenderMCPBlock(mcp.RenderOptions{ + MaxWidth: m.getMaxWidth(), + MaxItems: maxMCPs, + ShowSection: true, + SectionName: core.Section("MCPs", m.getMaxWidth()), + }, true) } func formatTokensAndCost(tokens, contextWindow int64, cost float64) string { diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 9a74e79b30bbdcc9e0049f9fea55c23607fbc00a..acaf2740c9fb30fb4fc80ddea36b71e10c61b1e2 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -18,6 +18,8 @@ import ( "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/components/dialogs/models" "github.com/charmbracelet/crush/internal/tui/components/logo" + lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp" + "github.com/charmbracelet/crush/internal/tui/components/mcp" "github.com/charmbracelet/crush/internal/tui/exp/list" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" @@ -655,7 +657,7 @@ func (s *splashCmp) Bindings() []key.Binding { } func (s *splashCmp) getMaxInfoWidth() int { - return min(s.width-2, 40) // 2 for left padding + return min(s.width-2, 90) // 2 for left padding } func (s *splashCmp) cwd() string { @@ -670,29 +672,10 @@ func (s *splashCmp) cwd() string { } func LSPList(maxWidth int) []string { - t := styles.CurrentTheme() - lspList := []string{} - lsp := config.Get().LSP.Sorted() - if len(lsp) == 0 { - return []string{t.S().Base.Foreground(t.Border).Render("None")} - } - for _, l := range lsp { - iconColor := t.Success - if l.LSP.Disabled { - iconColor = t.FgMuted - } - lspList = append(lspList, - core.Status( - core.StatusOpts{ - IconColor: iconColor, - Title: l.Name, - Description: l.LSP.Command, - }, - maxWidth, - ), - ) - } - return lspList + return lspcomponent.RenderLSPList(nil, lspcomponent.RenderOptions{ + MaxWidth: maxWidth, + ShowSection: false, + }) } func (s *splashCmp) lspBlock() string { @@ -709,29 +692,10 @@ func (s *splashCmp) lspBlock() string { } func MCPList(maxWidth int) []string { - t := styles.CurrentTheme() - mcpList := []string{} - mcps := config.Get().MCP.Sorted() - if len(mcps) == 0 { - return []string{t.S().Base.Foreground(t.Border).Render("None")} - } - for _, l := range mcps { - iconColor := t.Success - if l.MCP.Disabled { - iconColor = t.FgMuted - } - mcpList = append(mcpList, - core.Status( - core.StatusOpts{ - IconColor: iconColor, - Title: l.Name, - Description: l.MCP.Command, - }, - maxWidth, - ), - ) - } - return mcpList + return mcp.RenderMCPList(mcp.RenderOptions{ + MaxWidth: maxWidth, + ShowSection: false, + }) } func (s *splashCmp) mcpBlock() string { diff --git a/internal/tui/components/files/files.go b/internal/tui/components/files/files.go new file mode 100644 index 0000000000000000000000000000000000000000..9ddced4c908ecae59452ecc999facfcaf52443b3 --- /dev/null +++ b/internal/tui/components/files/files.go @@ -0,0 +1,142 @@ +package files + +import ( + "fmt" + "os" + "sort" + "strings" + + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/history" + "github.com/charmbracelet/crush/internal/tui/components/core" + "github.com/charmbracelet/crush/internal/tui/styles" +) + +// FileHistory represents a file history with initial and latest versions. +type FileHistory struct { + InitialVersion history.File + LatestVersion history.File +} + +// SessionFile represents a file with its history information. +type SessionFile struct { + History FileHistory + FilePath string + Additions int + Deletions int +} + +// RenderOptions contains options for rendering file lists. +type RenderOptions struct { + MaxWidth int + MaxItems int + ShowSection bool + SectionName string +} + +// RenderFileList renders a list of file status items with the given options. +func RenderFileList(fileSlice []SessionFile, opts RenderOptions) []string { + t := styles.CurrentTheme() + fileList := []string{} + + if opts.ShowSection { + sectionName := opts.SectionName + if sectionName == "" { + sectionName = "Modified Files" + } + section := t.S().Subtle.Render(sectionName) + fileList = append(fileList, section, "") + } + + if len(fileSlice) == 0 { + fileList = append(fileList, t.S().Base.Foreground(t.Border).Render("None")) + return fileList + } + + // Sort files by the latest version's created time + sort.Slice(fileSlice, func(i, j int) bool { + return fileSlice[i].History.LatestVersion.CreatedAt > fileSlice[j].History.LatestVersion.CreatedAt + }) + + // Determine how many items to show + maxItems := len(fileSlice) + if opts.MaxItems > 0 { + maxItems = min(opts.MaxItems, len(fileSlice)) + } + + filesShown := 0 + for _, file := range fileSlice { + if file.Additions == 0 && file.Deletions == 0 { + continue // skip files with no changes + } + if filesShown >= maxItems { + break + } + + var statusParts []string + if file.Additions > 0 { + statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions))) + } + if file.Deletions > 0 { + statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions))) + } + + extraContent := strings.Join(statusParts, " ") + cwd := config.Get().WorkingDir() + string(os.PathSeparator) + filePath := file.FilePath + filePath = strings.TrimPrefix(filePath, cwd) + filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2) + filePath = ansi.Truncate(filePath, opts.MaxWidth-lipgloss.Width(extraContent)-2, "…") + + fileList = append(fileList, + core.Status( + core.StatusOpts{ + IconColor: t.FgMuted, + NoIcon: true, + Title: filePath, + ExtraContent: extraContent, + }, + opts.MaxWidth, + ), + ) + filesShown++ + } + + return fileList +} + +// RenderFileBlock renders a complete file block with optional truncation indicator. +func RenderFileBlock(fileSlice []SessionFile, opts RenderOptions, showTruncationIndicator bool) string { + t := styles.CurrentTheme() + fileList := RenderFileList(fileSlice, opts) + + // Add truncation indicator if needed + if showTruncationIndicator && opts.MaxItems > 0 { + totalFilesWithChanges := 0 + for _, file := range fileSlice { + if file.Additions > 0 || file.Deletions > 0 { + totalFilesWithChanges++ + } + } + if totalFilesWithChanges > opts.MaxItems { + remaining := totalFilesWithChanges - opts.MaxItems + if remaining == 1 { + fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("…")) + } else { + fileList = append(fileList, + t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)), + ) + } + } + } + + content := lipgloss.JoinVertical(lipgloss.Left, fileList...) + if opts.MaxWidth > 0 { + return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content) + } + return content +} diff --git a/internal/tui/components/lsp/lsp.go b/internal/tui/components/lsp/lsp.go new file mode 100644 index 0000000000000000000000000000000000000000..10d9f42198a6996e966d01305131e734fa54a614 --- /dev/null +++ b/internal/tui/components/lsp/lsp.go @@ -0,0 +1,160 @@ +package lsp + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss/v2" + + "github.com/charmbracelet/crush/internal/app" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/lsp" + "github.com/charmbracelet/crush/internal/lsp/protocol" + "github.com/charmbracelet/crush/internal/tui/components/core" + "github.com/charmbracelet/crush/internal/tui/styles" +) + +// RenderOptions contains options for rendering LSP lists. +type RenderOptions struct { + MaxWidth int + MaxItems int + ShowSection bool + SectionName string +} + +// RenderLSPList renders a list of LSP status items with the given options. +func RenderLSPList(lspClients map[string]*lsp.Client, opts RenderOptions) []string { + t := styles.CurrentTheme() + lspList := []string{} + + if opts.ShowSection { + sectionName := opts.SectionName + if sectionName == "" { + sectionName = "LSPs" + } + section := t.S().Subtle.Render(sectionName) + lspList = append(lspList, section, "") + } + + lspConfigs := config.Get().LSP.Sorted() + if len(lspConfigs) == 0 { + lspList = append(lspList, t.S().Base.Foreground(t.Border).Render("None")) + return lspList + } + + // Get LSP states + lspStates := app.GetLSPStates() + + // Determine how many items to show + maxItems := len(lspConfigs) + if opts.MaxItems > 0 { + maxItems = min(opts.MaxItems, len(lspConfigs)) + } + + for i, l := range lspConfigs { + if i >= maxItems { + break + } + + // Determine icon color and description based on state + iconColor := t.FgMuted + description := l.LSP.Command + + if l.LSP.Disabled { + iconColor = t.FgMuted + description = t.S().Subtle.Render("disabled") + } else if state, exists := lspStates[l.Name]; exists { + switch state.State { + case lsp.StateStarting: + iconColor = t.Yellow + description = t.S().Subtle.Render("starting...") + case lsp.StateReady: + iconColor = t.Success + description = l.LSP.Command + case lsp.StateError: + iconColor = t.Red + if state.Error != nil { + description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error())) + } else { + description = t.S().Subtle.Render("error") + } + } + } + + // Calculate diagnostic counts if we have LSP clients + var extraContent string + if lspClients != nil { + lspErrs := map[protocol.DiagnosticSeverity]int{ + protocol.SeverityError: 0, + protocol.SeverityWarning: 0, + protocol.SeverityHint: 0, + protocol.SeverityInformation: 0, + } + if client, ok := lspClients[l.Name]; ok { + for _, diagnostics := range client.GetDiagnostics() { + for _, diagnostic := range diagnostics { + if severity, ok := lspErrs[diagnostic.Severity]; ok { + lspErrs[diagnostic.Severity] = severity + 1 + } + } + } + } + + errs := []string{} + if lspErrs[protocol.SeverityError] > 0 { + errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError]))) + } + if lspErrs[protocol.SeverityWarning] > 0 { + errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning]))) + } + if lspErrs[protocol.SeverityHint] > 0 { + errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint]))) + } + if lspErrs[protocol.SeverityInformation] > 0 { + errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation]))) + } + extraContent = strings.Join(errs, " ") + } + + lspList = append(lspList, + core.Status( + core.StatusOpts{ + IconColor: iconColor, + Title: l.Name, + Description: description, + ExtraContent: extraContent, + }, + opts.MaxWidth, + ), + ) + } + + return lspList +} + +// RenderLSPBlock renders a complete LSP block with optional truncation indicator. +func RenderLSPBlock(lspClients map[string]*lsp.Client, opts RenderOptions, showTruncationIndicator bool) string { + t := styles.CurrentTheme() + lspList := RenderLSPList(lspClients, opts) + + // Add truncation indicator if needed + if showTruncationIndicator && opts.MaxItems > 0 { + lspConfigs := config.Get().LSP.Sorted() + if len(lspConfigs) > opts.MaxItems { + remaining := len(lspConfigs) - opts.MaxItems + if remaining == 1 { + lspList = append(lspList, t.S().Base.Foreground(t.FgMuted).Render("…")) + } else { + lspList = append(lspList, + t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)), + ) + } + } + } + + content := lipgloss.JoinVertical(lipgloss.Left, lspList...) + if opts.MaxWidth > 0 { + return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content) + } + return content +} diff --git a/internal/tui/components/mcp/mcp.go b/internal/tui/components/mcp/mcp.go new file mode 100644 index 0000000000000000000000000000000000000000..93f2dcb230721ab95c3ea2f4937647ff7ccf5bda --- /dev/null +++ b/internal/tui/components/mcp/mcp.go @@ -0,0 +1,128 @@ +package mcp + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss/v2" + + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/llm/agent" + "github.com/charmbracelet/crush/internal/tui/components/core" + "github.com/charmbracelet/crush/internal/tui/styles" +) + +// RenderOptions contains options for rendering MCP lists. +type RenderOptions struct { + MaxWidth int + MaxItems int + ShowSection bool + SectionName string +} + +// RenderMCPList renders a list of MCP status items with the given options. +func RenderMCPList(opts RenderOptions) []string { + t := styles.CurrentTheme() + mcpList := []string{} + + if opts.ShowSection { + sectionName := opts.SectionName + if sectionName == "" { + sectionName = "MCPs" + } + section := t.S().Subtle.Render(sectionName) + mcpList = append(mcpList, section, "") + } + + mcps := config.Get().MCP.Sorted() + if len(mcps) == 0 { + mcpList = append(mcpList, t.S().Base.Foreground(t.Border).Render("None")) + return mcpList + } + + // Get MCP states + mcpStates := agent.GetMCPStates() + + // Determine how many items to show + maxItems := len(mcps) + if opts.MaxItems > 0 { + maxItems = min(opts.MaxItems, len(mcps)) + } + + for i, l := range mcps { + if i >= maxItems { + break + } + + // Determine icon and color based on state + iconColor := t.FgMuted + description := l.MCP.Command + extraContent := "" + + if state, exists := mcpStates[l.Name]; exists { + switch state.State { + case agent.MCPStateDisabled: + iconColor = t.FgMuted + description = t.S().Subtle.Render("disabled") + case agent.MCPStateStarting: + iconColor = t.Yellow + description = t.S().Subtle.Render("starting...") + case agent.MCPStateConnected: + iconColor = t.Success + if state.ToolCount > 0 { + extraContent = t.S().Subtle.Render(fmt.Sprintf("(%d tools)", state.ToolCount)) + } + case agent.MCPStateError: + iconColor = t.Red + if state.Error != nil { + description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error())) + } else { + description = t.S().Subtle.Render("error") + } + } + } else if l.MCP.Disabled { + iconColor = t.FgMuted + description = t.S().Subtle.Render("disabled") + } + + mcpList = append(mcpList, + core.Status( + core.StatusOpts{ + IconColor: iconColor, + Title: l.Name, + Description: description, + ExtraContent: extraContent, + }, + opts.MaxWidth, + ), + ) + } + + return mcpList +} + +// RenderMCPBlock renders a complete MCP block with optional truncation indicator. +func RenderMCPBlock(opts RenderOptions, showTruncationIndicator bool) string { + t := styles.CurrentTheme() + mcpList := RenderMCPList(opts) + + // Add truncation indicator if needed + if showTruncationIndicator && opts.MaxItems > 0 { + mcps := config.Get().MCP.Sorted() + if len(mcps) > opts.MaxItems { + remaining := len(mcps) - opts.MaxItems + if remaining == 1 { + mcpList = append(mcpList, t.S().Base.Foreground(t.FgMuted).Render("…")) + } else { + mcpList = append(mcpList, + t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("…and %d more", remaining)), + ) + } + } + } + + content := lipgloss.JoinVertical(lipgloss.Left, mcpList...) + if opts.MaxWidth > 0 { + return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content) + } + return content +} From 38f1b2b6b7f01e68c039eb49f5bd2799c713f481 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 4 Aug 2025 12:04:11 +0200 Subject: [PATCH 10/39] chore: remove unused varibale --- internal/app/lsp_events.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal/app/lsp_events.go b/internal/app/lsp_events.go index 9af42eced75e01937285a1e8e2b9696ccd04039d..5961ec5c13e05fc42ff4eab7fbee744224a49694 100644 --- a/internal/app/lsp_events.go +++ b/internal/app/lsp_events.go @@ -2,7 +2,6 @@ package app import ( "context" - "sync" "time" "github.com/charmbracelet/crush/internal/csync" @@ -40,7 +39,6 @@ type LSPClientInfo struct { var ( lspStates = csync.NewMap[string, LSPClientInfo]() lspBroker = pubsub.NewBroker[LSPEvent]() - lspMutex sync.RWMutex ) // SubscribeLSPEvents returns a channel for LSP events From 87a483cc24696b0c23edddbcd39c8ae244888bbd Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Mon, 4 Aug 2025 09:16:26 -0300 Subject: [PATCH 11/39] chore(legal): @Ed4ward has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index f2a774e00890cc5d2059133563c44664862fcbd6..fd1813da29531a5ed0aa5f9c2886bf7bfb303de6 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -207,6 +207,14 @@ "created_at": "2025-08-03T21:35:15Z", "repoId": 987670088, "pullRequestNo": 527 + }, + { + "name": "Ed4ward", + "id": 153800328, + "comment_id": 3150375016, + "created_at": "2025-08-04T12:16:16Z", + "repoId": 987670088, + "pullRequestNo": 539 } ] } \ No newline at end of file From 8940ba1ddf3d686e2e8b3f29a04ab904ec2e8c66 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Sat, 2 Aug 2025 09:16:05 +0200 Subject: [PATCH 12/39] fix: fix openai compatible provider some providers expect simple content differently Co-authored-by: Peter Steinberger --- internal/llm/provider/openai.go | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/internal/llm/provider/openai.go b/internal/llm/provider/openai.go index b001353c9d94acebdf3eba9707c1525b65a38098..7075ddcc4dd8bceb14e8fa6837d2df391e9a1298 100644 --- a/internal/llm/provider/openai.go +++ b/internal/llm/provider/openai.go @@ -70,8 +70,9 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag systemMessage = o.providerOptions.systemPromptPrefix + "\n" + systemMessage } - systemTextBlock := openai.ChatCompletionContentPartTextParam{Text: systemMessage} + system := openai.SystemMessage(systemMessage) if isAnthropicModel && !o.providerOptions.disableCache { + systemTextBlock := openai.ChatCompletionContentPartTextParam{Text: systemMessage} systemTextBlock.SetExtraFields( map[string]any{ "cache_control": map[string]string{ @@ -79,10 +80,10 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag }, }, ) + var content []openai.ChatCompletionContentPartTextParam + content = append(content, systemTextBlock) + system = openai.SystemMessage(content) } - var content []openai.ChatCompletionContentPartTextParam - content = append(content, systemTextBlock) - system := openai.SystemMessage(content) openaiMessages = append(openaiMessages, system) for i, msg := range messages { @@ -93,9 +94,12 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag switch msg.Role { case message.User: var content []openai.ChatCompletionContentPartUnionParam + textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()} content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock}) + hasBinaryContent := false for _, binaryContent := range msg.BinaryContent() { + hasBinaryContent = true imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(catwalk.InferenceProviderOpenAI)} imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL} @@ -108,8 +112,11 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag }, }) } - - openaiMessages = append(openaiMessages, openai.UserMessage(content)) + if hasBinaryContent || (isAnthropicModel && !o.providerOptions.disableCache) { + openaiMessages = append(openaiMessages, openai.UserMessage(content)) + } else { + openaiMessages = append(openaiMessages, openai.UserMessage(msg.Content().String())) + } case message.Assistant: assistantMsg := openai.ChatCompletionAssistantMessageParam{ @@ -134,13 +141,15 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag }, }, } + if !isAnthropicModel { + assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{ + OfString: param.NewOpt(msg.Content().String()), + } + } } if len(msg.ToolCalls()) > 0 { hasContent = true - assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{ - OfString: param.NewOpt(msg.Content().String()), - } assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(msg.ToolCalls())) for i, call := range msg.ToolCalls() { assistantMsg.ToolCalls[i] = openai.ChatCompletionMessageToolCallParam{ From 7f7749601a0792f772c0fffdbc73e05121b5055e Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Sat, 2 Aug 2025 10:42:08 +0200 Subject: [PATCH 13/39] fix: handle nil body in http log --- internal/log/http.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/log/http.go b/internal/log/http.go index 1091e5706c09be374e6775f8906c91505e10b33f..2c74bd05201ad9cbd8d60c6e3c3db3f637fb99b3 100644 --- a/internal/log/http.go +++ b/internal/log/http.go @@ -79,6 +79,9 @@ func (h *HTTPRoundTripLogger) RoundTrip(req *http.Request) (*http.Response, erro } func bodyToString(body io.ReadCloser) string { + if body == nil { + return "" + } src, err := io.ReadAll(body) if err != nil { slog.Error("Failed to read body", "error", err) From 5177982f3ff60f98cf55e5cb33215cc742e48927 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Sat, 2 Aug 2025 14:23:41 +0200 Subject: [PATCH 14/39] chore: fix models --- internal/tui/components/chat/splash/splash.go | 12 ++---------- internal/tui/components/dialogs/models/models.go | 12 ++---------- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 9a74e79b30bbdcc9e0049f9fea55c23607fbc00a..c87f965f47298a9de726ce20a16587ffde7971ca 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -3,7 +3,6 @@ package splash import ( "fmt" "os" - "slices" "strings" "time" @@ -107,16 +106,9 @@ func (s *splashCmp) SetOnboarding(onboarding bool) { return } filteredProviders := []catwalk.Provider{} - simpleProviders := []string{ - "anthropic", - "openai", - "gemini", - "xai", - "groq", - "openrouter", - } + for _, p := range providers { - if slices.Contains(simpleProviders, string(p.ID)) { + if strings.HasPrefix(p.APIKey, "$") && p.ID != catwalk.InferenceProviderAzure { filteredProviders = append(filteredProviders, p) } } diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go index d863e1998ad91164822d11253c960d50b8d8199a..bdeaae4f3c7d86cfc7c68605574132c653022407 100644 --- a/internal/tui/components/dialogs/models/models.go +++ b/internal/tui/components/dialogs/models/models.go @@ -2,7 +2,7 @@ package models import ( "fmt" - "slices" + "strings" "time" "github.com/charmbracelet/bubbles/v2/help" @@ -99,16 +99,8 @@ func (m *modelDialogCmp) Init() tea.Cmd { providers, err := config.Providers() if err == nil { filteredProviders := []catwalk.Provider{} - simpleProviders := []string{ - "anthropic", - "openai", - "gemini", - "xai", - "groq", - "openrouter", - } for _, p := range providers { - if slices.Contains(simpleProviders, string(p.ID)) { + if strings.HasPrefix(p.APIKey, "$") && p.ID != catwalk.InferenceProviderAzure { filteredProviders = append(filteredProviders, p) } } From 0de5b24655432cfaf8268e1387251e902940dfef Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 4 Aug 2025 14:14:38 +0200 Subject: [PATCH 15/39] fix: some openai providers Co-authored-by: Peter Steinberger --- internal/llm/provider/openai.go | 98 ++++++++++++++------------------- 1 file changed, 41 insertions(+), 57 deletions(-) diff --git a/internal/llm/provider/openai.go b/internal/llm/provider/openai.go index 7075ddcc4dd8bceb14e8fa6837d2df391e9a1298..70bbe128663ce6163a93a2eb172e6d23f5873af3 100644 --- a/internal/llm/provider/openai.go +++ b/internal/llm/provider/openai.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "log/slog" + "slices" "strings" "time" @@ -14,6 +15,7 @@ import ( "github.com/charmbracelet/crush/internal/llm/tools" "github.com/charmbracelet/crush/internal/log" "github.com/charmbracelet/crush/internal/message" + "github.com/google/uuid" "github.com/openai/openai-go" "github.com/openai/openai-go/option" "github.com/openai/openai-go/packages/param" @@ -338,21 +340,16 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t acc := openai.ChatCompletionAccumulator{} currentContent := "" toolCalls := make([]message.ToolCall, 0) - - var currentToolCallID string - var currentToolCall openai.ChatCompletionMessageToolCall var msgToolCalls []openai.ChatCompletionMessageToolCall - currentToolIndex := 0 for openaiStream.Next() { chunk := openaiStream.Current() // Kujtim: this is an issue with openrouter qwen, its sending -1 for the tool index if len(chunk.Choices) > 0 && len(chunk.Choices[0].Delta.ToolCalls) > 0 && chunk.Choices[0].Delta.ToolCalls[0].Index == -1 { - chunk.Choices[0].Delta.ToolCalls[0].Index = int64(currentToolIndex) - currentToolIndex++ + chunk.Choices[0].Delta.ToolCalls[0].Index = 0 } acc.AddChunk(chunk) // This fixes multiple tool calls for some providers - for _, choice := range chunk.Choices { + for i, choice := range chunk.Choices { if choice.Delta.Content != "" { eventChan <- ProviderEvent{ Type: EventContentDelta, @@ -361,63 +358,50 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t currentContent += choice.Delta.Content } else if len(choice.Delta.ToolCalls) > 0 { toolCall := choice.Delta.ToolCalls[0] - // Detect tool use start - if currentToolCallID == "" { - if toolCall.ID != "" { - currentToolCallID = toolCall.ID - eventChan <- ProviderEvent{ - Type: EventToolUseStart, - ToolCall: &message.ToolCall{ - ID: toolCall.ID, - Name: toolCall.Function.Name, - Finished: false, - }, + newToolCall := false + if len(msgToolCalls)-1 >= int(toolCall.Index) { // tool call exists + existingToolCall := msgToolCalls[toolCall.Index] + if toolCall.ID != "" && toolCall.ID != existingToolCall.ID { + found := false + // try to find the tool based on the ID + for i, tool := range msgToolCalls { + if tool.ID == toolCall.ID { + msgToolCalls[i].Function.Arguments += toolCall.Function.Arguments + found = true + } } - currentToolCall = openai.ChatCompletionMessageToolCall{ - ID: toolCall.ID, - Type: "function", - Function: openai.ChatCompletionMessageToolCallFunction{ - Name: toolCall.Function.Name, - Arguments: toolCall.Function.Arguments, - }, + if !found { + newToolCall = true } - } - } else { - // Delta tool use - if toolCall.ID == "" || toolCall.ID == currentToolCallID { - currentToolCall.Function.Arguments += toolCall.Function.Arguments } else { - // Detect new tool use - if toolCall.ID != currentToolCallID { - msgToolCalls = append(msgToolCalls, currentToolCall) - currentToolCallID = toolCall.ID - eventChan <- ProviderEvent{ - Type: EventToolUseStart, - ToolCall: &message.ToolCall{ - ID: toolCall.ID, - Name: toolCall.Function.Name, - Finished: false, - }, - } - currentToolCall = openai.ChatCompletionMessageToolCall{ - ID: toolCall.ID, - Type: "function", - Function: openai.ChatCompletionMessageToolCallFunction{ - Name: toolCall.Function.Name, - Arguments: toolCall.Function.Arguments, - }, - } - } + msgToolCalls[toolCall.Index].Function.Arguments += toolCall.Function.Arguments } + } else { + newToolCall = true } - } - // Kujtim: some models send finish stop even for tool calls - if choice.FinishReason == "tool_calls" || (choice.FinishReason == "stop" && currentToolCallID != "") { - msgToolCalls = append(msgToolCalls, currentToolCall) - if len(acc.Choices) > 0 { - acc.Choices[0].Message.ToolCalls = msgToolCalls + if newToolCall { // new tool call + if toolCall.ID == "" { + toolCall.ID = uuid.NewString() + } + eventChan <- ProviderEvent{ + Type: EventToolUseStart, + ToolCall: &message.ToolCall{ + ID: toolCall.ID, + Name: toolCall.Function.Name, + Finished: false, + }, + } + msgToolCalls = append(msgToolCalls, openai.ChatCompletionMessageToolCall{ + ID: toolCall.ID, + Type: "function", + Function: openai.ChatCompletionMessageToolCallFunction{ + Name: toolCall.Function.Name, + Arguments: toolCall.Function.Arguments, + }, + }) } } + acc.Choices[i].Message.ToolCalls = slices.Clone(msgToolCalls) } } From d9b0062208beee9e71cfb3e606a7de377b8c9493 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 4 Aug 2025 14:21:53 +0200 Subject: [PATCH 16/39] chore: simplify model filtering --- internal/tui/components/chat/splash/splash.go | 14 -------------- internal/tui/components/dialogs/models/list.go | 16 ++++++++++++---- internal/tui/components/dialogs/models/models.go | 11 ----------- 3 files changed, 12 insertions(+), 29 deletions(-) diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index c87f965f47298a9de726ce20a16587ffde7971ca..b0681c798631ee8cf3960fda26bfc2416aff2692 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -100,20 +100,6 @@ func New() Splash { func (s *splashCmp) SetOnboarding(onboarding bool) { s.isOnboarding = onboarding - if onboarding { - providers, err := config.Providers() - if err != nil { - return - } - filteredProviders := []catwalk.Provider{} - - for _, p := range providers { - if strings.HasPrefix(p.APIKey, "$") && p.ID != catwalk.InferenceProviderAzure { - filteredProviders = append(filteredProviders, p) - } - } - s.modelList.SetProviders(filteredProviders) - } } func (s *splashCmp) SetProjectInit(needsInit bool) { diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go index 4d2986d2192568c9aac4db76f9011552ac61ee34..8c22094323ca2274a421ba50d668beaa42616041 100644 --- a/internal/tui/components/dialogs/models/list.go +++ b/internal/tui/components/dialogs/models/list.go @@ -3,10 +3,12 @@ package models import ( "fmt" "slices" + "strings" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/env" "github.com/charmbracelet/crush/internal/tui/exp/list" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" @@ -49,6 +51,16 @@ func (m *ModelListComponent) Init() tea.Cmd { var cmds []tea.Cmd if len(m.providers) == 0 { providers, err := config.Providers() + filteredProviders := []catwalk.Provider{} + for _, p := range providers { + hasApiKeyEnv := strings.HasPrefix(p.APIKey, "$") + resolver := config.NewEnvironmentVariableResolver(env.New()) + endpoint, _ := resolver.ResolveValue(p.APIEndpoint) + if endpoint != "" && hasApiKeyEnv { + filteredProviders = append(filteredProviders, p) + } + } + m.providers = providers if err != nil { cmds = append(cmds, util.ReportError(err)) @@ -242,7 +254,3 @@ func (m *ModelListComponent) GetModelType() int { func (m *ModelListComponent) SetInputPlaceholder(placeholder string) { m.list.SetInputPlaceholder(placeholder) } - -func (m *ModelListComponent) SetProviders(providers []catwalk.Provider) { - m.providers = providers -} diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go index bdeaae4f3c7d86cfc7c68605574132c653022407..e09b040a52ebf911ceefc455b0892c7c9ceba754 100644 --- a/internal/tui/components/dialogs/models/models.go +++ b/internal/tui/components/dialogs/models/models.go @@ -2,7 +2,6 @@ package models import ( "fmt" - "strings" "time" "github.com/charmbracelet/bubbles/v2/help" @@ -96,16 +95,6 @@ func NewModelDialogCmp() ModelDialog { } func (m *modelDialogCmp) Init() tea.Cmd { - providers, err := config.Providers() - if err == nil { - filteredProviders := []catwalk.Provider{} - for _, p := range providers { - if strings.HasPrefix(p.APIKey, "$") && p.ID != catwalk.InferenceProviderAzure { - filteredProviders = append(filteredProviders, p) - } - } - m.modelList.SetProviders(filteredProviders) - } return tea.Batch(m.modelList.Init(), m.apiKeyInput.Init()) } From bfd089d4193cbd3da9d81a4ac52852ab08eeee89 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 4 Aug 2025 14:54:55 +0200 Subject: [PATCH 17/39] chore: fix missing assignment --- internal/tui/components/dialogs/models/list.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go index 8c22094323ca2274a421ba50d668beaa42616041..bcec9e981228202d8eb10c86e34642043b499634 100644 --- a/internal/tui/components/dialogs/models/list.go +++ b/internal/tui/components/dialogs/models/list.go @@ -61,7 +61,7 @@ func (m *ModelListComponent) Init() tea.Cmd { } } - m.providers = providers + m.providers = filteredProviders if err != nil { cmds = append(cmds, util.ReportError(err)) } From 7055d9e7a8f8888f06e5122fd1ac1b2a1c819df2 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 4 Aug 2025 15:26:51 +0200 Subject: [PATCH 18/39] chore: small fix --- internal/llm/agent/mcp-tools.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 7affe74bd393ac88b0422cc24dd8a91c4d169e73..429f0881f44ec63a3c7c1beaa0876bef8b76960a 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -299,8 +299,7 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con mcpClients.Set(name, c) tools := getTools(ctx, name, permissions, c, cfg.WorkingDir()) - toolCount := len(tools) - updateMCPState(name, MCPStateConnected, nil, c, toolCount) + updateMCPState(name, MCPStateConnected, nil, c, len(tools)) result.Append(tools...) }(name, m) } From 054fba302914e64a36ce58e80d615df82f92267a Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Mon, 4 Aug 2025 11:01:45 -0300 Subject: [PATCH 19/39] chore(legal): @ngnhng has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index fd1813da29531a5ed0aa5f9c2886bf7bfb303de6..0b2aa1335dadaefee2b547c3d85278006c4425f4 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -215,6 +215,14 @@ "created_at": "2025-08-04T12:16:16Z", "repoId": 987670088, "pullRequestNo": 539 + }, + { + "name": "ngnhng", + "id": 51743767, + "comment_id": 3150846779, + "created_at": "2025-08-04T14:01:30Z", + "repoId": 987670088, + "pullRequestNo": 546 } ] } \ No newline at end of file From 1b50cb61c81702d0a94bded3b0007ec3d4c348c1 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 4 Aug 2025 16:05:09 +0200 Subject: [PATCH 20/39] chore: reasoning for openai providers that support it --- internal/llm/provider/openai.go | 13 ++++++++++++- internal/tui/components/chat/messages/messages.go | 3 +++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/internal/llm/provider/openai.go b/internal/llm/provider/openai.go index 70bbe128663ce6163a93a2eb172e6d23f5873af3..9b612b64d63a55d278a686ba5b3f7bda7f973c69 100644 --- a/internal/llm/provider/openai.go +++ b/internal/llm/provider/openai.go @@ -2,6 +2,7 @@ package provider import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -348,8 +349,18 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t chunk.Choices[0].Delta.ToolCalls[0].Index = 0 } acc.AddChunk(chunk) - // This fixes multiple tool calls for some providers for i, choice := range chunk.Choices { + reasoning, ok := choice.Delta.JSON.ExtraFields["reasoning"] + if ok && reasoning.Raw() != "" { + reasoningStr := "" + json.Unmarshal([]byte(reasoning.Raw()), &reasoningStr) + if reasoningStr != "" { + eventChan <- ProviderEvent{ + Type: EventThinkingDelta, + Thinking: reasoningStr, + } + } + } if choice.Delta.Content != "" { eventChan <- ProviderEvent{ Type: EventContentDelta, diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 17bb582dcadbea1f314b976bc31a31639f8d9609..17c157df5292280c6f094ec4e0f95bee82c6a77b 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -274,6 +274,9 @@ func (m *messageCmp) renderThinkingContent() string { if reasoningContent.StartedAt > 0 { duration := m.message.ThinkingDuration() if reasoningContent.FinishedAt > 0 { + if duration.String() == "0s" { + return "" + } m.anim.SetLabel("") opts := core.StatusOpts{ Title: "Thought for", From 87ee7dfa77f871b5c31ef5e8d9940b05bd03c7bf Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 4 Aug 2025 11:54:55 -0300 Subject: [PATCH 21/39] fix(mcp): update lib, set transport logger (#510) --- go.mod | 2 +- go.sum | 4 ++-- internal/llm/agent/mcp-tools.go | 11 ++++++++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 82b89a581d7bbd35698ed5f555ad0e1b324b9ff4..7e41d27cb2c2b8c7382ebfefaeab5188c5dc1e67 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/google/uuid v1.6.0 github.com/invopop/jsonschema v0.13.0 github.com/joho/godotenv v1.5.1 - github.com/mark3labs/mcp-go v0.34.0 + github.com/mark3labs/mcp-go v0.36.0 github.com/muesli/termenv v0.16.0 github.com/ncruces/go-sqlite3 v0.25.0 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 diff --git a/go.sum b/go.sum index c223c37700b6b9f3f3fc0fb6c537c734bab63ac7..37c3c7ed4509a19f5732262c904cf01ad329a51b 100644 --- a/go.sum +++ b/go.sum @@ -180,8 +180,8 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mark3labs/mcp-go v0.34.0 h1:eWy7WBGvhk6EyAAyVzivTCprE52iXJwNtvHV6Cv3bR0= -github.com/mark3labs/mcp-go v0.34.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= +github.com/mark3labs/mcp-go v0.36.0 h1:rIZaijrRYPeSbJG8/qNDe0hWlGrCJ7FWHNMz2SQpTis= +github.com/mark3labs/mcp-go v0.36.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 97f00c92d156b5d50c2a6a84d1f853a0db1246aa..8df78e52452385a44a06fd07feb62d3b9892388f 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -11,10 +11,8 @@ import ( "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/llm/tools" - "github.com/charmbracelet/crush/internal/version" - "github.com/charmbracelet/crush/internal/permission" - + "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" @@ -191,6 +189,7 @@ func createMcpClient(m config.MCPConfig) (*client.Client, error) { return client.NewStreamableHttpClient( m.URL, transport.WithHTTPHeaders(m.ResolvedHeaders()), + transport.WithLogger(mcpHTTPLogger{}), ) case config.MCPSse: return client.NewSSEMCPClient( @@ -201,3 +200,9 @@ func createMcpClient(m config.MCPConfig) (*client.Client, error) { return nil, fmt.Errorf("unsupported mcp type: %s", m.Type) } } + +// for MCP's HTTP client. +type mcpHTTPLogger struct{} + +func (l mcpHTTPLogger) Errorf(format string, v ...any) { slog.Error(fmt.Sprintf(format, v...)) } +func (l mcpHTTPLogger) Infof(format string, v ...any) { slog.Info(fmt.Sprintf(format, v...)) } From f2829452eaa6c3807d861e4c0a395805d44522a0 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 4 Aug 2025 12:59:25 -0300 Subject: [PATCH 22/39] ci: add scoop (#545) --- .goreleaser.yml | 6 ++++++ 1 file changed, 6 insertions(+) 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" From 8d67777c6662953330d489ff15c0a10beea91acc Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 4 Aug 2025 13:51:58 -0300 Subject: [PATCH 23/39] ci: fix dependabot dependencies label Signed-off-by: Carlos Alexandro Becker --- .github/dependabot.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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" From 5fabd4d5df7d4bacf46b6965ebe6cad3eb8b768a Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 4 Aug 2025 14:08:00 -0300 Subject: [PATCH 24/39] ci: print action --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) 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}} From af852382fb379b77c37aab7d09b06de6722c1ff4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Aug 2025 14:37:29 -0300 Subject: [PATCH 25/39] chore(deps): bump github.com/alecthomas/chroma/v2 from 2.15.0 to 2.20.0 (#549) Bumps [github.com/alecthomas/chroma/v2](https://github.com/alecthomas/chroma) from 2.15.0 to 2.20.0. - [Release notes](https://github.com/alecthomas/chroma/releases) - [Changelog](https://github.com/alecthomas/chroma/blob/master/.goreleaser.yml) - [Commits](https://github.com/alecthomas/chroma/compare/v2.15.0...v2.20.0) --- updated-dependencies: - dependency-name: github.com/alecthomas/chroma/v2 dependency-version: 2.20.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index 7e41d27cb2c2b8c7382ebfefaeab5188c5dc1e67..82cfa460ff58331d8157c5d8b710441f58ab42ab 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 diff --git a/go.sum b/go.sum index 37c3c7ed4509a19f5732262c904cf01ad329a51b..d93215ff184daac15e42478ab57b28b3f2c79c00 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= From 5f79b36a40493255d4513477412668b90d585186 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Aug 2025 14:40:19 -0300 Subject: [PATCH 26/39] chore(deps): bump golang.org/x/text from 0.25.0 to 0.27.0 (#544) Bumps [golang.org/x/text](https://github.com/golang/text) from 0.25.0 to 0.27.0. - [Release notes](https://github.com/golang/text/releases) - [Commits](https://github.com/golang/text/compare/v0.25.0...v0.27.0) --- updated-dependencies: - dependency-name: golang.org/x/text dependency-version: 0.27.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 82cfa460ff58331d8157c5d8b710441f58ab42ab..554ddfe41d2b9109593014798c04b83c0b2edbf9 100644 --- a/go.mod +++ b/go.mod @@ -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 d93215ff184daac15e42478ab57b28b3f2c79c00..a92dbc037bbb4c28ca94b7572954d74a041bb56a 100644 --- a/go.sum +++ b/go.sum @@ -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= From 75fd9510edbf29acf9507ff82dcf8230bd33c257 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 4 Aug 2025 23:14:44 +0200 Subject: [PATCH 27/39] chore: update catwalk --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 7e41d27cb2c2b8c7382ebfefaeab5188c5dc1e67..6f12853c9f7f70b78544049f950b1264af918c68 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/charlievieth/fastwalk v1.0.11 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d - github.com/charmbracelet/catwalk v0.3.5 + github.com/charmbracelet/catwalk v0.4.5 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250721205738-ea66aa652ee0 @@ -56,7 +56,7 @@ require ( github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect - golang.org/x/oauth2 v0.25.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/time v0.8.0 // indirect google.golang.org/api v0.211.0 // indirect ) diff --git a/go.sum b/go.sum index 37c3c7ed4509a19f5732262c904cf01ad329a51b..7913df3dee8435f583171fc1d4ec404ca9ab5134 100644 --- a/go.sum +++ b/go.sum @@ -78,8 +78,8 @@ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d h1:YMXLZHSo8DjytVY/b5dK8LDuyQsVUmBK3ydQMpu2Ui4= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d/go.mod h1:XIQ1qQfRph6Z5o2EikCydjumo0oDInQySRHuPATzbZc= -github.com/charmbracelet/catwalk v0.3.5 h1:ChMvA5ooTNZhDKFagmGNQgIZvZp8XjpdaJ+cDmhgCgA= -github.com/charmbracelet/catwalk v0.3.5/go.mod h1:gUUCqqZ8bk4D7ZzGTu3I77k7cC2x4exRuJBN1H2u2pc= +github.com/charmbracelet/catwalk v0.4.5 h1:Kv3PadDe8IF8gpcYTfAJdCee5Bv4HufvtNT61FXtq5g= +github.com/charmbracelet/catwalk v0.4.5/go.mod h1:WnKgNPmQHuMyk7GtwAQwl+ezHusfH40IvzML2qwUGwc= github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 h1:+Cz+VfxD5DO+JT1LlswXWhre0HYLj6l2HW8HVGfMuC0= @@ -332,8 +332,8 @@ golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= -golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= -golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= From ec4c6f7898169a9948340617ddbacd25e978b760 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 4 Aug 2025 23:26:17 +0200 Subject: [PATCH 28/39] chore: small fix --- internal/tui/components/dialogs/models/list.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go index bcec9e981228202d8eb10c86e34642043b499634..66b55d85b299cb0bacb4cc2466c7b4146248ba05 100644 --- a/internal/tui/components/dialogs/models/list.go +++ b/internal/tui/components/dialogs/models/list.go @@ -8,7 +8,6 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/env" "github.com/charmbracelet/crush/internal/tui/exp/list" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" @@ -53,10 +52,8 @@ func (m *ModelListComponent) Init() tea.Cmd { providers, err := config.Providers() filteredProviders := []catwalk.Provider{} for _, p := range providers { - hasApiKeyEnv := strings.HasPrefix(p.APIKey, "$") - resolver := config.NewEnvironmentVariableResolver(env.New()) - endpoint, _ := resolver.ResolveValue(p.APIEndpoint) - if endpoint != "" && hasApiKeyEnv { + hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$") + if hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure { filteredProviders = append(filteredProviders, p) } } From dcbc27300c749b6eacf2b3d6d39756a0e757bdd7 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Mon, 4 Aug 2025 18:55:53 -0300 Subject: [PATCH 29/39] chore(legal): @zloeber has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 0b2aa1335dadaefee2b547c3d85278006c4425f4..437f9f08ec22936ec32b680bfc9ffacf31985ec5 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -223,6 +223,14 @@ "created_at": "2025-08-04T14:01:30Z", "repoId": 987670088, "pullRequestNo": 546 + }, + { + "name": "zloeber", + "id": 4702624, + "comment_id": 3152513500, + "created_at": "2025-08-04T21:55:42Z", + "repoId": 987670088, + "pullRequestNo": 564 } ] } \ No newline at end of file From bcac79a326364948e82318a5ffa2332f0bc5a966 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Mon, 4 Aug 2025 21:25:00 -0300 Subject: [PATCH 30/39] chore(legal): @nelsenm2 has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 437f9f08ec22936ec32b680bfc9ffacf31985ec5..85eb9a13ef8835ecb9b663b9086c2cde8d734415 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -231,6 +231,14 @@ "created_at": "2025-08-04T21:55:42Z", "repoId": 987670088, "pullRequestNo": 564 + }, + { + "name": "nelsenm2", + "id": 197524521, + "comment_id": 3152872109, + "created_at": "2025-08-05T00:24:50Z", + "repoId": 987670088, + "pullRequestNo": 569 } ] } \ No newline at end of file From 19d3cd06eed1a9cb594a0e31bd18d113d6de5729 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Mon, 4 Aug 2025 20:58:03 -0400 Subject: [PATCH 31/39] chore: change LSP/MCP loading icon color + refactor icons + theme prep --- .../tui/components/chat/messages/messages.go | 1 - internal/tui/components/core/core.go | 39 +++++++------------ internal/tui/components/files/files.go | 2 - internal/tui/components/lsp/lsp.go | 12 +++--- internal/tui/components/mcp/mcp.go | 12 +++--- internal/tui/styles/crush.go | 11 +++++- internal/tui/styles/theme.go | 6 +++ 7 files changed, 41 insertions(+), 42 deletions(-) diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 17bb582dcadbea1f314b976bc31a31639f8d9609..907efab4eaf22aac6ce4f5ff2ec5afbb244aed2b 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -278,7 +278,6 @@ func (m *messageCmp) renderThinkingContent() string { opts := core.StatusOpts{ Title: "Thought for", Description: duration.String(), - NoIcon: true, } return t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1)) } else if finishReason != nil && finishReason.Reason == message.FinishReasonCanceled { diff --git a/internal/tui/components/core/core.go b/internal/tui/components/core/core.go index 9f6657de33d5ed824b7d5bc7086342e8f3ec7d48..18de56b17f08e4513bde34fe9fef7aaf4e08c09f 100644 --- a/internal/tui/components/core/core.go +++ b/internal/tui/components/core/core.go @@ -82,41 +82,30 @@ func Title(title string, width int) string { } type StatusOpts struct { - Icon string - IconColor color.Color - NoIcon bool // If true, no icon will be displayed + Icon string // if empty no icon will be shown Title string TitleColor color.Color Description string DescriptionColor color.Color - ExtraContent string // Additional content to append after the description + ExtraContent string // additional content to append after the description } -func Status(ops StatusOpts, width int) string { +func Status(opts StatusOpts, width int) string { t := styles.CurrentTheme() - icon := "●" - iconColor := t.Success - if ops.Icon != "" { - icon = ops.Icon - } else if ops.NoIcon { - icon = "" - } - if ops.IconColor != nil { - iconColor = ops.IconColor - } - title := ops.Title + icon := opts.Icon + title := opts.Title titleColor := t.FgMuted - if ops.TitleColor != nil { - titleColor = ops.TitleColor + if opts.TitleColor != nil { + titleColor = opts.TitleColor } - description := ops.Description + description := opts.Description descriptionColor := t.FgSubtle - if ops.DescriptionColor != nil { - descriptionColor = ops.DescriptionColor + if opts.DescriptionColor != nil { + descriptionColor = opts.DescriptionColor } title = t.S().Base.Foreground(titleColor).Render(title) if description != "" { - extraContentWidth := lipgloss.Width(ops.ExtraContent) + extraContentWidth := lipgloss.Width(opts.ExtraContent) if extraContentWidth > 0 { extraContentWidth += 1 } @@ -126,11 +115,11 @@ func Status(ops StatusOpts, width int) string { content := []string{} if icon != "" { - content = append(content, t.S().Base.Foreground(iconColor).Render(icon)) + content = append(content, icon) } content = append(content, title, description) - if ops.ExtraContent != "" { - content = append(content, ops.ExtraContent) + if opts.ExtraContent != "" { + content = append(content, opts.ExtraContent) } return strings.Join(content, " ") diff --git a/internal/tui/components/files/files.go b/internal/tui/components/files/files.go index 234a75fd4e06431018eab1fbf37e90e562da4083..8272bd53900acf4dd032f86b8f9d2a0bd3b52ccd 100644 --- a/internal/tui/components/files/files.go +++ b/internal/tui/components/files/files.go @@ -98,8 +98,6 @@ func RenderFileList(fileSlice []SessionFile, opts RenderOptions) []string { fileList = append(fileList, core.Status( core.StatusOpts{ - IconColor: t.FgMuted, - NoIcon: true, Title: filePath, ExtraContent: extraContent, }, diff --git a/internal/tui/components/lsp/lsp.go b/internal/tui/components/lsp/lsp.go index 10d9f42198a6996e966d01305131e734fa54a614..893e6470d9f99992fe9d195e5500db8a3891c763 100644 --- a/internal/tui/components/lsp/lsp.go +++ b/internal/tui/components/lsp/lsp.go @@ -57,22 +57,22 @@ func RenderLSPList(lspClients map[string]*lsp.Client, opts RenderOptions) []stri } // Determine icon color and description based on state - iconColor := t.FgMuted + // iconColor := t.FgMuted + icon := t.ItemOfflineIcon 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 + icon = t.ItemBusyIcon description = t.S().Subtle.Render("starting...") case lsp.StateReady: - iconColor = t.Success + icon = t.ItemOnlineIcon description = l.LSP.Command case lsp.StateError: - iconColor = t.Red + icon = t.ItemErrorIcon if state.Error != nil { description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error())) } else { @@ -119,7 +119,7 @@ func RenderLSPList(lspClients map[string]*lsp.Client, opts RenderOptions) []stri lspList = append(lspList, core.Status( core.StatusOpts{ - IconColor: iconColor, + Icon: icon.String(), Title: l.Name, Description: description, ExtraContent: extraContent, diff --git a/internal/tui/components/mcp/mcp.go b/internal/tui/components/mcp/mcp.go index 93f2dcb230721ab95c3ea2f4937647ff7ccf5bda..2376011ae1f18f44962d59f142652a52bfc47c3d 100644 --- a/internal/tui/components/mcp/mcp.go +++ b/internal/tui/components/mcp/mcp.go @@ -54,25 +54,24 @@ func RenderMCPList(opts RenderOptions) []string { } // Determine icon and color based on state - iconColor := t.FgMuted + icon := t.ItemOfflineIcon 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 + icon = t.ItemBusyIcon description = t.S().Subtle.Render("starting...") case agent.MCPStateConnected: - iconColor = t.Success + icon = t.ItemOnlineIcon if state.ToolCount > 0 { extraContent = t.S().Subtle.Render(fmt.Sprintf("(%d tools)", state.ToolCount)) } case agent.MCPStateError: - iconColor = t.Red + icon = t.ItemErrorIcon if state.Error != nil { description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error())) } else { @@ -80,14 +79,13 @@ func RenderMCPList(opts RenderOptions) []string { } } } else if l.MCP.Disabled { - iconColor = t.FgMuted description = t.S().Subtle.Render("disabled") } mcpList = append(mcpList, core.Status( core.StatusOpts{ - IconColor: iconColor, + Icon: icon.String(), Title: l.Name, Description: description, ExtraContent: extraContent, diff --git a/internal/tui/styles/crush.go b/internal/tui/styles/crush.go index 2c54d5e41c91521b9418cdcdd4bcbc5dc7231eee..f27632784ad64ed3228ee548c7c8fe84b58bc9ec 100644 --- a/internal/tui/styles/crush.go +++ b/internal/tui/styles/crush.go @@ -1,11 +1,12 @@ package styles import ( + "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/x/exp/charmtone" ) func NewCrushTheme() *Theme { - return &Theme{ + t := &Theme{ Name: "crush", IsDark: true, @@ -54,4 +55,12 @@ func NewCrushTheme() *Theme { RedLight: charmtone.Salmon, Cherry: charmtone.Cherry, } + + // LSP and MCP status. + t.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("●") + t.ItemBusyIcon = t.ItemOfflineIcon.Foreground(charmtone.Citron) + t.ItemErrorIcon = t.ItemOfflineIcon.Foreground(charmtone.Coral) + t.ItemOnlineIcon = t.ItemOfflineIcon.Foreground(charmtone.Guac) + + return t } diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go index 1d6967684c6ccb5c8f9db2dd23300600b2b5af15..e917cb2b6ffc1ff864012366e0711b66ccf1be83 100644 --- a/internal/tui/styles/theme.go +++ b/internal/tui/styles/theme.go @@ -74,6 +74,12 @@ type Theme struct { RedLight color.Color Cherry color.Color + // LSP and MCP status indicators. + ItemOfflineIcon lipgloss.Style + ItemBusyIcon lipgloss.Style + ItemErrorIcon lipgloss.Style + ItemOnlineIcon lipgloss.Style + styles *Styles } From f5f7a10b2650201606866a1cb220b5540a25cbf8 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Mon, 4 Aug 2025 21:35:37 -0400 Subject: [PATCH 32/39] chore(tests): update golden files for status --- CRUSH.md | 2 ++ internal/tui/components/core/status_test.go | 3 --- .../core/testdata/TestStatus/AllFieldsWithExtraContent.golden | 2 +- .../tui/components/core/testdata/TestStatus/Default.golden | 2 +- .../core/testdata/TestStatus/EmptyDescription.golden | 2 +- .../components/core/testdata/TestStatus/LongDescription.golden | 2 +- .../tui/components/core/testdata/TestStatus/NarrowWidth.golden | 2 +- .../components/core/testdata/TestStatus/VeryNarrowWidth.golden | 2 +- .../tui/components/core/testdata/TestStatus/WithColors.golden | 2 +- .../components/core/testdata/TestStatus/WithCustomIcon.golden | 2 +- .../core/testdata/TestStatus/WithExtraContent.golden | 2 +- .../core/testdata/TestStatusTruncation/Width20.golden | 2 +- .../core/testdata/TestStatusTruncation/Width30.golden | 2 +- .../core/testdata/TestStatusTruncation/Width40.golden | 2 +- .../core/testdata/TestStatusTruncation/Width50.golden | 2 +- .../core/testdata/TestStatusTruncation/Width60.golden | 2 +- 16 files changed, 16 insertions(+), 17 deletions(-) diff --git a/CRUSH.md b/CRUSH.md index 69132723e99e20b6e1d56ee79f7c777e79ce06d8..5a3104b6685fb5e246c77d416d4a12adeda91734 100644 --- a/CRUSH.md +++ b/CRUSH.md @@ -4,6 +4,8 @@ - **Build**: `go build .` or `go run .` - **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`) +- **Update Golden Files**: `go test ./... -update` (regenerates .golden files when test output changes) + - Update specific package: `go test ./internal/tui/components/core -update` (in this case, we're updating "core") - **Lint**: `task lint-fix` - **Format**: `task fmt` (gofumpt -w .) - **Dev**: `task dev` (runs with profiling enabled) diff --git a/internal/tui/components/core/status_test.go b/internal/tui/components/core/status_test.go index 0b24dc321d8863c8bad2bc4fc38e38020230a7f5..c82fc5b2a3e735e1eafd385b74ae5a4877032bd9 100644 --- a/internal/tui/components/core/status_test.go +++ b/internal/tui/components/core/status_test.go @@ -37,7 +37,6 @@ func TestStatus(t *testing.T) { { name: "NoIcon", opts: core.StatusOpts{ - NoIcon: true, Title: "Info", Description: "This status has no icon", }, @@ -47,7 +46,6 @@ func TestStatus(t *testing.T) { name: "WithColors", opts: core.StatusOpts{ Icon: "⚠", - IconColor: color.RGBA{255, 165, 0, 255}, // Orange Title: "Warning", TitleColor: color.RGBA{255, 255, 0, 255}, // Yellow Description: "This is a warning message", @@ -102,7 +100,6 @@ func TestStatus(t *testing.T) { name: "AllFieldsWithExtraContent", opts: core.StatusOpts{ Icon: "🚀", - IconColor: color.RGBA{0, 255, 0, 255}, // Green Title: "Deployment", TitleColor: color.RGBA{0, 0, 255, 255}, // Blue Description: "Deploying to production environment", diff --git a/internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden b/internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden index e6f7fb0be25997b79c3d39bddedee2f2d7b11b72..89477e3738e6547ea26734e8a49df5d281d70c57 100644 --- a/internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden +++ b/internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden @@ -1 +1 @@ -🚀 Deployment Deploying to production environment v1.2.3 \ No newline at end of file +🚀 Deployment Deploying to production environment v1.2.3 \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/Default.golden b/internal/tui/components/core/testdata/TestStatus/Default.golden index a0066dedd418dafe54757dc3159b3a6b11d106ca..2151efd10b7aeb6500b55a0e61fbf5d4a6ef1638 100644 --- a/internal/tui/components/core/testdata/TestStatus/Default.golden +++ b/internal/tui/components/core/testdata/TestStatus/Default.golden @@ -1 +1 @@ -● Status Everything is working fine \ No newline at end of file +Status Everything is working fine \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden b/internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden index f9c4d759b50d02598791a6462f8e9cab2e0a0b6d..5b396377658610dd0fbc0746fd960f2faaf76f49 100644 --- a/internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden +++ b/internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden @@ -1 +1 @@ -● Title Only  \ No newline at end of file +● Title Only  \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/LongDescription.golden b/internal/tui/components/core/testdata/TestStatus/LongDescription.golden index f008176649f7941b9f1ee6276f6e65fea36d4c52..13fc6c3335871aaa5513d370d078f8e350571abe 100644 --- a/internal/tui/components/core/testdata/TestStatus/LongDescription.golden +++ b/internal/tui/components/core/testdata/TestStatus/LongDescription.golden @@ -1 +1 @@ -● Processing This is a very long description that should be… \ No newline at end of file +Processing This is a very long description that should be … \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden b/internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden index 5b9efd7dbb74dcf56344567c1918b470f90eace7..0c5b8e93c35e302038e019d58682716b1b220ef7 100644 --- a/internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden +++ b/internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden @@ -1 +1 @@ -● Status Short message \ No newline at end of file +● Status Short message \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden b/internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden index 26628ae3bc28acd49e8f30e60f65912fe563c0e6..9bb3917977486b8f862c74db4f43951a9c44a450 100644 --- a/internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden +++ b/internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden @@ -1 +1 @@ -● Test This will be… \ No newline at end of file +● Test This will be… \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/WithColors.golden b/internal/tui/components/core/testdata/TestStatus/WithColors.golden index ff0e3a6ec4847c4786387d26c9752f664d78cd51..97eeb24db9a9803f4d8877296d38a9d878b50fed 100644 --- a/internal/tui/components/core/testdata/TestStatus/WithColors.golden +++ b/internal/tui/components/core/testdata/TestStatus/WithColors.golden @@ -1 +1 @@ -⚠ Warning This is a warning message \ No newline at end of file +⚠ Warning This is a warning message \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden b/internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden index 6857f0d29dd58886308e15ea50c7e0822834f2ee..00cf9455b72e0fd3b8fc94e48b09053bb3fde60a 100644 --- a/internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden +++ b/internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden @@ -1 +1 @@ -✓ Success Operation completed successfully \ No newline at end of file +✓ Success Operation completed successfully \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden b/internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden index 47b02e81b5ec4fc0d0c5dd54545d9634811b1636..292d1fa97f0400a7c411eff5a658af537fc8b69e 100644 --- a/internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden +++ b/internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden @@ -1 +1 @@ -● Build Building project [2/5] \ No newline at end of file +Build Building project [2/5] \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden b/internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden index 4437cba67aa068c2597e558000b9b3005478b378..0df96289f5aa373f174aa9f833478d5c559abe53 100644 --- a/internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden +++ b/internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden @@ -1 +1 @@ -● Very Long Title  [extra] \ No newline at end of file +● Very Long Title  [extra] \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden b/internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden index b09cc983c97382e4d92719bb5606d22f9dc2301f..56915d1966ab547740910398b101fd70371bb264 100644 --- a/internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden +++ b/internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden @@ -1 +1 @@ -● Very Long Title Thi… [extra] \ No newline at end of file +● Very Long Title Thi… [extra] \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden b/internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden index 5113ce07a0b07d1cfddbcbae0c14046546308f2a..6b249b2f865698ebc73ed7787daad30ddf417945 100644 --- a/internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden +++ b/internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden @@ -1 +1 @@ -● Very Long Title This is an ex… [extra] \ No newline at end of file +● Very Long Title This is an ex… [extra] \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden b/internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden index 25bd8723b0cd461311364ecaac92a2b93f00ecd9..1862198d631f525c3080f7f811ade5a5738658b1 100644 --- a/internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden +++ b/internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden @@ -1 +1 @@ -● Very Long Title This is an extremely lo… [extra] \ No newline at end of file +● Very Long Title This is an extremely lo… [extra] \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden b/internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden index 0152f1c2d0ac9e011d744e0cd02283c18edc8d03..0f29e46d2660d1bf2584c730c50972e962c4dd32 100644 --- a/internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden +++ b/internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden @@ -1 +1 @@ -● Very Long Title This is an extremely long descrip… [extra] \ No newline at end of file +● Very Long Title This is an extremely long descrip… [extra] \ No newline at end of file From 40e16d1913ae73647b3c87c2d5d01a45bf6523e2 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Mon, 4 Aug 2025 21:36:37 -0400 Subject: [PATCH 33/39] chore: remove commented code Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/tui/components/lsp/lsp.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/tui/components/lsp/lsp.go b/internal/tui/components/lsp/lsp.go index 893e6470d9f99992fe9d195e5500db8a3891c763..f2546c945e436ca196064dda5b50d35583d5b2ab 100644 --- a/internal/tui/components/lsp/lsp.go +++ b/internal/tui/components/lsp/lsp.go @@ -57,7 +57,6 @@ func RenderLSPList(lspClients map[string]*lsp.Client, opts RenderOptions) []stri } // Determine icon color and description based on state - // iconColor := t.FgMuted icon := t.ItemOfflineIcon description := l.LSP.Command From 889b1765ba22a9533f90e419efc941b74ecc17f4 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 4 Aug 2025 17:07:42 -0300 Subject: [PATCH 34/39] docs: improve wording Signed-off-by: Carlos Alexandro Becker --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bd9413799a9b30ffc55dd3695841e2b1334faf0b..7fb832b541b7122e2f7f882aa82e1edaf0cd524d 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ nix run github:numtide/nix-ai-tools#crush
Nix (NUR) - + Crush is available via [NUR](https://github.com/nix-community/NUR) in `nur.repos.charmbracelet.crush`. You can also try out Crush via `nix-shell`: @@ -221,10 +221,10 @@ control but don't want Crush to consider when providing context. The `.crushignore` file uses the same syntax as `.gitignore` and can be placed in the root of your project or in subdirectories. -### Whitelisting Tools +### Allowing Tools By default, Crush will ask you for permission before running tool calls. If -you'd like, you can whitelist tools to be executed without prompting you for +you'd like, you can allow tools to be executed without prompting you for permissions. Use this with care. ```json From 5df3ebbe8eb7dbcc501f9c2b3f4ae2608ebc5fad Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Mon, 4 Aug 2025 23:01:13 -0400 Subject: [PATCH 35/39] docs(readme): add bedrock and vertex info --- README.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/README.md b/README.md index 7fb832b541b7122e2f7f882aa82e1edaf0cd524d..c85c3686b0096f0dfc7332311bdd4caccfea8e96 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,48 @@ Custom Anthropic-compatible providers follow this format: } ``` +### Amazon Bedrock + +Crush currently supports running Anthropic models through Bedrock, with caching disabled. + +* A Bedrock provider will appear once you have AWS configured, i.e. `aws configure` +* Crush also expects the `AWS_REGION` or `AWS_DEFAULT_REGION` to be set +* To use a specific AWS profile set `AWS_PROFILE` in your environment, i.e. `AWS_PROFILE=myprofile crush` + +### Vertex AI Platform + +Vertex AI will appear in the list of available providers when `VERTEXAI_PROJECT` and `VERTEXAI_LOCATION` are set. You will also need to be authenticated: + +```bash +gcloud auth application-default login +``` + +To add specific models to the configuration, configure as such: + +```json +{ + "$schema": "https://charm.land/crush.json", + "providers": { + "vertexai": { + "models": [ + { + "id": "claude-sonnet-4@20250514", + "name": "VertexAI Sonnet 4", + "cost_per_1m_in": 3, + "cost_per_1m_out": 15, + "cost_per_1m_in_cached": 3.75, + "cost_per_1m_out_cached": 0.3, + "context_window": 200000, + "default_max_tokens": 50000, + "can_reason": true, + "supports_attachments": true + } + ] + } + } +} +``` + ## Logging Sometimes you need to look at logs. Luckily, Crush logs all sorts of From d2535e2bbe70b361eebe4920809ac9347cf22b18 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Tue, 5 Aug 2025 00:39:20 -0300 Subject: [PATCH 36/39] chore(legal): @mohseenrm has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 85eb9a13ef8835ecb9b663b9086c2cde8d734415..b205cfb0b481c66fad5e08fbdaf3dd5d3892e2e1 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -239,6 +239,14 @@ "created_at": "2025-08-05T00:24:50Z", "repoId": 987670088, "pullRequestNo": 569 + }, + { + "name": "mohseenrm", + "id": 10768371, + "comment_id": 3153159347, + "created_at": "2025-08-05T03:39:12Z", + "repoId": 987670088, + "pullRequestNo": 574 } ] } \ No newline at end of file From 2673806884a295b144be542278b5e71aa6ebf423 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 5 Aug 2025 14:15:30 +0200 Subject: [PATCH 37/39] docs(readme): add Ollama and LMStudio config (#538) * docs: improve readme with more info * chore: small punctuation changes * chore: small markdwon improvements * docs(readme): copyedits * docs(readme): remove implicit colon * docs(readme): fix ephemeral config path on Widows --------- Co-authored-by: Christian Rocha --- README.md | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 62 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7fb832b541b7122e2f7f882aa82e1edaf0cd524d..fa772ab3859a1874cf7f8ddc70098bde4422e24f 100644 --- a/README.md +++ b/README.md @@ -138,19 +138,29 @@ Crush runs great with no configuration. That said, if you do need or want to customize Crush, configuration can be added either local to the project itself, or globally, with the following priority: -1. `./.crush.json` -2. `./crush.json` -3. `$HOME/.config/crush/crush.json` +1. `.crush.json` +2. `crush.json` +3. `$HOME/.config/crush/crush.json` (Windows: `%USERPROFILE%\AppData\Local\crush\crush.json`) Configuration itself is stored as a JSON object: ```json { - "this-setting": { } - "that-setting": { } + "this-setting": {"this": "that"}, + "that-setting": ["ceci", "cela"] } ``` +As an additional note, Crush also stores ephemeral data, such as application state, in one additional location: + +```bash +# Unix +$HOME/.local/shared/crush/crush.json + +# Windows +%LOCALAPPDATA%\crush\crush.json +``` + ### LSPs Crush can use LSPs for additional context to help inform its decisions, just @@ -245,6 +255,53 @@ permissions. Use this with care. You can also skip all permission prompts entirely by running Crush with the `--yolo` flag. Be very, very careful with this feature. +### Local Models + +Local models can also be configured via OpenAI-compatible API. Here are two common examples: + +#### Ollama + +```json +{ + "providers": { + "ollama": { + "name": "Ollama", + "base_url": "http://localhost:11434/v1/", + "type": "openai", + "models": [ + { + "name": "Qwen 3 30B", + "id": "qwen3:30b", + "context_window": 256000, + "default_max_tokens": 20000 + } + ] + } +} +``` + +#### LM Studio + +```json +{ + "providers": { + "lmstudio": { + "name": "LM Studio", + "base_url": "http://localhost:1234/v1/", + "type": "openai", + "models": [ + { + "name": "Qwen 3 30B", + "id": "qwen/qwen3-30b-a3b-2507", + "context_window": 256000, + "default_max_tokens": 20000 + } + ] + } + } +} +``` + ### Custom Providers Crush supports custom provider configurations for both OpenAI-compatible and From d72bac2307e3027ed4e98b34723d0f999314f185 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 5 Aug 2025 09:51:06 -0300 Subject: [PATCH 38/39] fix(mcp): set logger on all mcp clients (#554) * fix: set logger on all mcp clients * fix: dep * chore: comment --- go.mod | 2 +- go.sum | 4 ++-- internal/llm/agent/mcp-tools.go | 16 +++++++++------- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index 554ddfe41d2b9109593014798c04b83c0b2edbf9..4564b93b3799db28ad5ce8b47de6c90dfcc58b46 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/google/uuid v1.6.0 github.com/invopop/jsonschema v0.13.0 github.com/joho/godotenv v1.5.1 - github.com/mark3labs/mcp-go v0.36.0 + github.com/mark3labs/mcp-go v0.37.0 github.com/muesli/termenv v0.16.0 github.com/ncruces/go-sqlite3 v0.25.0 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 diff --git a/go.sum b/go.sum index a92dbc037bbb4c28ca94b7572954d74a041bb56a..3ccdc37bc1829d198ae33383e30a24fb95a9b1c9 100644 --- a/go.sum +++ b/go.sum @@ -180,8 +180,8 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mark3labs/mcp-go v0.36.0 h1:rIZaijrRYPeSbJG8/qNDe0hWlGrCJ7FWHNMz2SQpTis= -github.com/mark3labs/mcp-go v0.36.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mark3labs/mcp-go v0.37.0 h1:BywvZLPRT6Zx6mMG/MJfxLSZQkTGIcJSEGKsvr4DsoQ= +github.com/mark3labs/mcp-go v0.37.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 72b57ec07ff00ba94e188b9f00ed08698a1de028..ac4385d2725c600c01a8ac479c8364c99b0cb217 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -308,29 +308,31 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con func createMcpClient(m config.MCPConfig) (*client.Client, error) { switch m.Type { case config.MCPStdio: - return client.NewStdioMCPClient( + return client.NewStdioMCPClientWithOptions( m.Command, m.ResolvedEnv(), - m.Args..., + m.Args, + transport.WithCommandLogger(mcpLogger{}), ) case config.MCPHttp: return client.NewStreamableHttpClient( m.URL, transport.WithHTTPHeaders(m.ResolvedHeaders()), - transport.WithLogger(mcpHTTPLogger{}), + transport.WithHTTPLogger(mcpLogger{}), ) case config.MCPSse: return client.NewSSEMCPClient( m.URL, client.WithHeaders(m.ResolvedHeaders()), + transport.WithSSELogger(mcpLogger{}), ) default: return nil, fmt.Errorf("unsupported mcp type: %s", m.Type) } } -// for MCP's HTTP client. -type mcpHTTPLogger struct{} +// for MCP's clients. +type mcpLogger struct{} -func (l mcpHTTPLogger) Errorf(format string, v ...any) { slog.Error(fmt.Sprintf(format, v...)) } -func (l mcpHTTPLogger) Infof(format string, v ...any) { slog.Info(fmt.Sprintf(format, v...)) } +func (l mcpLogger) Errorf(format string, v ...any) { slog.Error(fmt.Sprintf(format, v...)) } +func (l mcpLogger) Infof(format string, v ...any) { slog.Info(fmt.Sprintf(format, v...)) } From 9a9861c693fdc6a69a67628c35e0bb80bee22202 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 5 Aug 2025 16:31:11 +0200 Subject: [PATCH 39/39] chore: update catwalk --- go.mod | 9 ++++++++- go.sum | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 11cdd601897e4b3e564ede57e5166c0515b8225f..1724fad6445f85f3edf6e88ac2f048aaebe17f8a 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/charlievieth/fastwalk v1.0.11 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d - github.com/charmbracelet/catwalk v0.4.5 + github.com/charmbracelet/catwalk v0.4.6 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250721205738-ea66aa652ee0 @@ -51,8 +51,15 @@ require ( require ( cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/buger/jsonparser v1.1.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_golang v1.23.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.65.0 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect diff --git a/go.sum b/go.sum index 2f0a23a24faa153d3a836005c1b06b7c2f6bd5f3..daa8cbc337f4b9b34d1603e62c693bcbb7f237e1 100644 --- a/go.sum +++ b/go.sum @@ -68,10 +68,14 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar/v4 v4.9.0 h1:DBvuZxjdKkRP/dr4GVV4w2fnmrk5Hxc90T51LZjv0JA= github.com/bmatcuk/doublestar/v4 v4.9.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8= github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5 h1:GTcMIfDQJKyNKS+xVt7GkNIwz+tBuQtIuiP50WpzNgs= @@ -80,6 +84,8 @@ github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d/go.mod h1:XIQ1qQfRph6Z5o2EikCydjumo0oDInQySRHuPATzbZc= github.com/charmbracelet/catwalk v0.4.5 h1:Kv3PadDe8IF8gpcYTfAJdCee5Bv4HufvtNT61FXtq5g= github.com/charmbracelet/catwalk v0.4.5/go.mod h1:WnKgNPmQHuMyk7GtwAQwl+ezHusfH40IvzML2qwUGwc= +github.com/charmbracelet/catwalk v0.4.6 h1:Y0JDq5V4agK8oO3lKC/hhInrYXePGwZPNo8I1Lk08jc= +github.com/charmbracelet/catwalk v0.4.6/go.mod h1:WnKgNPmQHuMyk7GtwAQwl+ezHusfH40IvzML2qwUGwc= github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 h1:+Cz+VfxD5DO+JT1LlswXWhre0HYLj6l2HW8HVGfMuC0= @@ -202,6 +208,8 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-sqlite3 v0.25.0 h1:trugKUs98Zwy9KwRr/EUxZHL92LYt7UqcKqAfpGpK+I= github.com/ncruces/go-sqlite3 v0.25.0/go.mod h1:n6Z7036yFilJx04yV0mi5JWaF66rUmXn1It9Ux8dx68= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= @@ -221,6 +229,14 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJzYU= github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k= +github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= +github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c h1:kmzxiX+OB0knCo1V0dkEkdPelzCdAzCURCfmFArn2/A= github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=