From a48d4e3538ad46db534c20e8f5860789a7cf5966 Mon Sep 17 00:00:00 2001 From: Vadim Inshakov Date: Sat, 27 Sep 2025 00:54:34 +0500 Subject: [PATCH 01/94] fix(stream): stream hang, add stream timeout (#1070) Co-authored-by: Carlos Alexandro Becker --- internal/llm/agent/agent.go | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index 44efba31835aa4d68a79538fd637f1eff43cbb3e..dfc8cbc3f2d0030d0ba0df2f9d33fe75cbb5599c 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -26,6 +26,8 @@ import ( "github.com/charmbracelet/crush/internal/shell" ) +const streamChunkTimeout = 80 * time.Second + type AgentEventType string const ( @@ -553,16 +555,25 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg ctx = context.WithValue(ctx, tools.MessageIDContextKey, assistantMsg.ID) // Process each event in the stream. - for event := range eventChan { - if processErr := a.processEvent(ctx, sessionID, &assistantMsg, event); processErr != nil { - if errors.Is(processErr, context.Canceled) { - a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "") - } else { - a.finishMessage(ctx, &assistantMsg, message.FinishReasonError, "API Error", processErr.Error()) +loop: + for { + select { + case event, ok := <-eventChan: + if !ok { + break loop } - return assistantMsg, nil, processErr - } - if ctx.Err() != nil { + if processErr := a.processEvent(ctx, sessionID, &assistantMsg, event); processErr != nil { + if errors.Is(processErr, context.Canceled) { + a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "") + } else { + a.finishMessage(ctx, &assistantMsg, message.FinishReasonError, "API Error", processErr.Error()) + } + return assistantMsg, nil, processErr + } + case <-time.After(streamChunkTimeout): + a.finishMessage(ctx, &assistantMsg, message.FinishReasonError, "Stream timeout", "No chunk received within timeout") + return assistantMsg, nil, fmt.Errorf("stream chunk timeout") + case <-ctx.Done(): a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "") return assistantMsg, nil, ctx.Err() } From 42a41595d1d9725ad653759694323802e9496f8a Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Sat, 27 Sep 2025 10:09:31 -0300 Subject: [PATCH 02/94] chore(legal): @maxious 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 136d5857bf881c2fca2a5ada072cde11ac97af3c..e92b62b75054734fd16450e5ec5e3eb56ee3cf57 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -663,6 +663,14 @@ "created_at": "2025-09-26T13:30:16Z", "repoId": 987670088, "pullRequestNo": 1135 + }, + { + "name": "maxious", + "id": 81432, + "comment_id": 3341700737, + "created_at": "2025-09-27T13:09:22Z", + "repoId": 987670088, + "pullRequestNo": 1141 } ] } \ No newline at end of file From baf053409d1f4945513ab77ff8772c3abe150985 Mon Sep 17 00:00:00 2001 From: daniel <15257433+kslamph@users.noreply.github.com> Date: Sun, 28 Sep 2025 21:18:02 +0800 Subject: [PATCH 03/94] fix(gemini): add baseURL resolution and conditional HTTPOptions configuration (#1144) --- internal/llm/provider/gemini.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/internal/llm/provider/gemini.go b/internal/llm/provider/gemini.go index 3987deb7ebcc6330c9d3bcb4a52aeeb292eab43f..f8d1c6ab4f0577afe2bbbd0b12c8c11a4b203b32 100644 --- a/internal/llm/provider/gemini.go +++ b/internal/llm/provider/gemini.go @@ -43,9 +43,14 @@ func createGeminiClient(opts providerClientOptions) (*genai.Client, error) { cc := &genai.ClientConfig{ APIKey: opts.apiKey, Backend: genai.BackendGeminiAPI, - HTTPOptions: genai.HTTPOptions{ - BaseURL: opts.baseURL, - }, + } + if opts.baseURL != "" { + resolvedBaseURL, err := config.Get().Resolve(opts.baseURL) + if err == nil && resolvedBaseURL != "" { + cc.HTTPOptions = genai.HTTPOptions{ + BaseURL: resolvedBaseURL, + } + } } if config.Get().Options.Debug { cc.HTTPClient = log.NewHTTPClient() @@ -572,4 +577,4 @@ func contains(s string, substrs ...string) bool { } } return false -} +} \ No newline at end of file From efb77aced1e23c9363958bb04f0a747bb3a58f64 Mon Sep 17 00:00:00 2001 From: daniel <15257433+kslamph@users.noreply.github.com> Date: Sun, 28 Sep 2025 21:37:21 +0800 Subject: [PATCH 04/94] fix(gemini): add missing newline at end of file --- internal/llm/provider/gemini.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/llm/provider/gemini.go b/internal/llm/provider/gemini.go index f8d1c6ab4f0577afe2bbbd0b12c8c11a4b203b32..91733844e0699b6f1ea62d47e0c4f61b2cde6e36 100644 --- a/internal/llm/provider/gemini.go +++ b/internal/llm/provider/gemini.go @@ -577,4 +577,4 @@ func contains(s string, substrs ...string) bool { } } return false -} \ No newline at end of file +} From 2bb58b8042414a100bd488286d6547e4991204dd Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Sun, 28 Sep 2025 22:19:50 -0300 Subject: [PATCH 06/94] chore(legal): @Wangch29 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 e92b62b75054734fd16450e5ec5e3eb56ee3cf57..4ecfd86887d5d072491a8fa764628e8935e4ebfe 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -671,6 +671,14 @@ "created_at": "2025-09-27T13:09:22Z", "repoId": 987670088, "pullRequestNo": 1141 + }, + { + "name": "Wangch29", + "id": 115294077, + "comment_id": 3344526018, + "created_at": "2025-09-29T01:19:40Z", + "repoId": 987670088, + "pullRequestNo": 1148 } ] } \ No newline at end of file From e0c1cfcb3783edbbf50a5391c3db299042f89275 Mon Sep 17 00:00:00 2001 From: daniel <15257433+kslamph@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:00:28 +0800 Subject: [PATCH 07/94] fix(gemini): use full MIME type for binary content in message conversion (fixes charmbracelet/crush#995) --- internal/llm/provider/gemini.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/llm/provider/gemini.go b/internal/llm/provider/gemini.go index 91733844e0699b6f1ea62d47e0c4f61b2cde6e36..a846d8d582524bb6bf9c8ed31e3796ec8d94b419 100644 --- a/internal/llm/provider/gemini.go +++ b/internal/llm/provider/gemini.go @@ -70,9 +70,8 @@ func (g *geminiClient) convertMessages(messages []message.Message) []*genai.Cont var parts []*genai.Part parts = append(parts, &genai.Part{Text: msg.Content().String()}) for _, binaryContent := range msg.BinaryContent() { - imageFormat := strings.Split(binaryContent.MIMEType, "/") parts = append(parts, &genai.Part{InlineData: &genai.Blob{ - MIMEType: imageFormat[1], + MIMEType: binaryContent.MIMEType, Data: binaryContent.Data, }}) } From 34ff32621ccdeccc72947a9249a253a75f67a79c Mon Sep 17 00:00:00 2001 From: kujtimiihoxha Date: Mon, 29 Sep 2025 08:19:23 +0200 Subject: [PATCH 08/94] fix(agent): timer should reset after each chunk --- internal/llm/agent/agent.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index dfc8cbc3f2d0030d0ba0df2f9d33fe75cbb5599c..91661dabc7bbe6a8099a67dc24b43de2a9545dbf 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -555,6 +555,9 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg ctx = context.WithValue(ctx, tools.MessageIDContextKey, assistantMsg.ID) // Process each event in the stream. + timer := time.NewTimer(streamChunkTimeout) + defer timer.Stop() + loop: for { select { @@ -562,6 +565,9 @@ loop: if !ok { break loop } + // Reset the timeout timer since we received a chunk + timer.Reset(streamChunkTimeout) + if processErr := a.processEvent(ctx, sessionID, &assistantMsg, event); processErr != nil { if errors.Is(processErr, context.Canceled) { a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "") @@ -570,7 +576,7 @@ loop: } return assistantMsg, nil, processErr } - case <-time.After(streamChunkTimeout): + case <-timer.C: a.finishMessage(ctx, &assistantMsg, message.FinishReasonError, "Stream timeout", "No chunk received within timeout") return assistantMsg, nil, fmt.Errorf("stream chunk timeout") case <-ctx.Done(): From b5a0d14cca0ecd0c271917508c4f02a169bbbbcc Mon Sep 17 00:00:00 2001 From: kujtimiihoxha Date: Mon, 29 Sep 2025 08:22:28 +0200 Subject: [PATCH 09/94] fix(lint): remove empty line --- internal/llm/agent/agent.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index 91661dabc7bbe6a8099a67dc24b43de2a9545dbf..1f260f7c4134a3561f34aa64aa2096a9cd14641d 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -567,7 +567,7 @@ loop: } // Reset the timeout timer since we received a chunk timer.Reset(streamChunkTimeout) - + if processErr := a.processEvent(ctx, sessionID, &assistantMsg, event); processErr != nil { if errors.Is(processErr, context.Canceled) { a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "") From fb5c89219e72c97232f790e71a905b4336933991 Mon Sep 17 00:00:00 2001 From: kujtimiihoxha Date: Mon, 29 Sep 2025 09:04:43 +0200 Subject: [PATCH 10/94] chore: small fixes --- internal/event/logger.go | 9 +++++---- internal/llm/agent/agent.go | 2 +- internal/llm/agent/errors.go | 1 + 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/event/logger.go b/internal/event/logger.go index 7648ae2c2cca91ed20535c0d65a677cd4db84500..7581676b018f5ac6001827db851a132792d21985 100644 --- a/internal/event/logger.go +++ b/internal/event/logger.go @@ -1,6 +1,7 @@ package event import ( + "fmt" "log/slog" "github.com/posthog/posthog-go" @@ -11,17 +12,17 @@ var _ posthog.Logger = logger{} type logger struct{} func (logger) Debugf(format string, args ...any) { - slog.Debug(format, args...) + slog.Debug(fmt.Sprintf(format, args...)) } func (logger) Logf(format string, args ...any) { - slog.Info(format, args...) + slog.Info(fmt.Sprintf(format, args...)) } func (logger) Warnf(format string, args ...any) { - slog.Warn(format, args...) + slog.Warn(fmt.Sprintf(format, args...)) } func (logger) Errorf(format string, args ...any) { - slog.Error(format, args...) + slog.Error(fmt.Sprintf(format, args...)) } diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index 1f260f7c4134a3561f34aa64aa2096a9cd14641d..ce2a08d81ef9494e57739061f85696f771163991 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -578,7 +578,7 @@ loop: } case <-timer.C: a.finishMessage(ctx, &assistantMsg, message.FinishReasonError, "Stream timeout", "No chunk received within timeout") - return assistantMsg, nil, fmt.Errorf("stream chunk timeout") + return assistantMsg, nil, ErrStreamTimeout case <-ctx.Done(): a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "") return assistantMsg, nil, ctx.Err() diff --git a/internal/llm/agent/errors.go b/internal/llm/agent/errors.go index 0e2f983d64b42b93ad3a51f32ce0335b0374a613..943918390c6708b5ae6ea5e40e50ebce209cc263 100644 --- a/internal/llm/agent/errors.go +++ b/internal/llm/agent/errors.go @@ -7,6 +7,7 @@ import ( var ( ErrRequestCancelled = errors.New("request canceled by user") + ErrStreamTimeout = errors.New("stream chunk timeout") ErrSessionBusy = errors.New("session is currently processing another request") ) From 06a5840ca0df258fabd4ceac1b132af88158311c Mon Sep 17 00:00:00 2001 From: kujtimiihoxha Date: Mon, 29 Sep 2025 09:12:18 +0200 Subject: [PATCH 11/94] chore: increase timeout a bit in case of opus for e.x it could take longer for a response even in streaming for some reason --- internal/llm/agent/agent.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index ce2a08d81ef9494e57739061f85696f771163991..2a3f45a2c56c74c80d32abead5038f9697dbf319 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -26,7 +26,7 @@ import ( "github.com/charmbracelet/crush/internal/shell" ) -const streamChunkTimeout = 80 * time.Second +const streamChunkTimeout = 2 * time.Minute type AgentEventType string From 107a82355d85442613e419ca5f29ac471f867c84 Mon Sep 17 00:00:00 2001 From: nguyen <51743767+ngnhng@users.noreply.github.com> Date: Mon, 29 Sep 2025 16:58:09 +0700 Subject: [PATCH 12/94] fix(openai): 429 insuffice not retry (#546) --- internal/llm/provider/openai.go | 12 ++++- internal/llm/provider/openai_test.go | 76 ++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/internal/llm/provider/openai.go b/internal/llm/provider/openai.go index 8ec366caff4156fbf4baae76fc24ce5c30d4a91d..3e92e077b3156ddccc186e0b104b7db174290c18 100644 --- a/internal/llm/provider/openai.go +++ b/internal/llm/provider/openai.go @@ -529,11 +529,19 @@ func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error) return true, 0, nil } - if apiErr.StatusCode != http.StatusTooManyRequests && apiErr.StatusCode != http.StatusInternalServerError { + if apiErr.StatusCode == http.StatusTooManyRequests { + // Check if this is an insufficient quota error (permanent) + if apiErr.Type == "insufficient_quota" || apiErr.Code == "insufficient_quota" { + return false, 0, fmt.Errorf("OpenAI quota exceeded: %s. Please check your plan and billing details", apiErr.Message) + } + // Other 429 errors (rate limiting) can be retried + } else if apiErr.StatusCode != http.StatusInternalServerError { return false, 0, err } - retryAfterValues = apiErr.Response.Header.Values("Retry-After") + if apiErr.Response != nil { + retryAfterValues = apiErr.Response.Header.Values("Retry-After") + } } if apiErr != nil { diff --git a/internal/llm/provider/openai_test.go b/internal/llm/provider/openai_test.go index 8088ba22b4cd49b26130cd3812e8705e8dfe1cba..52b0a20c9316d67ba987ccc5051aa2f6d321aff4 100644 --- a/internal/llm/provider/openai_test.go +++ b/internal/llm/provider/openai_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "os" + "strings" "testing" "time" @@ -88,3 +89,78 @@ func TestOpenAIClientStreamChoices(t *testing.T) { } } } + +func TestOpenAIClient429InsufficientQuotaError(t *testing.T) { + client := &openaiClient{ + providerOptions: providerClientOptions{ + modelType: config.SelectedModelTypeLarge, + apiKey: "test-key", + systemMessage: "test", + config: config.ProviderConfig{ + ID: "test-openai", + APIKey: "test-key", + }, + model: func(config.SelectedModelType) catwalk.Model { + return catwalk.Model{ + ID: "test-model", + Name: "test-model", + } + }, + }, + } + + // Test insufficient_quota error should not retry + apiErr := &openai.Error{ + StatusCode: 429, + Message: "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.", + Type: "insufficient_quota", + Code: "insufficient_quota", + } + + retry, _, err := client.shouldRetry(1, apiErr) + if retry { + t.Error("Expected shouldRetry to return false for insufficient_quota error, but got true") + } + if err == nil { + t.Error("Expected shouldRetry to return an error for insufficient_quota, but got nil") + } + if err != nil && !strings.Contains(err.Error(), "quota") { + t.Errorf("Expected error message to mention quota, got: %v", err) + } +} + +func TestOpenAIClient429RateLimitError(t *testing.T) { + client := &openaiClient{ + providerOptions: providerClientOptions{ + modelType: config.SelectedModelTypeLarge, + apiKey: "test-key", + systemMessage: "test", + config: config.ProviderConfig{ + ID: "test-openai", + APIKey: "test-key", + }, + model: func(config.SelectedModelType) catwalk.Model { + return catwalk.Model{ + ID: "test-model", + Name: "test-model", + } + }, + }, + } + + // Test regular rate limit error should retry + apiErr := &openai.Error{ + StatusCode: 429, + Message: "Rate limit reached for requests", + Type: "rate_limit_exceeded", + Code: "rate_limit_exceeded", + } + + retry, _, err := client.shouldRetry(1, apiErr) + if !retry { + t.Error("Expected shouldRetry to return true for rate_limit_exceeded error, but got false") + } + if err != nil { + t.Errorf("Expected shouldRetry to return nil error for rate_limit_exceeded, but got: %v", err) + } +} From 4f28c51bf5d38084c332b7383f8a927fab839c3b Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 29 Sep 2025 08:19:03 -0300 Subject: [PATCH 13/94] fix(mcp): do not eat list tools errors (#1138) Signed-off-by: Carlos Alexandro Becker --- internal/llm/agent/mcp-tools.go | 35 +++++++++++++++++---------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index a2e6b912ab503c61522501ad522a9f0a65fc37b0..f5125c8b89f2dda534396f3c51df3839390022ce 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -196,14 +196,10 @@ func (b *McpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolRes return runTool(ctx, b.mcpName, b.tool.Name, params.Input) } -func getTools(ctx context.Context, name string, permissions permission.Service, c *client.Client, workingDir string) []tools.BaseTool { +func getTools(ctx context.Context, name string, permissions permission.Service, c *client.Client, workingDir string) ([]tools.BaseTool, error) { 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 + return nil, err } mcpTools := make([]tools.BaseTool, 0, len(result.Tools)) for _, tool := range result.Tools { @@ -214,7 +210,7 @@ func getTools(ctx context.Context, name string, permissions permission.Service, workingDir: workingDir, }) } - return mcpTools + return mcpTools, nil } // SubscribeMCPEvents returns a channel for MCP events @@ -314,13 +310,21 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con ctx, cancel := context.WithTimeout(ctx, mcpTimeout(m)) defer cancel() + c, err := createAndInitializeClient(ctx, name, m, cfg.Resolver()) if err != nil { return } - mcpClients.Set(name, c) - tools := getTools(ctx, name, permissions, c, cfg.WorkingDir()) + tools, err := getTools(ctx, name, permissions, c, cfg.WorkingDir()) + if err != nil { + slog.Error("error listing tools", "error", err) + updateMCPState(name, MCPStateError, err, nil, 0) + c.Close() + return + } + + mcpClients.Set(name, c) updateMCPState(name, MCPStateConnected, nil, c, len(tools)) result.Append(tools...) }(name, m) @@ -341,14 +345,11 @@ func createAndInitializeClient(ctx context.Context, name string, m config.MCPCon initCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - // Only call Start() for non-stdio clients, as stdio clients auto-start - if m.Type != config.MCPStdio { - if err := c.Start(initCtx); err != nil { - updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0) - slog.Error("error starting mcp client", "error", err, "name", name) - _ = c.Close() - return nil, err - } + if err := c.Start(initCtx); err != nil { + updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0) + slog.Error("error starting mcp client", "error", err, "name", name) + _ = c.Close() + return nil, err } if _, err := c.Initialize(initCtx, mcpInitRequest); err != nil { updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0) From 38bed4ade99397d9657722ae477ee5256fa82505 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Sep 2025 11:49:37 +0000 Subject: [PATCH 14/94] chore(deps): bump github.com/mark3labs/mcp-go from 0.40.0 to 0.41.0 in the all group (#1156) 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 d3e668320cfdec39160d618b189f1470bf07d028..699233cdd52fe59165e8f9c44a85d1413f1bc4b6 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,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.40.0 + github.com/mark3labs/mcp-go v0.41.0 github.com/muesli/termenv v0.16.0 github.com/ncruces/go-sqlite3 v0.29.0 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 diff --git a/go.sum b/go.sum index f69217e9d4e9831abc8e1b47b80e23a19dcfcffa..f54651f8f6b5fa0e6f9f4a3ee53a61d0eec0970c 100644 --- a/go.sum +++ b/go.sum @@ -194,8 +194,8 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ github.com/lucasb-eyer/go-colorful v1.3.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.40.0 h1:M0oqK412OHBKut9JwXSsj4KanSmEKpzoW8TcxoPOkAU= -github.com/mark3labs/mcp-go v0.40.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mark3labs/mcp-go v0.41.0 h1:IFfJaovCet65F3av00bE1HzSnmHpMRWM1kz96R98I70= +github.com/mark3labs/mcp-go v0.41.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= From e762ddf8232a9ca2419f4c3c4b0417026b8d4d59 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 29 Sep 2025 17:21:27 +0200 Subject: [PATCH 15/94] fix(agent): remove timout for now (#1158) --- internal/llm/agent/agent.go | 12 ------------ internal/llm/agent/errors.go | 1 - 2 files changed, 13 deletions(-) diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index 2a3f45a2c56c74c80d32abead5038f9697dbf319..9bae6e5b8092b987b1c8146460cef946e595beb5 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -26,8 +26,6 @@ import ( "github.com/charmbracelet/crush/internal/shell" ) -const streamChunkTimeout = 2 * time.Minute - type AgentEventType string const ( @@ -554,10 +552,6 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg // Add the session and message ID into the context if needed by tools. ctx = context.WithValue(ctx, tools.MessageIDContextKey, assistantMsg.ID) - // Process each event in the stream. - timer := time.NewTimer(streamChunkTimeout) - defer timer.Stop() - loop: for { select { @@ -565,9 +559,6 @@ loop: if !ok { break loop } - // Reset the timeout timer since we received a chunk - timer.Reset(streamChunkTimeout) - if processErr := a.processEvent(ctx, sessionID, &assistantMsg, event); processErr != nil { if errors.Is(processErr, context.Canceled) { a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "") @@ -576,9 +567,6 @@ loop: } return assistantMsg, nil, processErr } - case <-timer.C: - a.finishMessage(ctx, &assistantMsg, message.FinishReasonError, "Stream timeout", "No chunk received within timeout") - return assistantMsg, nil, ErrStreamTimeout case <-ctx.Done(): a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "") return assistantMsg, nil, ctx.Err() diff --git a/internal/llm/agent/errors.go b/internal/llm/agent/errors.go index 943918390c6708b5ae6ea5e40e50ebce209cc263..0e2f983d64b42b93ad3a51f32ce0335b0374a613 100644 --- a/internal/llm/agent/errors.go +++ b/internal/llm/agent/errors.go @@ -7,7 +7,6 @@ import ( var ( ErrRequestCancelled = errors.New("request canceled by user") - ErrStreamTimeout = errors.New("stream chunk timeout") ErrSessionBusy = errors.New("session is currently processing another request") ) From 53a4703c97e6a55008dd19fe663ec8738452aaf2 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 29 Sep 2025 15:03:39 -0300 Subject: [PATCH 17/94] chore(taskfile): change `release` task to add a commit for the tag (#1159) --- Taskfile.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index 80d6bd86d1070e2f4e900660a7cab060ebdfbcea..54b50a68217b6ff66ddf1de9a28a8f45d224fefc 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -97,7 +97,8 @@ tasks: - sh: "[ $(git status --porcelain=2 | wc -l) = 0 ]" msg: "Git is dirty" cmds: + - git commit --allow-empty -m "{{.NEXT}}" - git tag -d nightly - git tag --sign {{.NEXT}} {{.CLI_ARGS}} - - echo "pushing {{.NEXT}}..." + - echo "Pushing {{.NEXT}}..." - git push origin --tags From de306c21924e3ed6c174dcc8c58f771c53cdf3a4 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Mon, 29 Sep 2025 15:17:26 -0400 Subject: [PATCH 18/94] chore: update session chooser key help text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change Enter key help from "confirm" to "choose" - Change Escape key help from "cancel" to "exit" 💘 Generated with Crush Co-Authored-By: Crush --- internal/tui/components/dialogs/sessions/keys.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tui/components/dialogs/sessions/keys.go b/internal/tui/components/dialogs/sessions/keys.go index bc7ec1ba9f83915caee9189504abf0b07bd4a24b..73c50899f7ae7da3655fc8a3e3a3dd34c4c22f95 100644 --- a/internal/tui/components/dialogs/sessions/keys.go +++ b/internal/tui/components/dialogs/sessions/keys.go @@ -15,7 +15,7 @@ func DefaultKeyMap() KeyMap { return KeyMap{ Select: key.NewBinding( key.WithKeys("enter", "tab", "ctrl+y"), - key.WithHelp("enter", "confirm"), + key.WithHelp("enter", "choose"), ), Next: key.NewBinding( key.WithKeys("down", "ctrl+n"), @@ -27,7 +27,7 @@ func DefaultKeyMap() KeyMap { ), Close: key.NewBinding( key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "cancel"), + key.WithHelp("esc", "exit"), ), } } From cfa3244a77c8eb4326bbc6c4ecb91fdc70c8693f Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Mon, 29 Sep 2025 17:15:40 -0400 Subject: [PATCH 19/94] chore: update model chooser key help text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change Enter key help from "confirm" to "choose" - Change Escape key help from "cancel" to "exit" 💘 Generated with Crush Co-Authored-By: Crush --- internal/tui/components/dialogs/models/keys.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tui/components/dialogs/models/keys.go b/internal/tui/components/dialogs/models/keys.go index ef4a6228b839c43a3862e251999dadf81dd6403f..4ec1a487e865981edc0be5852bb6c044ddf04c1f 100644 --- a/internal/tui/components/dialogs/models/keys.go +++ b/internal/tui/components/dialogs/models/keys.go @@ -19,7 +19,7 @@ func DefaultKeyMap() KeyMap { return KeyMap{ Select: key.NewBinding( key.WithKeys("enter", "ctrl+y"), - key.WithHelp("enter", "confirm"), + key.WithHelp("enter", "choose"), ), Next: key.NewBinding( key.WithKeys("down", "ctrl+n"), @@ -35,7 +35,7 @@ func DefaultKeyMap() KeyMap { ), Close: key.NewBinding( key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "cancel"), + key.WithHelp("esc", "exit"), ), } } From 6cd1733aed022fefe5ec54416c2b01d42c797288 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 30 Sep 2025 10:55:07 -0300 Subject: [PATCH 20/94] fix(mcp): SSE MCPs not working (#1157) Signed-off-by: Carlos Alexandro Becker --- internal/llm/agent/mcp-tools.go | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index f5125c8b89f2dda534396f3c51df3839390022ce..ebd1698f2f7bf45ecda15c9160464e3d295ce3d6 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -341,29 +341,32 @@ func createAndInitializeClient(ctx context.Context, name string, m config.MCPCon return nil, err } + // XXX: ideally we should be able to use context.WithTimeout here, but, + // the SSE MCP client will start failing once that context is canceled. timeout := mcpTimeout(m) - initCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - if err := c.Start(initCtx); err != nil { + mcpCtx, cancel := context.WithCancel(ctx) + cancelTimer := time.AfterFunc(timeout, cancel) + if err := c.Start(mcpCtx); err != nil { updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0) slog.Error("error starting mcp client", "error", err, "name", name) _ = c.Close() + cancel() return nil, err } - if _, err := c.Initialize(initCtx, mcpInitRequest); err != nil { + if _, err := c.Initialize(mcpCtx, mcpInitRequest); err != nil { updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0) slog.Error("error initializing mcp client", "error", err, "name", name) _ = c.Close() + cancel() return nil, err } - + cancelTimer.Stop() slog.Info("Initialized mcp client", "name", name) return c, nil } func maybeTimeoutErr(err error, timeout time.Duration) error { - if errors.Is(err, context.DeadlineExceeded) { + if errors.Is(err, context.Canceled) { return fmt.Errorf("timed out after %s", timeout) } return err From 598e2f68ea98d11c0a87a566aa4a4e79396c1825 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 30 Sep 2025 13:32:57 -0300 Subject: [PATCH 21/94] fix(style): heartbit in --version Signed-off-by: Carlos Alexandro Becker --- internal/cmd/root.go | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index ea9c218b67c65815b6bcc2c8b1cb17fd02390b39..825c35419f17248d1ea854a1a0ae2aca27bcaa20 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -1,6 +1,7 @@ package cmd import ( + "bytes" "context" "fmt" "io" @@ -10,6 +11,7 @@ import ( "strconv" tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/colorprofile" "github.com/charmbracelet/crush/internal/app" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/db" @@ -17,6 +19,8 @@ import ( "github.com/charmbracelet/crush/internal/tui" "github.com/charmbracelet/crush/internal/version" "github.com/charmbracelet/fang" + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/x/exp/charmtone" "github.com/charmbracelet/x/term" "github.com/spf13/cobra" ) @@ -93,7 +97,39 @@ crush -y }, } +var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(` + ▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄ + ███████████ ███████████ +████████████████████████████ +████████████████████████████ +██████████▀██████▀██████████ +██████████ ██████ ██████████ +▀▀██████▄████▄▄████▄██████▀▀ + ████████████████████████ + ████████████████████ + ▀▀██████████▀▀ + ▀▀▀▀▀▀ +`) + +// copied from cobra: +const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}} +` + func Execute() { + // NOTE: very hacky: we create a colorprofile writer with STDOUT, then make + // it forward to a bytes.Buffer, write the colored heartbit to it, and then + // finally prepend it in the version template. + // Unfortunately cobra doesn't give us a way to set a function to handle + // printing the version, and PreRunE runs after the version is already + // handled, so that doesn't work either. + // This is the only way I could find that works relatively well. + if term.IsTerminal(os.Stdout.Fd()) { + var b bytes.Buffer + w := colorprofile.NewWriter(os.Stdout, os.Environ()) + w.Forward = &b + _, _ = w.WriteString(heartbit.String()) + rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate) + } if err := fang.Execute( context.Background(), rootCmd, From e5e01d5f9db2e784954bbde9273a34c97f81ea4c Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 30 Sep 2025 15:36:58 -0300 Subject: [PATCH 22/94] ci: update changelog group names Signed-off-by: Carlos Alexandro Becker --- .goreleaser.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index c0da1c50aec71d899b0cffe09be64e3756e92f51..aabf2f7606462ebb540fd6ebe9efb302a6855e5f 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -312,19 +312,19 @@ changelog: - "^wip " - "^wip:" groups: - - title: "New Features" - regexp: '^.*?feat(\(.+\))??!?:.+$' + - title: "Deps" + regexp: "^.*\\(deps\\)*:+.*$" + order: 300 + - title: "New!" + regexp: "^.*feat[(\\w)]*:+.*$" order: 100 - - title: "Security updates" - regexp: '^.*?sec(\(.+\))??!?:.+$' - order: 150 - - title: "Bug fixes and improvements" - regexp: '^.*?(fix|refactor)(\(.+\))??!?:.+$' + - title: "Fixed" + regexp: "^.*fix[(\\w)]*:+.*$" order: 200 - - title: "Documentation updates" - regexp: ^.*?docs?(\(.+\))??!?:.+$ + - title: "Docs" + regexp: "^.*docs[(\\w)]*:+.*$" order: 400 - - title: Other work + - title: "Other stuff" order: 9999 release: From afa10c5eb9c84e8a78233446ec4c2803b1c868d8 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 30 Sep 2025 17:53:53 -0300 Subject: [PATCH 23/94] feat: crush dirs (#551) Signed-off-by: Carlos Alexandro Becker Co-authored-by: Christian Rocha --- internal/cmd/dirs.go | 66 +++++++++++++++++++++++++++++++++++++++++ internal/cmd/logs.go | 1 - internal/cmd/root.go | 9 ++++-- internal/cmd/schema.go | 4 --- internal/config/load.go | 5 ++-- 5 files changed, 76 insertions(+), 9 deletions(-) create mode 100644 internal/cmd/dirs.go diff --git a/internal/cmd/dirs.go b/internal/cmd/dirs.go new file mode 100644 index 0000000000000000000000000000000000000000..d3bc0bd4fb1482e2657eedaab9ce4cee30a09373 --- /dev/null +++ b/internal/cmd/dirs.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "os" + "path/filepath" + + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/lipgloss/v2/table" + "github.com/charmbracelet/x/term" + "github.com/spf13/cobra" +) + +var dirsCmd = &cobra.Command{ + Use: "dirs", + Short: "Print directories used by Crush", + Long: `Print the directories where Crush stores its configuration and data files. +This includes the global configuration directory and data directory.`, + Example: ` +# Print all directories +crush dirs + +# Print only the config directory +crush dirs config + +# Print only the data directory +crush dirs data + `, + Run: func(cmd *cobra.Command, args []string) { + if term.IsTerminal(os.Stdout.Fd()) { + // We're in a TTY: make it fancy. + t := table.New(). + Border(lipgloss.RoundedBorder()). + StyleFunc(func(row, col int) lipgloss.Style { + return lipgloss.NewStyle().Padding(0, 2) + }). + Row("Config", filepath.Dir(config.GlobalConfig())). + Row("Data", filepath.Dir(config.GlobalConfigData())) + lipgloss.Println(t) + return + } + // Not a TTY. + cmd.Println(filepath.Dir(config.GlobalConfig())) + cmd.Println(filepath.Dir(config.GlobalConfigData())) + }, +} + +var configDirCmd = &cobra.Command{ + Use: "config", + Short: "Print the configuration directory used by Crush", + Run: func(cmd *cobra.Command, args []string) { + cmd.Println(filepath.Dir(config.GlobalConfig())) + }, +} + +var dataDirCmd = &cobra.Command{ + Use: "data", + Short: "Print the datauration directory used by Crush", + Run: func(cmd *cobra.Command, args []string) { + cmd.Println(filepath.Dir(config.GlobalConfigData())) + }, +} + +func init() { + dirsCmd.AddCommand(configDirCmd, dataDirCmd) +} diff --git a/internal/cmd/logs.go b/internal/cmd/logs.go index 85921c4e4354194d0d260e814fc61222c114d3ef..e7160f4a1307406be20f1fe00a59e93de5232d67 100644 --- a/internal/cmd/logs.go +++ b/internal/cmd/logs.go @@ -68,7 +68,6 @@ var logsCmd = &cobra.Command{ func init() { logsCmd.Flags().BoolP("follow", "f", false, "Follow log output") logsCmd.Flags().IntP("tail", "t", defaultTailLines, "Show only the last N lines default: 1000 for performance") - rootCmd.AddCommand(logsCmd) } func followLogs(ctx context.Context, logsFile string, tailLines int) error { diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 825c35419f17248d1ea854a1a0ae2aca27bcaa20..0a2be1cbe6e480fe5719640711bd4caffb430229 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -33,8 +33,13 @@ func init() { rootCmd.Flags().BoolP("help", "h", false, "Help") rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)") - rootCmd.AddCommand(runCmd) - rootCmd.AddCommand(updateProvidersCmd) + rootCmd.AddCommand( + runCmd, + dirsCmd, + updateProvidersCmd, + logsCmd, + schemaCmd, + ) } var rootCmd = &cobra.Command{ diff --git a/internal/cmd/schema.go b/internal/cmd/schema.go index f835e250c24ea91a9d5084c9a414ed0e1ae28474..6070eb9144dc0e46bf0f374b2cb1a860f09e83e9 100644 --- a/internal/cmd/schema.go +++ b/internal/cmd/schema.go @@ -24,7 +24,3 @@ var schemaCmd = &cobra.Command{ return nil }, } - -func init() { - rootCmd.AddCommand(schemaCmd) -} diff --git a/internal/config/load.go b/internal/config/load.go index e39074f78bdb8df0ddc98bfbc7322541175b71d6..b36813084049a89b5e67d79d6342335cb85230e3 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -520,7 +520,7 @@ func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) erro func lookupConfigs(cwd string) []string { // prepend default config paths configPaths := []string{ - globalConfig(), + GlobalConfig(), GlobalConfigData(), } @@ -596,7 +596,8 @@ func hasAWSCredentials(env env.Env) bool { return false } -func globalConfig() string { +// GlobalConfig returns the global configuration file path for the application. +func GlobalConfig() string { xdgConfigHome := os.Getenv("XDG_CONFIG_HOME") if xdgConfigHome != "" { return filepath.Join(xdgConfigHome, appName, fmt.Sprintf("%s.json", appName)) From 8c7c0db22606db51910c2a24dc15319369ce60f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E7=8E=AE=20=28Jade=20Lin=29?= Date: Wed, 1 Oct 2025 20:03:17 +0800 Subject: [PATCH 25/94] feat(mcp): notifications support - tools/list_changed (#967) Signed-off-by: Carlos Alexandro Becker Co-authored-by: Carlos Alexandro Becker --- internal/csync/maps.go | 19 +++++ internal/csync/maps_test.go | 52 +++++++++++++ internal/llm/agent/agent.go | 128 +++++++++++++++++++++++--------- internal/llm/agent/mcp-tools.go | 65 ++++++++++++---- 4 files changed, 217 insertions(+), 47 deletions(-) diff --git a/internal/csync/maps.go b/internal/csync/maps.go index b7a1f3109f6c15e7e5592cb538943a2d9e340819..1fd2005790014b2ce4bd5a78dbb7931d54cbe66c 100644 --- a/internal/csync/maps.go +++ b/internal/csync/maps.go @@ -27,6 +27,25 @@ func NewMapFrom[K comparable, V any](m map[K]V) *Map[K, V] { } } +// NewLazyMap creates a new lazy-loaded map. The provided load function is +// executed in a separate goroutine to populate the map. +func NewLazyMap[K comparable, V any](load func() map[K]V) *Map[K, V] { + m := &Map[K, V]{} + m.mu.Lock() + go func() { + m.inner = load() + m.mu.Unlock() + }() + return m +} + +// Reset replaces the inner map with the new one. +func (m *Map[K, V]) Reset(input map[K]V) { + m.mu.Lock() + defer m.mu.Unlock() + m.inner = input +} + // Set sets the value for the specified key in the map. func (m *Map[K, V]) Set(key K, value V) { m.mu.Lock() diff --git a/internal/csync/maps_test.go b/internal/csync/maps_test.go index 4a8019260a2610b7f5ae0d854029207c6b945d04..4c590f008dad91e8dcbc40d1b90d87ef1b3e5750 100644 --- a/internal/csync/maps_test.go +++ b/internal/csync/maps_test.go @@ -5,6 +5,8 @@ import ( "maps" "sync" "testing" + "testing/synctest" + "time" "github.com/stretchr/testify/require" ) @@ -36,6 +38,56 @@ func TestNewMapFrom(t *testing.T) { require.Equal(t, 1, value) } +func TestNewLazyMap(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + t.Helper() + + waiter := sync.Mutex{} + waiter.Lock() + loadCalled := false + + loadFunc := func() map[string]int { + waiter.Lock() + defer waiter.Unlock() + loadCalled = true + return map[string]int{ + "key1": 1, + "key2": 2, + } + } + + m := NewLazyMap(loadFunc) + require.NotNil(t, m) + + waiter.Unlock() // Allow the load function to proceed + time.Sleep(100 * time.Millisecond) + require.True(t, loadCalled) + require.Equal(t, 2, m.Len()) + + value, ok := m.Get("key1") + require.True(t, ok) + require.Equal(t, 1, value) + }) +} + +func TestMap_Reset(t *testing.T) { + t.Parallel() + + m := NewMapFrom(map[string]int{ + "a": 10, + }) + + m.Reset(map[string]int{ + "b": 20, + }) + value, ok := m.Get("b") + require.True(t, ok) + require.Equal(t, 20, value) + require.Equal(t, 1, m.Len()) +} + func TestMap_Set(t *testing.T) { t.Parallel() diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index 9bae6e5b8092b987b1c8146460cef946e595beb5..1efc3fc268392c06481d61ae6e11c9d67cdc13e8 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log/slog" + "maps" "slices" "strings" "time" @@ -65,11 +66,13 @@ type agent struct { sessions session.Service messages message.Service permissions permission.Service - mcpTools []McpTool + baseTools *csync.Map[string, tools.BaseTool] + mcpTools *csync.Map[string, tools.BaseTool] + lspClients *csync.Map[string, *lsp.Client] - tools *csync.LazySlice[tools.BaseTool] // We need this to be able to update it when model changes - agentToolFn func() (tools.BaseTool, error) + agentToolFn func() (tools.BaseTool, error) + cleanupFuncs []func() provider provider.Provider providerID string @@ -171,14 +174,16 @@ func NewAgent( return nil, err } - toolFn := func() []tools.BaseTool { - slog.Info("Initializing agent tools", "agent", agentCfg.ID) + baseToolsFn := func() map[string]tools.BaseTool { + slog.Info("Initializing agent base tools", "agent", agentCfg.ID) defer func() { - slog.Info("Initialized agent tools", "agent", agentCfg.ID) + slog.Info("Initialized agent base tools", "agent", agentCfg.ID) }() + // Base tools available to all agents cwd := cfg.WorkingDir() - allTools := []tools.BaseTool{ + result := make(map[string]tools.BaseTool) + for _, tool := range []tools.BaseTool{ tools.NewBashTool(permissions, cwd, cfg.Options.Attribution), tools.NewDownloadTool(permissions, cwd), tools.NewEditTool(lspClients, permissions, history, cwd), @@ -190,36 +195,25 @@ func NewAgent( tools.NewSourcegraphTool(), tools.NewViewTool(lspClients, permissions, cwd), tools.NewWriteTool(lspClients, permissions, history, cwd), + } { + result[tool.Name()] = tool } + return result + } + mcpToolsFn := func() map[string]tools.BaseTool { + slog.Info("Initializing agent mcp tools", "agent", agentCfg.ID) + defer func() { + slog.Info("Initialized agent mcp tools", "agent", agentCfg.ID) + }() mcpToolsOnce.Do(func() { - mcpTools = doGetMCPTools(ctx, permissions, cfg) + doGetMCPTools(ctx, permissions, cfg) }) - withCoderTools := func(t []tools.BaseTool) []tools.BaseTool { - if agentCfg.ID == "coder" { - t = append(t, mcpTools...) - if lspClients.Len() > 0 { - t = append(t, tools.NewDiagnosticsTool(lspClients)) - } - } - return t - } - - if agentCfg.AllowedTools == nil { - return withCoderTools(allTools) - } - - var filteredTools []tools.BaseTool - for _, tool := range allTools { - if slices.Contains(agentCfg.AllowedTools, tool.Name()) { - filteredTools = append(filteredTools, tool) - } - } - return withCoderTools(filteredTools) + return maps.Collect(mcpTools.Seq2()) } - return &agent{ + a := &agent{ Broker: pubsub.NewBroker[AgentEvent](), agentCfg: agentCfg, provider: agentProvider, @@ -231,10 +225,14 @@ func NewAgent( summarizeProviderID: string(providerCfg.ID), agentToolFn: agentToolFn, activeRequests: csync.NewMap[string, context.CancelFunc](), - tools: csync.NewLazySlice(toolFn), + mcpTools: csync.NewLazyMap(mcpToolsFn), + baseTools: csync.NewLazyMap(baseToolsFn), promptQueue: csync.NewMap[string, []string](), permissions: permissions, - }, nil + lspClients: lspClients, + } + a.setupEvents(ctx) + return a, nil } func (a *agent) Model() catwalk.Model { @@ -517,7 +515,18 @@ func (a *agent) createUserMessage(ctx context.Context, sessionID, content string } func (a *agent) getAllTools() ([]tools.BaseTool, error) { - allTools := slices.Collect(a.tools.Seq()) + var allTools []tools.BaseTool + for tool := range a.baseTools.Seq() { + if a.agentCfg.AllowedTools == nil || slices.Contains(a.agentCfg.AllowedTools, tool.Name()) { + allTools = append(allTools, tool) + } + } + if a.agentCfg.ID == "coder" { + allTools = slices.AppendSeq(allTools, a.mcpTools.Seq()) + if a.lspClients.Len() > 0 { + allTools = append(allTools, tools.NewDiagnosticsTool(a.lspClients)) + } + } if a.agentToolFn != nil { agentTool, agentToolErr := a.agentToolFn() if agentToolErr != nil { @@ -591,7 +600,7 @@ loop: default: // Continue processing var tool tools.BaseTool - allTools, _ := a.getAllTools() + allTools, _ = a.getAllTools() for _, availableTool := range allTools { if availableTool.Info().Name == toolCall.Name { tool = availableTool @@ -960,6 +969,12 @@ func (a *agent) CancelAll() { a.Cancel(key) // key is sessionID } + for _, cleanup := range a.cleanupFuncs { + if cleanup != nil { + cleanup() + } + } + timeout := time.After(5 * time.Second) for a.IsBusy() { select { @@ -1071,3 +1086,48 @@ func (a *agent) UpdateModel() error { return nil } + +func (a *agent) setupEvents(ctx context.Context) { + ctx, cancel := context.WithCancel(ctx) + + go func() { + subCh := SubscribeMCPEvents(ctx) + + for { + select { + case event, ok := <-subCh: + if !ok { + slog.Debug("MCPEvents subscription channel closed") + return + } + switch event.Payload.Type { + case MCPEventToolsListChanged: + name := event.Payload.Name + c, ok := mcpClients.Get(name) + if !ok { + slog.Warn("MCP client not found for tools update", "name", name) + continue + } + cfg := config.Get() + tools, err := getTools(ctx, name, a.permissions, c, cfg.WorkingDir()) + if err != nil { + slog.Error("error listing tools", "error", err) + updateMCPState(name, MCPStateError, err, nil, 0) + _ = c.Close() + continue + } + updateMcpTools(name, tools) + a.mcpTools.Reset(maps.Collect(mcpTools.Seq2())) + updateMCPState(name, MCPStateConnected, nil, c, a.mcpTools.Len()) + default: + continue + } + case <-ctx.Done(): + slog.Debug("MCPEvents subscription cancelled") + return + } + } + }() + + a.cleanupFuncs = append(a.cleanupFuncs, cancel) +} diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index ebd1698f2f7bf45ecda15c9160464e3d295ce3d6..181f32b7280faf3eb36040d2ebecf3f892350f53 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -8,7 +8,6 @@ import ( "fmt" "log/slog" "maps" - "slices" "strings" "sync" "time" @@ -54,7 +53,8 @@ func (s MCPState) String() string { type MCPEventType string const ( - MCPEventStateChanged MCPEventType = "state_changed" + MCPEventStateChanged MCPEventType = "state_changed" + MCPEventToolsListChanged MCPEventType = "tools_list_changed" ) // MCPEvent represents an event in the MCP system @@ -77,11 +77,12 @@ type MCPClientInfo struct { } var ( - mcpToolsOnce sync.Once - mcpTools []tools.BaseTool - mcpClients = csync.NewMap[string, *client.Client]() - mcpStates = csync.NewMap[string, MCPClientInfo]() - mcpBroker = pubsub.NewBroker[MCPEvent]() + mcpToolsOnce sync.Once + mcpTools = csync.NewMap[string, tools.BaseTool]() + mcpClient2Tools = csync.NewMap[string, []tools.BaseTool]() + mcpClients = csync.NewMap[string, *client.Client]() + mcpStates = csync.NewMap[string, MCPClientInfo]() + mcpBroker = pubsub.NewBroker[MCPEvent]() ) type McpTool struct { @@ -237,8 +238,12 @@ func updateMCPState(name string, state MCPState, err error, client *client.Clien Client: client, ToolCount: toolCount, } - if state == MCPStateConnected { + switch state { + case MCPStateConnected: info.ConnectedAt = time.Now() + case MCPStateError: + updateMcpTools(name, nil) + mcpClients.Del(name) } mcpStates.Set(name, info) @@ -252,6 +257,14 @@ func updateMCPState(name string, state MCPState, err error, client *client.Clien }) } +// publishMCPEventToolsListChanged publishes a tool list changed event +func publishMCPEventToolsListChanged(name string) { + mcpBroker.Publish(pubsub.UpdatedEvent, MCPEvent{ + Type: MCPEventToolsListChanged, + Name: name, + }) +} + // CloseMCPClients closes all MCP clients. This should be called during application shutdown. func CloseMCPClients() error { var errs []error @@ -274,10 +287,8 @@ var mcpInitRequest = mcp.InitializeRequest{ }, } -func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) []tools.BaseTool { +func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) { var wg sync.WaitGroup - result := csync.NewSlice[tools.BaseTool]() - // Initialize states for all configured MCPs for name, m := range cfg.MCP { if m.Disabled { @@ -316,6 +327,8 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con return } + mcpClients.Set(name, c) + tools, err := getTools(ctx, name, permissions, c, cfg.WorkingDir()) if err != nil { slog.Error("error listing tools", "error", err) @@ -324,13 +337,26 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con return } + updateMcpTools(name, tools) mcpClients.Set(name, c) updateMCPState(name, MCPStateConnected, nil, c, len(tools)) - result.Append(tools...) }(name, m) } wg.Wait() - return slices.Collect(result.Seq()) +} + +// updateMcpTools updates the global mcpTools and mcpClient2Tools maps +func updateMcpTools(mcpName string, tools []tools.BaseTool) { + if len(tools) == 0 { + mcpClient2Tools.Del(mcpName) + } else { + mcpClient2Tools.Set(mcpName, tools) + } + for _, tools := range mcpClient2Tools.Seq2() { + for _, t := range tools { + mcpTools.Set(t.Name(), t) + } + } } func createAndInitializeClient(ctx context.Context, name string, m config.MCPConfig, resolver config.VariableResolver) (*client.Client, error) { @@ -341,11 +367,22 @@ func createAndInitializeClient(ctx context.Context, name string, m config.MCPCon return nil, err } + c.OnNotification(func(n mcp.JSONRPCNotification) { + slog.Debug("Received MCP notification", "name", name, "notification", n) + switch n.Method { + case "notifications/tools/list_changed": + publishMCPEventToolsListChanged(name) + default: + slog.Debug("Unhandled MCP notification", "name", name, "method", n.Method) + } + }) + // XXX: ideally we should be able to use context.WithTimeout here, but, // the SSE MCP client will start failing once that context is canceled. timeout := mcpTimeout(m) mcpCtx, cancel := context.WithCancel(ctx) cancelTimer := time.AfterFunc(timeout, cancel) + if err := c.Start(mcpCtx); err != nil { updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0) slog.Error("error starting mcp client", "error", err, "name", name) @@ -353,6 +390,7 @@ func createAndInitializeClient(ctx context.Context, name string, m config.MCPCon cancel() return nil, err } + if _, err := c.Initialize(mcpCtx, mcpInitRequest); err != nil { updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0) slog.Error("error initializing mcp client", "error", err, "name", name) @@ -360,6 +398,7 @@ func createAndInitializeClient(ctx context.Context, name string, m config.MCPCon cancel() return nil, err } + cancelTimer.Stop() slog.Info("Initialized mcp client", "name", name) return c, nil From d102102ce498e69b4d22d0d5fbca372a941b42ec Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 30 Sep 2025 17:04:59 -0400 Subject: [PATCH 26/94] chore(task): annotate tags during release --- Taskfile.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index 54b50a68217b6ff66ddf1de9a28a8f45d224fefc..0739cab998f34c7b4129b0765b9b225a2455f8ae 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -90,7 +90,7 @@ tasks: vars: NEXT: sh: go run github.com/caarlos0/svu/v3@latest next --always - prompt: "This will release {{.NEXT}}. Continue?" + prompt: "This will release {{.NEXT}}. Please make sure you've fetch tags. Continue?" preconditions: - sh: '[ $(git symbolic-ref --short HEAD) = "main" ]' msg: Not on main branch @@ -99,6 +99,6 @@ tasks: cmds: - git commit --allow-empty -m "{{.NEXT}}" - git tag -d nightly - - git tag --sign {{.NEXT}} {{.CLI_ARGS}} + - git tag --annotate --sign {{.NEXT}} {{.CLI_ARGS}} - echo "Pushing {{.NEXT}}..." - git push origin --tags From c96abaed6b4115352e490129006dcfd165b49cba Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 1 Oct 2025 11:16:10 -0300 Subject: [PATCH 27/94] feat: limit filepath walk, automatic low limits when not git repo (#1052) Signed-off-by: Carlos Alexandro Becker --- internal/config/config.go | 35 ++++++++ internal/config/load.go | 27 ++++++ internal/fsext/fileutil.go | 65 ++++---------- internal/fsext/fileutil_test.go | 90 +++++++++---------- internal/fsext/ignore_test.go | 8 +- internal/fsext/lookup_test.go | 64 ++----------- internal/fsext/ls.go | 33 ++++--- internal/fsext/ls_test.go | 73 +++++++-------- internal/llm/prompt/coder.go | 2 +- internal/llm/tools/ls.go | 61 +++++++------ internal/tui/components/chat/editor/editor.go | 4 +- schema.json | 72 ++++++++++++++- 12 files changed, 296 insertions(+), 238 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index fc5d62ef1c361c4e4aae29a2683ed92c8e76fd9d..858fa1c47b33f6a5e6bafb81b4799ea5739736f9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -131,6 +131,19 @@ type TUIOptions struct { CompactMode bool `json:"compact_mode,omitempty" jsonschema:"description=Enable compact mode for the TUI interface,default=false"` DiffMode string `json:"diff_mode,omitempty" jsonschema:"description=Diff mode for the TUI interface,enum=unified,enum=split"` // Here we can add themes later or any TUI related options + // + + Completions Completions `json:"completions,omitzero" jsonschema:"description=Completions UI options"` +} + +// Completions defines options for the completions UI. +type Completions struct { + MaxDepth *int `json:"max_depth,omitempty" jsonschema:"description=Maximum depth for the ls tool,default=0,example=10"` + MaxItems *int `json:"max_items,omitempty" jsonschema:"description=Maximum number of items to return for the ls tool,default=1000,example=100"` +} + +func (c Completions) Limits() (depth, items int) { + return ptrValOr(c.MaxDepth, -1), ptrValOr(c.MaxItems, -1) } type Permissions struct { @@ -246,6 +259,19 @@ type Agent struct { ContextPaths []string `json:"context_paths,omitempty"` } +type Tools struct { + Ls ToolLs `json:"ls,omitzero"` +} + +type ToolLs struct { + MaxDepth *int `json:"max_depth,omitempty" jsonschema:"description=Maximum depth for the ls tool,default=0,example=10"` + MaxItems *int `json:"max_items,omitempty" jsonschema:"description=Maximum number of items to return for the ls tool,default=1000,example=100"` +} + +func (t ToolLs) Limits() (depth, items int) { + return ptrValOr(t.MaxDepth, -1), ptrValOr(t.MaxItems, -1) +} + // Config holds the configuration for crush. type Config struct { Schema string `json:"$schema,omitempty"` @@ -264,6 +290,8 @@ type Config struct { Permissions *Permissions `json:"permissions,omitempty" jsonschema:"description=Permission settings for tool usage"` + Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"` + // Internal workingDir string `json:"-"` // TODO: most likely remove this concept when I come back to it @@ -579,3 +607,10 @@ func resolveEnvs(envs map[string]string) []string { } return res } + +func ptrValOr[T any](t *T, el T) T { + if t == nil { + return el + } + return *t +} diff --git a/internal/config/load.go b/internal/config/load.go index b36813084049a89b5e67d79d6342335cb85230e3..9fb45028d6936a652f2657f51707b6cde73f4084 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -1,12 +1,14 @@ package config import ( + "context" "encoding/json" "fmt" "io" "log/slog" "maps" "os" + "os/exec" "path/filepath" "runtime" "slices" @@ -62,6 +64,16 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) { cfg.Options.Debug, ) + if !isInsideWorktree() { + const depth = 2 + const items = 100 + slog.Warn("No git repository detected in working directory, will limit file walk operations", "depth", depth, "items", items) + assignIfNil(&cfg.Tools.Ls.MaxDepth, depth) + assignIfNil(&cfg.Tools.Ls.MaxItems, items) + assignIfNil(&cfg.Options.TUI.Completions.MaxDepth, depth) + assignIfNil(&cfg.Options.TUI.Completions.MaxItems, items) + } + // Load known providers, this loads the config from catwalk providers, err := Providers(cfg) if err != nil { @@ -638,3 +650,18 @@ func GlobalConfigData() string { return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName)) } + +func assignIfNil[T any](ptr **T, val T) { + if *ptr == nil { + *ptr = &val + } +} + +func isInsideWorktree() bool { + bts, err := exec.CommandContext( + context.Background(), + "git", "rev-parse", + "--is-inside-work-tree", + ).CombinedOutput() + return err == nil && strings.TrimSpace(string(bts)) == "true" +} diff --git a/internal/fsext/fileutil.go b/internal/fsext/fileutil.go index 30c552324452cbce4436701506419916c014d7f9..182b145a609311d20544d399c1212097c7519dda 100644 --- a/internal/fsext/fileutil.go +++ b/internal/fsext/fileutil.go @@ -1,15 +1,17 @@ package fsext import ( + "errors" "fmt" "os" "path/filepath" - "sort" + "slices" "strings" "time" "github.com/bmatcuk/doublestar/v4" "github.com/charlievieth/fastwalk" + "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/home" ) @@ -80,10 +82,9 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool, pattern = filepath.ToSlash(pattern) walker := NewFastGlobWalker(searchPath) - var matches []FileInfo + found := csync.NewSlice[FileInfo]() conf := fastwalk.Config{ - Follow: true, - // Use forward slashes when running a Windows binary under WSL or MSYS + Follow: true, ToSlash: fastwalk.DefaultToSlash(), Sort: fastwalk.SortFilesFirst, } @@ -121,31 +122,26 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool, return nil } - matches = append(matches, FileInfo{Path: path, ModTime: info.ModTime()}) - if limit > 0 && len(matches) >= limit*2 { + found.Append(FileInfo{Path: path, ModTime: info.ModTime()}) + if limit > 0 && found.Len() >= limit*2 { // NOTE: why x2? return filepath.SkipAll } return nil }) - if err != nil { + if err != nil && !errors.Is(err, filepath.SkipAll) { return nil, false, fmt.Errorf("fastwalk error: %w", err) } - sort.Slice(matches, func(i, j int) bool { - return matches[i].ModTime.After(matches[j].ModTime) + matches := slices.SortedFunc(found.Seq(), func(a, b FileInfo) int { + return b.ModTime.Compare(a.ModTime) }) - - truncated := false - if limit > 0 && len(matches) > limit { - matches = matches[:limit] - truncated = true - } + matches, truncated := truncate(matches, limit) results := make([]string, len(matches)) for i, m := range matches { results[i] = m.Path } - return results, truncated, nil + return results, truncated || errors.Is(err, filepath.SkipAll), nil } // ShouldExcludeFile checks if a file should be excluded from processing @@ -155,36 +151,6 @@ func ShouldExcludeFile(rootPath, filePath string) bool { shouldIgnore(filePath, nil) } -// WalkDirectories walks a directory tree and calls the provided function for each directory, -// respecting hierarchical .gitignore/.crushignore files like git does. -func WalkDirectories(rootPath string, fn func(path string, d os.DirEntry, err error) error) error { - dl := NewDirectoryLister(rootPath) - - conf := fastwalk.Config{ - Follow: true, - ToSlash: fastwalk.DefaultToSlash(), - Sort: fastwalk.SortDirsFirst, - } - - return fastwalk.Walk(&conf, rootPath, func(path string, d os.DirEntry, err error) error { - if err != nil { - return fn(path, d, err) - } - - // Only process directories - if !d.IsDir() { - return nil - } - - // Check if directory should be ignored - if dl.shouldIgnore(path, nil) { - return filepath.SkipDir - } - - return fn(path, d, err) - }) -} - func PrettyPath(path string) string { return home.Short(path) } @@ -248,3 +214,10 @@ func ToWindowsLineEndings(content string) (string, bool) { } return content, false } + +func truncate[T any](input []T, limit int) ([]T, bool) { + if limit > 0 && len(input) > limit { + return input[:limit], true + } + return input, false +} diff --git a/internal/fsext/fileutil_test.go b/internal/fsext/fileutil_test.go index 1779bfb9312f7834748badaf72a47563878f21da..3788fe5477b082dec496275a8ac028788d55fc64 100644 --- a/internal/fsext/fileutil_test.go +++ b/internal/fsext/fileutil_test.go @@ -5,7 +5,6 @@ import ( "os" "path/filepath" "testing" - "testing/synctest" "time" "github.com/stretchr/testify/require" @@ -148,37 +147,35 @@ func TestGlobWithDoubleStar(t *testing.T) { require.NoError(t, err) require.False(t, truncated) - require.Equal(t, matches, []string{file1}) + require.Equal(t, []string{file1}, matches) }) t.Run("returns results sorted by modification time (newest first)", func(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - testDir := t.TempDir() + testDir := t.TempDir() - file1 := filepath.Join(testDir, "file1.txt") - require.NoError(t, os.WriteFile(file1, []byte("first"), 0o644)) + file1 := filepath.Join(testDir, "file1.txt") + require.NoError(t, os.WriteFile(file1, []byte("first"), 0o644)) - file2 := filepath.Join(testDir, "file2.txt") - require.NoError(t, os.WriteFile(file2, []byte("second"), 0o644)) + file2 := filepath.Join(testDir, "file2.txt") + require.NoError(t, os.WriteFile(file2, []byte("second"), 0o644)) - file3 := filepath.Join(testDir, "file3.txt") - require.NoError(t, os.WriteFile(file3, []byte("third"), 0o644)) + file3 := filepath.Join(testDir, "file3.txt") + require.NoError(t, os.WriteFile(file3, []byte("third"), 0o644)) - base := time.Now() - m1 := base - m2 := base.Add(1 * time.Millisecond) - m3 := base.Add(2 * time.Millisecond) + base := time.Now() + m1 := base + m2 := base.Add(10 * time.Hour) + m3 := base.Add(20 * time.Hour) - require.NoError(t, os.Chtimes(file1, m1, m1)) - require.NoError(t, os.Chtimes(file2, m2, m2)) - require.NoError(t, os.Chtimes(file3, m3, m3)) + require.NoError(t, os.Chtimes(file1, m1, m1)) + require.NoError(t, os.Chtimes(file2, m2, m2)) + require.NoError(t, os.Chtimes(file3, m3, m3)) - matches, truncated, err := GlobWithDoubleStar("*.txt", testDir, 0) - require.NoError(t, err) - require.False(t, truncated) + matches, truncated, err := GlobWithDoubleStar("*.txt", testDir, 0) + require.NoError(t, err) + require.False(t, truncated) - require.Equal(t, matches, []string{file3, file2, file1}) - }) + require.Equal(t, []string{file3, file2, file1}, matches) }) t.Run("handles empty directory", func(t *testing.T) { @@ -188,7 +185,7 @@ func TestGlobWithDoubleStar(t *testing.T) { require.NoError(t, err) require.False(t, truncated) // Even empty directories should return the directory itself - require.Equal(t, matches, []string{testDir}) + require.Equal(t, []string{testDir}, matches) }) t.Run("handles non-existent search path", func(t *testing.T) { @@ -235,39 +232,38 @@ func TestGlobWithDoubleStar(t *testing.T) { matches, truncated, err = GlobWithDoubleStar("*.txt", testDir, 0) require.NoError(t, err) require.False(t, truncated) - require.Equal(t, matches, []string{goodFile}) + require.Equal(t, []string{goodFile}, matches) }) t.Run("handles mixed file and directory matching with sorting", func(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - testDir := t.TempDir() + testDir := t.TempDir() - oldestFile := filepath.Join(testDir, "old.test") - require.NoError(t, os.WriteFile(oldestFile, []byte("old"), 0o644)) + oldestFile := filepath.Join(testDir, "old.rs") + require.NoError(t, os.WriteFile(oldestFile, []byte("old"), 0o644)) - middleDir := filepath.Join(testDir, "mid.test") - require.NoError(t, os.MkdirAll(middleDir, 0o755)) + middleDir := filepath.Join(testDir, "mid.rs") + require.NoError(t, os.MkdirAll(middleDir, 0o755)) - newestFile := filepath.Join(testDir, "new.test") - require.NoError(t, os.WriteFile(newestFile, []byte("new"), 0o644)) + newestFile := filepath.Join(testDir, "new.rs") + require.NoError(t, os.WriteFile(newestFile, []byte("new"), 0o644)) - base := time.Now() - tOldest := base - tMiddle := base.Add(1 * time.Millisecond) - tNewest := base.Add(2 * time.Millisecond) + base := time.Now() + tOldest := base + tMiddle := base.Add(10 * time.Hour) + tNewest := base.Add(20 * time.Hour) - // Reverse the expected order - require.NoError(t, os.Chtimes(newestFile, tOldest, tOldest)) - require.NoError(t, os.Chtimes(middleDir, tMiddle, tMiddle)) - require.NoError(t, os.Chtimes(oldestFile, tNewest, tNewest)) + // Reverse the expected order + require.NoError(t, os.Chtimes(newestFile, tOldest, tOldest)) + require.NoError(t, os.Chtimes(middleDir, tMiddle, tMiddle)) + require.NoError(t, os.Chtimes(oldestFile, tNewest, tNewest)) - matches, truncated, err := GlobWithDoubleStar("*.test", testDir, 0) - require.NoError(t, err) - require.False(t, truncated) + matches, truncated, err := GlobWithDoubleStar("*.rs", testDir, 0) + require.NoError(t, err) + require.False(t, truncated) + require.Len(t, matches, 3) - // Results should be sorted by mod time, but we set the oldestFile - // to have the most recent mod time - require.Equal(t, matches, []string{oldestFile, middleDir, newestFile}) - }) + // Results should be sorted by mod time, but we set the oldestFile + // to have the most recent mod time + require.Equal(t, []string{oldestFile, middleDir, newestFile}, matches) }) } diff --git a/internal/fsext/ignore_test.go b/internal/fsext/ignore_test.go index 1b517ec0408fe69726bf4fa4bbb95c2a206e548c..a652f3a285fd256840fb3a711fb36e0217a43e28 100644 --- a/internal/fsext/ignore_test.go +++ b/internal/fsext/ignore_test.go @@ -9,14 +9,8 @@ import ( ) func TestCrushIgnore(t *testing.T) { - // Create a temporary directory for testing tempDir := t.TempDir() - - // Change to temp directory - oldWd, _ := os.Getwd() - err := os.Chdir(tempDir) - require.NoError(t, err) - defer os.Chdir(oldWd) + t.Chdir(tempDir) // Create test files require.NoError(t, os.WriteFile("test1.txt", []byte("test"), 0o644)) diff --git a/internal/fsext/lookup_test.go b/internal/fsext/lookup_test.go index b7604331673aad0d65d34e046901bc9eae722195..97c167f37d8ebcf4d19124367955874e7f816b67 100644 --- a/internal/fsext/lookup_test.go +++ b/internal/fsext/lookup_test.go @@ -12,15 +12,7 @@ import ( func TestLookupClosest(t *testing.T) { tempDir := t.TempDir() - - // Change to temp directory - oldWd, _ := os.Getwd() - err := os.Chdir(tempDir) - require.NoError(t, err) - - t.Cleanup(func() { - os.Chdir(oldWd) - }) + t.Chdir(tempDir) t.Run("target found in starting directory", func(t *testing.T) { testDir := t.TempDir() @@ -114,24 +106,15 @@ func TestLookupClosest(t *testing.T) { }) t.Run("relative path handling", func(t *testing.T) { - testDir := t.TempDir() - - // Change to test directory - oldWd, _ := os.Getwd() - err := os.Chdir(testDir) - require.NoError(t, err) - defer os.Chdir(oldWd) - // Create target file in current directory - err = os.WriteFile("target.txt", []byte("test"), 0o644) - require.NoError(t, err) + require.NoError(t, os.WriteFile("target.txt", []byte("test"), 0o644)) // Search using relative path foundPath, found := LookupClosest(".", "target.txt") require.True(t, found) // Resolve symlinks to handle macOS /private/var vs /var discrepancy - expectedPath, err := filepath.EvalSymlinks(filepath.Join(testDir, "target.txt")) + expectedPath, err := filepath.EvalSymlinks(filepath.Join(tempDir, "target.txt")) require.NoError(t, err) actualPath, err := filepath.EvalSymlinks(foundPath) require.NoError(t, err) @@ -145,15 +128,7 @@ func TestLookupClosestWithOwnership(t *testing.T) { // This test focuses on the basic functionality when ownership checks pass. tempDir := t.TempDir() - - // Change to temp directory - oldWd, _ := os.Getwd() - err := os.Chdir(tempDir) - require.NoError(t, err) - - t.Cleanup(func() { - os.Chdir(oldWd) - }) + t.Chdir(tempDir) t.Run("search respects same ownership", func(t *testing.T) { testDir := t.TempDir() @@ -177,15 +152,7 @@ func TestLookupClosestWithOwnership(t *testing.T) { func TestLookup(t *testing.T) { tempDir := t.TempDir() - - // Change to temp directory - oldWd, _ := os.Getwd() - err := os.Chdir(tempDir) - require.NoError(t, err) - - t.Cleanup(func() { - os.Chdir(oldWd) - }) + t.Chdir(tempDir) t.Run("no targets returns empty slice", func(t *testing.T) { testDir := t.TempDir() @@ -358,22 +325,9 @@ func TestLookup(t *testing.T) { }) t.Run("relative path handling", func(t *testing.T) { - testDir := t.TempDir() - - // Change to test directory - oldWd, _ := os.Getwd() - err := os.Chdir(testDir) - require.NoError(t, err) - - t.Cleanup(func() { - os.Chdir(oldWd) - }) - // Create target files in current directory - err = os.WriteFile("target1.txt", []byte("test1"), 0o644) - require.NoError(t, err) - err = os.WriteFile("target2.txt", []byte("test2"), 0o644) - require.NoError(t, err) + require.NoError(t, os.WriteFile("target1.txt", []byte("test1"), 0o644)) + require.NoError(t, os.WriteFile("target2.txt", []byte("test2"), 0o644)) // Search using relative path found, err := Lookup(".", "target1.txt", "target2.txt") @@ -381,9 +335,9 @@ func TestLookup(t *testing.T) { require.Len(t, found, 2) // Resolve symlinks to handle macOS /private/var vs /var discrepancy - expectedPath1, err := filepath.EvalSymlinks(filepath.Join(testDir, "target1.txt")) + expectedPath1, err := filepath.EvalSymlinks(filepath.Join(tempDir, "target1.txt")) require.NoError(t, err) - expectedPath2, err := filepath.EvalSymlinks(filepath.Join(testDir, "target2.txt")) + expectedPath2, err := filepath.EvalSymlinks(filepath.Join(tempDir, "target2.txt")) require.NoError(t, err) // Check that found paths match expected paths (order may vary) diff --git a/internal/fsext/ls.go b/internal/fsext/ls.go index 2027f734c4156572b134c012b2e3c143c364bd29..80d25a57f19867a4ca2af44df7e691bb9d109496 100644 --- a/internal/fsext/ls.go +++ b/internal/fsext/ls.go @@ -1,6 +1,7 @@ package fsext import ( + "errors" "log/slog" "os" "path/filepath" @@ -71,6 +72,11 @@ var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser { // Crush ".crush", + + // macOS stuff + "OrbStack", + ".local", + ".share", ) }) @@ -200,16 +206,17 @@ func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser { } // ListDirectory lists files and directories in the specified path, -func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) { - results := csync.NewSlice[string]() - truncated := false +func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int) ([]string, bool, error) { + found := csync.NewSlice[string]() dl := NewDirectoryLister(initialPath) + slog.Warn("listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns) + conf := fastwalk.Config{ - Follow: true, - // Use forward slashes when running a Windows binary under WSL or MSYS - ToSlash: fastwalk.DefaultToSlash(), - Sort: fastwalk.SortDirsFirst, + Follow: true, + ToSlash: fastwalk.DefaultToSlash(), + Sort: fastwalk.SortDirsFirst, + MaxDepth: depth, } err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error { @@ -228,19 +235,19 @@ func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]st if d.IsDir() { path = path + string(filepath.Separator) } - results.Append(path) + found.Append(path) } - if limit > 0 && results.Len() >= limit { - truncated = true + if limit > 0 && found.Len() >= limit { return filepath.SkipAll } return nil }) - if err != nil && results.Len() == 0 { - return nil, truncated, err + if err != nil && !errors.Is(err, filepath.SkipAll) { + return nil, false, err } - return slices.Collect(results.Seq()), truncated, nil + matches, truncated := truncate(slices.Collect(found.Seq()), limit) + return matches, truncated || errors.Is(err, filepath.SkipAll), nil } diff --git a/internal/fsext/ls_test.go b/internal/fsext/ls_test.go index a74ca3276c9af0edac6adbe1bd6e367d952af492..7bdad17fc46955d49fa08f7488d6efe8239294cb 100644 --- a/internal/fsext/ls_test.go +++ b/internal/fsext/ls_test.go @@ -5,26 +5,11 @@ import ( "path/filepath" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func chdir(t *testing.T, dir string) { - original, err := os.Getwd() - require.NoError(t, err) - - err = os.Chdir(dir) - require.NoError(t, err) - - t.Cleanup(func() { - err := os.Chdir(original) - require.NoError(t, err) - }) -} - func TestListDirectory(t *testing.T) { - tempDir := t.TempDir() - chdir(t, tempDir) + tmp := t.TempDir() testFiles := map[string]string{ "regular.txt": "content", @@ -35,32 +20,40 @@ func TestListDirectory(t *testing.T) { "build.log": "build output", } - for filePath, content := range testFiles { - dir := filepath.Dir(filePath) - if dir != "." { - require.NoError(t, os.MkdirAll(dir, 0o755)) - } - - err := os.WriteFile(filePath, []byte(content), 0o644) - require.NoError(t, err) + for name, content := range testFiles { + fp := filepath.Join(tmp, name) + dir := filepath.Dir(fp) + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(fp, []byte(content), 0o644)) } - files, truncated, err := ListDirectory(".", nil, 0) - require.NoError(t, err) - assert.False(t, truncated) - assert.Equal(t, len(files), 4) + t.Run("no limit", func(t *testing.T) { + files, truncated, err := ListDirectory(tmp, nil, -1, -1) + require.NoError(t, err) + require.False(t, truncated) + require.Len(t, files, 4) + require.ElementsMatch(t, []string{ + "regular.txt", + "subdir", + "subdir/.another", + "subdir/file.go", + }, relPaths(t, files, tmp)) + }) + t.Run("limit", func(t *testing.T) { + files, truncated, err := ListDirectory(tmp, nil, -1, 2) + require.NoError(t, err) + require.True(t, truncated) + require.Len(t, files, 2) + }) +} - fileSet := make(map[string]bool) - for _, file := range files { - fileSet[filepath.ToSlash(file)] = true +func relPaths(tb testing.TB, in []string, base string) []string { + tb.Helper() + out := make([]string, 0, len(in)) + for _, p := range in { + rel, err := filepath.Rel(base, p) + require.NoError(tb, err) + out = append(out, filepath.ToSlash(rel)) } - - assert.True(t, fileSet["./regular.txt"]) - assert.True(t, fileSet["./subdir/"]) - assert.True(t, fileSet["./subdir/file.go"]) - assert.True(t, fileSet["./regular.txt"]) - - assert.False(t, fileSet["./.hidden"]) - assert.False(t, fileSet["./.gitignore"]) - assert.False(t, fileSet["./build.log"]) + return out } diff --git a/internal/llm/prompt/coder.go b/internal/llm/prompt/coder.go index 90e5a17191f346a5df53622e1826bc04214ddbfc..57ed088b22de03fe875ad0822f159b35eb36a834 100644 --- a/internal/llm/prompt/coder.go +++ b/internal/llm/prompt/coder.go @@ -53,7 +53,7 @@ func getEnvironmentInfo() string { isGit := isGitRepo(cwd) platform := runtime.GOOS date := time.Now().Format("1/2/2006") - output, _ := tools.ListDirectoryTree(cwd, nil) + output, _, _ := tools.ListDirectoryTree(cwd, tools.LSParams{}) return fmt.Sprintf(`Here is useful information about the environment you are running in: Working directory: %s diff --git a/internal/llm/tools/ls.go b/internal/llm/tools/ls.go index f421e69e7af938801aa9c3affacfe30ed669fabc..305f7f10249594ff06ac008a8bf81145d7d834de 100644 --- a/internal/llm/tools/ls.go +++ b/internal/llm/tools/ls.go @@ -1,6 +1,7 @@ package tools import ( + "cmp" "context" _ "embed" "encoding/json" @@ -9,6 +10,7 @@ import ( "path/filepath" "strings" + "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/permission" ) @@ -16,11 +18,13 @@ import ( type LSParams struct { Path string `json:"path"` Ignore []string `json:"ignore"` + Depth int `json:"depth"` } type LSPermissionsParams struct { Path string `json:"path"` Ignore []string `json:"ignore"` + Depth int `json:"depth"` } type TreeNode struct { @@ -42,7 +46,7 @@ type lsTool struct { const ( LSToolName = "ls" - MaxLSFiles = 1000 + maxLSFiles = 1000 ) //go:embed ls.md @@ -68,6 +72,10 @@ func (l *lsTool) Info() ToolInfo { "type": "string", "description": "The path to the directory to list (defaults to current working directory)", }, + "depth": map[string]any{ + "type": "integer", + "description": "The maximum depth to traverse", + }, "ignore": map[string]any{ "type": "array", "description": "List of glob patterns to ignore", @@ -86,13 +94,7 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) { return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil } - searchPath := params.Path - if searchPath == "" { - searchPath = l.workingDir - } - - var err error - searchPath, err = fsext.Expand(searchPath) + searchPath, err := fsext.Expand(cmp.Or(params.Path, l.workingDir)) if err != nil { return ToolResponse{}, fmt.Errorf("error expanding path: %w", err) } @@ -137,44 +139,49 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) { } } - output, err := ListDirectoryTree(searchPath, params.Ignore) + output, metadata, err := ListDirectoryTree(searchPath, params) if err != nil { return ToolResponse{}, err } - // Get file count for metadata - files, truncated, err := fsext.ListDirectory(searchPath, params.Ignore, MaxLSFiles) - if err != nil { - return ToolResponse{}, fmt.Errorf("error listing directory for metadata: %w", err) - } - return WithResponseMetadata( NewTextResponse(output), - LSResponseMetadata{ - NumberOfFiles: len(files), - Truncated: truncated, - }, + metadata, ), nil } -func ListDirectoryTree(searchPath string, ignore []string) (string, error) { +func ListDirectoryTree(searchPath string, params LSParams) (string, LSResponseMetadata, error) { if _, err := os.Stat(searchPath); os.IsNotExist(err) { - return "", fmt.Errorf("path does not exist: %s", searchPath) + return "", LSResponseMetadata{}, fmt.Errorf("path does not exist: %s", searchPath) } - files, truncated, err := fsext.ListDirectory(searchPath, ignore, MaxLSFiles) + ls := config.Get().Tools.Ls + depth, limit := ls.Limits() + maxFiles := min(limit, maxLSFiles) + files, truncated, err := fsext.ListDirectory( + searchPath, + params.Ignore, + cmp.Or(params.Depth, depth), + maxFiles, + ) if err != nil { - return "", fmt.Errorf("error listing directory: %w", err) + return "", LSResponseMetadata{}, fmt.Errorf("error listing directory: %w", err) } + metadata := LSResponseMetadata{ + NumberOfFiles: len(files), + Truncated: truncated, + } tree := createFileTree(files, searchPath) - output := printTree(tree, searchPath) + var output string if truncated { - output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %d files and directories are included below:\n\n%s", MaxLSFiles, MaxLSFiles, output) + output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %[1]d files and directories are included below.\n", maxFiles) } - - return output, nil + if depth > 0 { + output = fmt.Sprintf("The directory tree is shown up to a depth of %d. Use a higher depth and a specific path to see more levels.\n", cmp.Or(params.Depth, depth)) + } + return output + "\n" + printTree(tree, searchPath), metadata, nil } func createFileTree(sortedPaths []string, rootPath string) []*TreeNode { diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 86390611f6115fc14def1e8a7713b252b0d6a59d..f70a0a3dbe63a9473f552efa233e03bd4efc0ee1 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -480,7 +480,9 @@ func (m *editorCmp) SetPosition(x, y int) tea.Cmd { } func (m *editorCmp) startCompletions() tea.Msg { - files, _, _ := fsext.ListDirectory(".", nil, 0) + ls := m.app.Config().Options.TUI.Completions + depth, limit := ls.Limits() + files, _, _ := fsext.ListDirectory(".", nil, depth, limit) slices.Sort(files) completionItems := make([]completions.Completion, 0, len(files)) for _, file := range files { diff --git a/schema.json b/schema.json index deb65846fe30ca689779e36745b9a429082c452b..014155f1b1f22309ec6381f44c41e97b3b3825dc 100644 --- a/schema.json +++ b/schema.json @@ -19,6 +19,28 @@ "additionalProperties": false, "type": "object" }, + "Completions": { + "properties": { + "max_depth": { + "type": "integer", + "description": "Maximum depth for the ls tool", + "default": 0, + "examples": [ + 10 + ] + }, + "max_items": { + "type": "integer", + "description": "Maximum number of items to return for the ls tool", + "default": 1000, + "examples": [ + 100 + ] + } + }, + "additionalProperties": false, + "type": "object" + }, "Config": { "properties": { "$schema": { @@ -53,10 +75,17 @@ "permissions": { "$ref": "#/$defs/Permissions", "description": "Permission settings for tool usage" + }, + "tools": { + "$ref": "#/$defs/Tools", + "description": "Tool configurations" } }, "additionalProperties": false, - "type": "object" + "type": "object", + "required": [ + "tools" + ] }, "LSPConfig": { "properties": { @@ -484,10 +513,51 @@ "split" ], "description": "Diff mode for the TUI interface" + }, + "completions": { + "$ref": "#/$defs/Completions", + "description": "Completions UI options" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "completions" + ] + }, + "ToolLs": { + "properties": { + "max_depth": { + "type": "integer", + "description": "Maximum depth for the ls tool", + "default": 0, + "examples": [ + 10 + ] + }, + "max_items": { + "type": "integer", + "description": "Maximum number of items to return for the ls tool", + "default": 1000, + "examples": [ + 100 + ] } }, "additionalProperties": false, "type": "object" + }, + "Tools": { + "properties": { + "ls": { + "$ref": "#/$defs/ToolLs" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "ls" + ] } } } From f7ffd8a884cd07ad3b59ef00e79a50b787500bab Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 5 Aug 2025 22:40:57 -0400 Subject: [PATCH 28/94] fix: don't supress application level panics --- main.go | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/main.go b/main.go index 49dbcd7d3c045ae1510d7ca2055fa480c6fadadf..e75cb03e3575cf902c2ff4b44ddd15e0405f0b60 100644 --- a/main.go +++ b/main.go @@ -3,23 +3,14 @@ package main import ( "log/slog" "net/http" + _ "net/http/pprof" "os" - _ "net/http/pprof" // profiling - - _ "github.com/joho/godotenv/autoload" // automatically load .env files - "github.com/charmbracelet/crush/internal/cmd" - "github.com/charmbracelet/crush/internal/event" - "github.com/charmbracelet/crush/internal/log" + _ "github.com/joho/godotenv/autoload" ) func main() { - defer log.RecoverPanic("main", func() { - event.Flush() - slog.Error("Application terminated due to unhandled panic") - }) - if os.Getenv("CRUSH_PROFILE") != "" { go func() { slog.Info("Serving pprof at localhost:6060") From d0724b16aa37057635896d04d0032413cf7923c7 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 1 Oct 2025 15:15:46 -0300 Subject: [PATCH 29/94] feat(tui): progress bar (#1162) Signed-off-by: Carlos Alexandro Becker --- go.mod | 6 +++--- go.sum | 12 ++++++++---- internal/app/app.go | 9 ++++++--- internal/tui/tui.go | 7 +++++++ 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index 699233cdd52fe59165e8f9c44a85d1413f1bc4b6..4beabb5b74efb565432aa36329698169e8aaae8d 100644 --- a/go.mod +++ b/go.mod @@ -13,13 +13,13 @@ require ( github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/charlievieth/fastwalk v1.0.14 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 - github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250910155747-997384b0b35e + github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250930175933-4cafc092c5e7 github.com/charmbracelet/catwalk v0.6.1 github.com/charmbracelet/fang v0.4.2 github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 - github.com/charmbracelet/x/ansi v0.10.1 + github.com/charmbracelet/x/ansi v0.10.2 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec @@ -104,7 +104,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.3.0 github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.17 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect diff --git a/go.sum b/go.sum index f54651f8f6b5fa0e6f9f4a3ee53a61d0eec0970c..55872255a6bf56f4ef8093dc277fc17c6df9092a 100644 --- a/go.sum +++ b/go.sum @@ -80,6 +80,8 @@ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250910155747-997384b0b35e h1:4BBnKWFwJ5FLyhw/ijFxKE04i9rubr8WIPR1kjO57iA= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250910155747-997384b0b35e/go.mod h1:F7AfLKYQqpM3NNBVs7ctW417tavhvoh9SBjsgtwpzbY= +github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250930175933-4cafc092c5e7 h1:wH4F+UvxcZSDOxy8j45tghiRo8amrYHejbE9+1C6xv0= +github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250930175933-4cafc092c5e7/go.mod h1:5IzIGXU1n0foRc8bRAherC8ZuQCQURPlwx3ANLq1138= github.com/charmbracelet/catwalk v0.6.1 h1:2rRqUlwo+fdyIty8jEvUufRTgqBl0aea21LV6YQPqb0= github.com/charmbracelet/catwalk v0.6.1/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY= github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= @@ -94,8 +96,10 @@ github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 h1:WkwO6Ks3mS github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706/go.mod h1:mjJGp00cxcfvD5xdCa+bso251Jt4owrQvuimJtVmEmM= github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef h1:VrWaUi2LXYLjfjCHowdSOEc6dQ9Ro14KY7Bw4IWd19M= github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef/go.mod h1:AThRsQH1t+dfyOKIwXRoJBniYFQUkUpQq4paheHMc2o= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/ansi v0.10.2-0.20250929231137-76218bae042e h1:aN905mmYvQsT4xKUZTdM+lCcuxTrubM3DGVTPxk0ArM= +github.com/charmbracelet/x/ansi v0.10.2-0.20250929231137-76218bae042e/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= +github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw= +github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a h1:zYSNtEJM9jwHbJts2k+Hroj+xQwsW1yxc4Wopdv7KaI= github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a/go.mod h1:rc2bsPC6MWae3LdOxNO1mOb443NlMrrDL0xEya48NNc= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1xwHZg6eMZ9Wv5TE1UGub6ARubyOd1Lo5kPUI/6VL50= @@ -198,8 +202,8 @@ github.com/mark3labs/mcp-go v0.41.0 h1:IFfJaovCet65F3av00bE1HzSnmHpMRWM1kz96R98I github.com/mark3labs/mcp-go v0.41.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= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= +github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= diff --git a/internal/app/app.go b/internal/app/app.go index 2b3d81fb58acdeb2570a765c0a25ec53b65121da..29631c1be84e96617adfeb705b2e35e0b68725e5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -17,12 +17,12 @@ import ( "github.com/charmbracelet/crush/internal/history" "github.com/charmbracelet/crush/internal/llm/agent" "github.com/charmbracelet/crush/internal/log" - "github.com/charmbracelet/crush/internal/pubsub" - "github.com/charmbracelet/crush/internal/lsp" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/permission" + "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/x/ansi" ) type App struct { @@ -107,7 +107,10 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool ctx, cancel := context.WithCancel(ctx) defer cancel() - // Start spinner if not in quiet mode. + // Start progress bar and spinner + fmt.Printf(ansi.SetIndeterminateProgressBar) + defer fmt.Printf(ansi.ResetProgressBar) + var spinner *format.Spinner if !quiet { spinner = format.NewSpinner(ctx, cancel, "Generating") diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 2c935810b833af01c582866ec38d5f7b277bc203..74d82e15514c70ee96b507a01b8f611d3ade6a4d 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -3,6 +3,7 @@ package tui import ( "context" "fmt" + "math/rand" "strings" "time" @@ -601,6 +602,12 @@ func (a *appModel) View() tea.View { view.Layer = canvas view.Cursor = cursor + view.ProgressBar = tea.NewProgressBar(tea.ProgressBarNone, 0) + if a.app.CoderAgent.IsBusy() { + // use a random percentage to prevent the ghostty from hiding it after + // a timeout. + view.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100)) + } return view } From 75a602ae3bdb8d3d22cb95f294f48348e0d4a267 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 1 Oct 2025 13:42:06 -0400 Subject: [PATCH 30/94] chore: print a bug reporting notice when crush crashes All the credit in this revision goes to @andreynering. --- internal/cmd/root.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 0a2be1cbe6e480fe5719640711bd4caffb430229..c1adfc8016033673610e0b37970ec75a9aea778a 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -3,6 +3,7 @@ package cmd import ( "bytes" "context" + "errors" "fmt" "io" "log/slog" @@ -93,7 +94,7 @@ crush -y if _, err := program.Run(); err != nil { event.Error(err) slog.Error("TUI run error", "error", err) - return fmt.Errorf("TUI error: %v", err) + return errors.New("Crush crashed. If metrics are enabled, we were notified about it. If you'd like to report it, please copy the stacktrace above and open an issue at https://github.com/charmbracelet/crush/issues/new?template=bug.yml") } return nil }, From fccc49f4cb9ba90027b6eda64ae7257b633def66 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 1 Oct 2025 14:17:07 -0400 Subject: [PATCH 31/94] chore(lint): ignore staticcheck in helpful crash error Co-authored-by: Andrey Nering --- internal/cmd/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index c1adfc8016033673610e0b37970ec75a9aea778a..d6a26d818643a05704f554223a7b7960792970c5 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -94,7 +94,7 @@ crush -y if _, err := program.Run(); err != nil { event.Error(err) slog.Error("TUI run error", "error", err) - return errors.New("Crush crashed. If metrics are enabled, we were notified about it. If you'd like to report it, please copy the stacktrace above and open an issue at https://github.com/charmbracelet/crush/issues/new?template=bug.yml") + return errors.New("Crush crashed. If metrics are enabled, we were notified about it. If you'd like to report it, please copy the stacktrace above and open an issue at https://github.com/charmbracelet/crush/issues/new?template=bug.yml") //nolint:staticcheck } return nil }, From 8c1e3764c76b47845e066ca7a4fddd4820b2b551 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 1 Oct 2025 12:39:42 -0400 Subject: [PATCH 32/94] chore(task): add helper for fetching tags --- Taskfile.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Taskfile.yaml b/Taskfile.yaml index 0739cab998f34c7b4129b0765b9b225a2455f8ae..8f714b0e5afba0dec28f85627be47503dc59c0fb 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -102,3 +102,8 @@ tasks: - git tag --annotate --sign {{.NEXT}} {{.CLI_ARGS}} - echo "Pushing {{.NEXT}}..." - git push origin --tags + + fetch-tags: + cmds: + - git tag -d nightly || true + - git fetch --tags From 08e384dae68fb364c31ceb6fb49b288bdf508c95 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 1 Oct 2025 21:22:17 -0400 Subject: [PATCH 33/94] chore(task): fetch tags before releasing --- Taskfile.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index 8f714b0e5afba0dec28f85627be47503dc59c0fb..a990205a63497fbb020c78298fb826890ee1dcda 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -90,15 +90,15 @@ tasks: vars: NEXT: sh: go run github.com/caarlos0/svu/v3@latest next --always - prompt: "This will release {{.NEXT}}. Please make sure you've fetch tags. Continue?" + prompt: "This will release {{.NEXT}}. Continue?" preconditions: - sh: '[ $(git symbolic-ref --short HEAD) = "main" ]' msg: Not on main branch - sh: "[ $(git status --porcelain=2 | wc -l) = 0 ]" msg: "Git is dirty" cmds: + - task: fetch-tags - git commit --allow-empty -m "{{.NEXT}}" - - git tag -d nightly - git tag --annotate --sign {{.NEXT}} {{.CLI_ARGS}} - echo "Pushing {{.NEXT}}..." - git push origin --tags From 255aa3bcaaa79a270b48da607b2adab6a7e89bbd Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 1 Oct 2025 21:23:33 -0400 Subject: [PATCH 34/94] chore(task): just use svu if it's already installed --- Taskfile.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index a990205a63497fbb020c78298fb826890ee1dcda..13c171ed2e67faa9aa87c6f9f7d0ec3b7018f382 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -89,7 +89,7 @@ tasks: desc: Create and push a new tag following semver vars: NEXT: - sh: go run github.com/caarlos0/svu/v3@latest next --always + sh: svu next --always || go run github.com/caarlos0/svu/v3@latest next --always prompt: "This will release {{.NEXT}}. Continue?" preconditions: - sh: '[ $(git symbolic-ref --short HEAD) = "main" ]' From 1afeeb78f711b4210ac64cac79ce5fdd0054a82e Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Sun, 5 Oct 2025 15:14:06 -0300 Subject: [PATCH 35/94] chore(legal): @kucukkanat 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 4ecfd86887d5d072491a8fa764628e8935e4ebfe..be16926ef1dc52cf523997eead83ed5ffe19eb95 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -679,6 +679,14 @@ "created_at": "2025-09-29T01:19:40Z", "repoId": 987670088, "pullRequestNo": 1148 + }, + { + "name": "kucukkanat", + "id": 914316, + "comment_id": 3369230313, + "created_at": "2025-10-05T18:13:57Z", + "repoId": 987670088, + "pullRequestNo": 1195 } ] } \ No newline at end of file From a2b69450c0215676121a50755c059ded4e31ab82 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Sun, 5 Oct 2025 16:17:23 -0300 Subject: [PATCH 36/94] chore(legal): @thuggys 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 be16926ef1dc52cf523997eead83ed5ffe19eb95..665f0d0e5c3af5ab0492a898cd1303535616273d 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -687,6 +687,14 @@ "created_at": "2025-10-05T18:13:57Z", "repoId": 987670088, "pullRequestNo": 1195 + }, + { + "name": "thuggys", + "id": 150315417, + "comment_id": 3369149503, + "created_at": "2025-10-05T15:59:55Z", + "repoId": 987670088, + "pullRequestNo": 1194 } ] } \ No newline at end of file From f1122df71fab7d11f369a936ef636abd40a7dc48 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Oct 2025 09:58:05 +0000 Subject: [PATCH 37/94] chore(deps): bump the all group with 7 updates (#1198) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 20 ++++++++++---------- go.sum | 40 ++++++++++++++++++---------------------- 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/go.mod b/go.mod index 4beabb5b74efb565432aa36329698169e8aaae8d..170788928c44d7e233da6c25871927f3a8bf2073 100644 --- a/go.mod +++ b/go.mod @@ -7,15 +7,15 @@ require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/PuerkitoBio/goquery v1.10.3 github.com/alecthomas/chroma/v2 v2.20.0 - github.com/anthropics/anthropic-sdk-go v1.12.0 + github.com/anthropics/anthropic-sdk-go v1.13.0 github.com/atotto/clipboard v0.1.4 github.com/aymanbagabas/go-udiff v0.3.1 github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/charlievieth/fastwalk v1.0.14 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250930175933-4cafc092c5e7 - github.com/charmbracelet/catwalk v0.6.1 - github.com/charmbracelet/fang v0.4.2 + github.com/charmbracelet/catwalk v0.6.3 + github.com/charmbracelet/fang v0.4.3 github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 @@ -26,13 +26,13 @@ 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.41.0 + github.com/mark3labs/mcp-go v0.41.1 github.com/muesli/termenv v0.16.0 - github.com/ncruces/go-sqlite3 v0.29.0 + github.com/ncruces/go-sqlite3 v0.29.1 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/nxadm/tail v1.4.11 github.com/openai/openai-go v1.12.0 - github.com/pressly/goose/v3 v3.25.0 + github.com/pressly/goose/v3 v3.26.0 github.com/qjebbs/go-jsons v1.0.0-alpha.4 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/sahilm/fuzzy v0.1.1 @@ -72,7 +72,7 @@ require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect - github.com/charmbracelet/colorprofile v0.3.2 // indirect + github.com/charmbracelet/colorprofile v0.3.2 github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d @@ -141,18 +141,18 @@ require ( go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.41.0 // indirect + golang.org/x/crypto v0.42.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/image v0.26.0 // indirect golang.org/x/net v0.43.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.36.0 // indirect - golang.org/x/term v0.34.0 // indirect + golang.org/x/term v0.35.0 // indirect golang.org/x/text v0.29.0 golang.org/x/time v0.8.0 // indirect google.golang.org/api v0.211.0 // indirect - google.golang.org/genai v1.26.0 + google.golang.org/genai v1.28.0 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect google.golang.org/grpc v1.71.0 // indirect google.golang.org/protobuf v1.36.8 // indirect diff --git a/go.sum b/go.sum index 55872255a6bf56f4ef8093dc277fc17c6df9092a..3669305d22b191791df373899305e5e18a4e1f71 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,8 @@ github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW5 github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= -github.com/anthropics/anthropic-sdk-go v1.12.0 h1:xPqlGnq7rWrTiHazIvCiumA0u7mGQnwDQtvA1M82h9U= -github.com/anthropics/anthropic-sdk-go v1.12.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +github.com/anthropics/anthropic-sdk-go v1.13.0 h1:Bhbe8sRoDPtipttg8bQYrMCKe2b79+q6rFW1vOKEUKI= +github.com/anthropics/anthropic-sdk-go v1.13.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY= @@ -78,16 +78,14 @@ github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICg github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 h1:973OHYuq2Jx9deyuPwe/6lsuQrDCatOsjP8uCd02URE= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= -github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250910155747-997384b0b35e h1:4BBnKWFwJ5FLyhw/ijFxKE04i9rubr8WIPR1kjO57iA= -github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250910155747-997384b0b35e/go.mod h1:F7AfLKYQqpM3NNBVs7ctW417tavhvoh9SBjsgtwpzbY= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250930175933-4cafc092c5e7 h1:wH4F+UvxcZSDOxy8j45tghiRo8amrYHejbE9+1C6xv0= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250930175933-4cafc092c5e7/go.mod h1:5IzIGXU1n0foRc8bRAherC8ZuQCQURPlwx3ANLq1138= -github.com/charmbracelet/catwalk v0.6.1 h1:2rRqUlwo+fdyIty8jEvUufRTgqBl0aea21LV6YQPqb0= -github.com/charmbracelet/catwalk v0.6.1/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY= +github.com/charmbracelet/catwalk v0.6.3 h1:RyL8Yqd4QsV3VyvBEsePScv1z2vKaZxPfQQ0XB5L5AA= +github.com/charmbracelet/catwalk v0.6.3/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY= github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= -github.com/charmbracelet/fang v0.4.2 h1:nWr7Tb82/TTNNGMGG35aTZ1X68loAOQmpb0qxkKXjas= -github.com/charmbracelet/fang v0.4.2/go.mod h1:wHJKQYO5ReYsxx+yZl+skDtrlKO/4LLEQ6EXsdHhRhg= +github.com/charmbracelet/fang v0.4.3 h1:qXeMxnL4H6mSKBUhDefHu8NfikFbP/MBNTfqTrXvzmY= +github.com/charmbracelet/fang v0.4.3/go.mod h1:wHJKQYO5ReYsxx+yZl+skDtrlKO/4LLEQ6EXsdHhRhg= github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018 h1:PU4Zvpagsk5sgaDxn5W4sxHuLp9QRMBZB3bFSk40A4w= github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018/go.mod h1:Z/GLmp9fzaqX4ze3nXG7StgWez5uBM5XtlLHK8V/qSk= github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea h1:g1HfUgSMvye8mgecMD1mPscpt+pzJoDEiSA+p2QXzdQ= @@ -96,8 +94,6 @@ github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 h1:WkwO6Ks3mS github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706/go.mod h1:mjJGp00cxcfvD5xdCa+bso251Jt4owrQvuimJtVmEmM= github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef h1:VrWaUi2LXYLjfjCHowdSOEc6dQ9Ro14KY7Bw4IWd19M= github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef/go.mod h1:AThRsQH1t+dfyOKIwXRoJBniYFQUkUpQq4paheHMc2o= -github.com/charmbracelet/x/ansi v0.10.2-0.20250929231137-76218bae042e h1:aN905mmYvQsT4xKUZTdM+lCcuxTrubM3DGVTPxk0ArM= -github.com/charmbracelet/x/ansi v0.10.2-0.20250929231137-76218bae042e/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw= github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a h1:zYSNtEJM9jwHbJts2k+Hroj+xQwsW1yxc4Wopdv7KaI= @@ -198,8 +194,8 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ github.com/lucasb-eyer/go-colorful v1.3.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.41.0 h1:IFfJaovCet65F3av00bE1HzSnmHpMRWM1kz96R98I70= -github.com/mark3labs/mcp-go v0.41.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA= +github.com/mark3labs/mcp-go v0.41.1/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.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= @@ -222,8 +218,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/ncruces/go-sqlite3 v0.29.0 h1:1tsLiagCoqZEfcHDeKsNSv5jvrY/Iu393pAnw2wLNJU= -github.com/ncruces/go-sqlite3 v0.29.0/go.mod h1:r1hSvYKPNJ+OlUA1O3r8o9LAawzPAlqeZiIdxTBBBJ0= +github.com/ncruces/go-sqlite3 v0.29.1 h1:NIi8AISWBToRHyoz01FXiTNvU147Tqdibgj2tFzJCqM= +github.com/ncruces/go-sqlite3 v0.29.1/go.mod h1:PpccBNNhvjwUOwDQEn2gXQPFPTWdlromj0+fSkd5KSg= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= @@ -243,8 +239,8 @@ 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/posthog/posthog-go v1.6.10 h1:OA6bkiUg89rI7f5cSXbcrH5+wLinyS6hHplnD92Pu/M= github.com/posthog/posthog-go v1.6.10/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY= -github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng= -github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= +github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= +github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= github.com/qjebbs/go-jsons v1.0.0-alpha.4 h1:Qsb4ohRUHQODIUAsJKdKJ/SIDbsO7oGOzsfy+h1yQZs= github.com/qjebbs/go-jsons v1.0.0-alpha.4/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= @@ -342,8 +338,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY= @@ -405,8 +401,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -429,8 +425,8 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.211.0 h1:IUpLjq09jxBSV1lACO33CGY3jsRcbctfGzhj+ZSE/Bg= google.golang.org/api v0.211.0/go.mod h1:XOloB4MXFH4UTlQSGuNUxw0UT74qdENK8d6JNsXKLi0= -google.golang.org/genai v1.26.0 h1:r4HGL54kFv/WCRMTAbZg05Ct+vXfhAbTRlXhFyBkEQo= -google.golang.org/genai v1.26.0/go.mod h1:OClfdf+r5aaD+sCd4aUSkPzJItmg2wD/WON9lQnRPaY= +google.golang.org/genai v1.28.0 h1:6qpUWFH3PkHPhxNnu3wjaCVJ6Jri1EIR7ks07f9IpIk= +google.golang.org/genai v1.28.0/go.mod h1:7pAilaICJlQBonjKKJNhftDFv3SREhZcTe9F6nRcjbg= google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g= google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= From 2d05fe8164076765b1bb5d62b6f89fe585ef846c Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Mon, 6 Oct 2025 16:32:04 -0300 Subject: [PATCH 38/94] chore(legal): @nikaro 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 665f0d0e5c3af5ab0492a898cd1303535616273d..dc7d5873d3110320f09b8457b696a47d307ef41a 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -695,6 +695,14 @@ "created_at": "2025-10-05T15:59:55Z", "repoId": 987670088, "pullRequestNo": 1194 + }, + { + "name": "nikaro", + "id": 3918653, + "comment_id": 3373586148, + "created_at": "2025-10-06T19:31:50Z", + "repoId": 987670088, + "pullRequestNo": 1200 } ] } \ No newline at end of file From 886bb7c7101ec637104d17b34dce8573e1daa5ee Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 9 Oct 2025 19:32:10 +0200 Subject: [PATCH 39/94] fix(mcp): fix ui description, double spaces (#1210) Signed-off-by: Carlos Alexandro Becker --- internal/tui/components/core/core.go | 7 +++++-- .../core/testdata/TestStatus/EmptyDescription.golden | 2 +- internal/tui/components/mcp/mcp.go | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/internal/tui/components/core/core.go b/internal/tui/components/core/core.go index 18de56b17f08e4513bde34fe9fef7aaf4e08c09f..80c28ba1e11c4ddeb7e6da1f4802577d23e8b4dc 100644 --- a/internal/tui/components/core/core.go +++ b/internal/tui/components/core/core.go @@ -110,14 +110,17 @@ func Status(opts StatusOpts, width int) string { extraContentWidth += 1 } description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContentWidth, "…") + description = t.S().Base.Foreground(descriptionColor).Render(description) } - description = t.S().Base.Foreground(descriptionColor).Render(description) content := []string{} if icon != "" { content = append(content, icon) } - content = append(content, title, description) + content = append(content, title) + if description != "" { + content = append(content, description) + } if opts.ExtraContent != "" { content = append(content, opts.ExtraContent) } diff --git a/internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden b/internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden index 5b396377658610dd0fbc0746fd960f2faaf76f49..db4acad54383ecbc2cc50061ee5ba77491dc545d 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/mcp/mcp.go b/internal/tui/components/mcp/mcp.go index d11826b77749ba65276b5336a5d88cdbc8552881..fd3bd012732397538cc263b2eff92ae617e866d8 100644 --- a/internal/tui/components/mcp/mcp.go +++ b/internal/tui/components/mcp/mcp.go @@ -55,7 +55,7 @@ func RenderMCPList(opts RenderOptions) []string { // Determine icon and color based on state icon := t.ItemOfflineIcon - description := l.MCP.Command + description := "" extraContent := "" if state, exists := mcpStates[l.Name]; exists { From 390983a851b54d39b5812bd5aaaab2101703ca14 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 9 Oct 2025 14:46:11 -0300 Subject: [PATCH 40/94] chore: allow to pass args to task run Signed-off-by: Carlos Alexandro Becker --- Taskfile.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index 13c171ed2e67faa9aa87c6f9f7d0ec3b7018f382..1c4225158fc21508e8dccac8d6f47610f7d81faf 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -38,7 +38,7 @@ tasks: run: desc: Run build cmds: - - go run . + - go run . {{.CLI_ARGS}} test: desc: Run tests @@ -104,6 +104,6 @@ tasks: - git push origin --tags fetch-tags: - cmds: + cmds: - git tag -d nightly || true - git fetch --tags From 04210801f02d2ee5db7ae89a7ec28e34d5d14d5b Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 9 Oct 2025 23:16:55 +0200 Subject: [PATCH 41/94] fix(lsp): small UI improvements (#1211) Signed-off-by: Carlos Alexandro Becker --- internal/tui/components/lsp/lsp.go | 51 +++++++++++++++--------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/internal/tui/components/lsp/lsp.go b/internal/tui/components/lsp/lsp.go index f5f4061045901c91ecb8bce1f47eab3ac1f7abcf..0c0384e91c36744b8f318f9bbc71e5e076a26abf 100644 --- a/internal/tui/components/lsp/lsp.go +++ b/internal/tui/components/lsp/lsp.go @@ -56,32 +56,7 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption break } - // Determine icon color and description based on state - icon := t.ItemOfflineIcon - description := l.LSP.Command - - if l.LSP.Disabled { - description = t.S().Subtle.Render("disabled") - } else if state, exists := lspStates[l.Name]; exists { - switch state.State { - case lsp.StateStarting: - icon = t.ItemBusyIcon - description = t.S().Subtle.Render("starting...") - case lsp.StateReady: - icon = t.ItemOnlineIcon - description = l.LSP.Command - case lsp.StateError: - icon = t.ItemErrorIcon - if state.Error != nil { - description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error())) - } else { - description = t.S().Subtle.Render("error") - } - case lsp.StateDisabled: - icon = t.ItemOfflineIcon.Foreground(t.FgMuted) - description = t.S().Base.Foreground(t.FgMuted).Render("no root markers found") - } - } + icon, description := iconAndDescription(l, t, lspStates) // Calculate diagnostic counts if we have LSP clients var extraContent string @@ -134,6 +109,30 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption return lspList } +func iconAndDescription(l config.LSP, t *styles.Theme, states map[string]app.LSPClientInfo) (lipgloss.Style, string) { + if l.LSP.Disabled { + return t.ItemOfflineIcon.Foreground(t.FgMuted), t.S().Subtle.Render("disabled") + } + + info := states[l.Name] + switch info.State { + case lsp.StateStarting: + return t.ItemBusyIcon, t.S().Subtle.Render("starting...") + case lsp.StateReady: + return t.ItemOnlineIcon, "" + case lsp.StateError: + description := t.S().Subtle.Render("error") + if info.Error != nil { + description = t.S().Subtle.Render(fmt.Sprintf("error: %s", info.Error.Error())) + } + return t.ItemErrorIcon, description + case lsp.StateDisabled: + return t.ItemOfflineIcon.Foreground(t.FgMuted), t.S().Subtle.Render("inactive") + default: + return t.ItemOfflineIcon, "" + } +} + // RenderLSPBlock renders a complete LSP block with optional truncation indicator. func RenderLSPBlock(lspClients *csync.Map[string, *lsp.Client], opts RenderOptions, showTruncationIndicator bool) string { t := styles.CurrentTheme() From a430043677c5fdf019ec0a7cc19bfc26d401b6a4 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 9 Oct 2025 23:51:01 -0300 Subject: [PATCH 42/94] fix: move some logs to debug Signed-off-by: Carlos Alexandro Becker --- internal/fsext/ls.go | 2 +- internal/llm/agent/agent.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/fsext/ls.go b/internal/fsext/ls.go index 80d25a57f19867a4ca2af44df7e691bb9d109496..c22b960ad02a42bf6adac7768b7d99e55a9390ee 100644 --- a/internal/fsext/ls.go +++ b/internal/fsext/ls.go @@ -210,7 +210,7 @@ func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int found := csync.NewSlice[string]() dl := NewDirectoryLister(initialPath) - slog.Warn("listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns) + slog.Debug("listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns) conf := fastwalk.Config{ Follow: true, diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index 1efc3fc268392c06481d61ae6e11c9d67cdc13e8..32032280908fc0045125f31db2d6112eddf6a7c9 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -175,9 +175,9 @@ func NewAgent( } baseToolsFn := func() map[string]tools.BaseTool { - slog.Info("Initializing agent base tools", "agent", agentCfg.ID) + slog.Debug("Initializing agent base tools", "agent", agentCfg.ID) defer func() { - slog.Info("Initialized agent base tools", "agent", agentCfg.ID) + slog.Debug("Initialized agent base tools", "agent", agentCfg.ID) }() // Base tools available to all agents @@ -201,9 +201,9 @@ func NewAgent( return result } mcpToolsFn := func() map[string]tools.BaseTool { - slog.Info("Initializing agent mcp tools", "agent", agentCfg.ID) + slog.Debug("Initializing agent mcp tools", "agent", agentCfg.ID) defer func() { - slog.Info("Initialized agent mcp tools", "agent", agentCfg.ID) + slog.Debug("Initialized agent mcp tools", "agent", agentCfg.ID) }() mcpToolsOnce.Do(func() { From ca66a11ab6293b7f598a83596f724f00e2960831 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 10 Oct 2025 10:07:49 -0300 Subject: [PATCH 43/94] refactor(mcp): use the new mcp library (#1208) Signed-off-by: Carlos Alexandro Becker --- go.mod | 4 +- go.sum | 16 +-- internal/config/config.go | 2 +- internal/llm/agent/agent.go | 1 + internal/llm/agent/mcp-tools.go | 193 +++++++++++++++----------------- 5 files changed, 100 insertions(+), 116 deletions(-) diff --git a/go.mod b/go.mod index 170788928c44d7e233da6c25871927f3a8bf2073..843e7f231f729e86d4e299349fa1293005ad3971 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,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.41.1 + github.com/modelcontextprotocol/go-sdk v1.0.0 github.com/muesli/termenv v0.16.0 github.com/ncruces/go-sqlite3 v0.29.1 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 @@ -91,6 +91,7 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/jsonschema-go v0.3.0 // indirect github.com/google/s2a-go v0.1.8 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect github.com/googleapis/gax-go/v2 v2.14.1 // indirect @@ -121,7 +122,6 @@ require ( github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect - github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/tetratelabs/wazero v1.9.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect diff --git a/go.sum b/go.sum index 3669305d22b191791df373899305e5e18a4e1f71..563016cca9ffcec4a7be40aeed80822a105d1769 100644 --- a/go.sum +++ b/go.sum @@ -130,8 +130,6 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= @@ -144,13 +142,15 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= +github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -194,8 +194,6 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ github.com/lucasb-eyer/go-colorful v1.3.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.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA= -github.com/mark3labs/mcp-go v0.41.1/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.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= @@ -206,6 +204,8 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modelcontextprotocol/go-sdk v1.0.0 h1:Z4MSjLi38bTgLrd/LjSmofqRqyBiVKRyQSJgw8q8V74= +github.com/modelcontextprotocol/go-sdk v1.0.0/go.mod h1:nYtYQroQ2KQiM0/SbyEPUWQ6xs4B95gJjEalc9AQyOs= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= @@ -265,8 +265,6 @@ github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/sourcegraph/jsonrpc2 v0.2.1 h1:2GtljixMQYUYCmIg7W9aF2dFmniq/mOr2T9tFRh6zSQ= github.com/sourcegraph/jsonrpc2 v0.2.1/go.mod h1:ZafdZgk/axhT1cvZAPOhw+95nz2I/Ra5qMlU4gTRwIo= -github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= @@ -422,6 +420,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.211.0 h1:IUpLjq09jxBSV1lACO33CGY3jsRcbctfGzhj+ZSE/Bg= google.golang.org/api v0.211.0/go.mod h1:XOloB4MXFH4UTlQSGuNUxw0UT74qdENK8d6JNsXKLi0= diff --git a/internal/config/config.go b/internal/config/config.go index 858fa1c47b33f6a5e6bafb81b4799ea5739736f9..b37b98cad717e789ad16237b3ca250a2f1555ba9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -99,7 +99,7 @@ type MCPType string const ( MCPStdio MCPType = "stdio" - MCPSse MCPType = "sse" + MCPSSE MCPType = "sse" MCPHttp MCPType = "http" ) diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index 32032280908fc0045125f31db2d6112eddf6a7c9..e338eef782912bdfea48ca72ebfd33c4cd981f62 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -1,3 +1,4 @@ +// Package agent contains the implementation of the AI agent service. package agent import ( diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 181f32b7280faf3eb36040d2ebecf3f892350f53..67f0b39ccfb6eb8aad3abd337e7545a59766d872 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -6,8 +6,11 @@ import ( "encoding/json" "errors" "fmt" + "io" "log/slog" "maps" + "net/http" + "os/exec" "strings" "sync" "time" @@ -19,9 +22,7 @@ import ( "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/version" - "github.com/mark3labs/mcp-go/client" - "github.com/mark3labs/mcp-go/client/transport" - "github.com/mark3labs/mcp-go/mcp" + "github.com/modelcontextprotocol/go-sdk/mcp" ) // MCPState represents the current state of an MCP client @@ -71,7 +72,7 @@ type MCPClientInfo struct { Name string State MCPState Error error - Client *client.Client + Client *mcp.ClientSession ToolCount int ConnectedAt time.Time } @@ -80,14 +81,14 @@ var ( mcpToolsOnce sync.Once mcpTools = csync.NewMap[string, tools.BaseTool]() mcpClient2Tools = csync.NewMap[string, []tools.BaseTool]() - mcpClients = csync.NewMap[string, *client.Client]() + mcpClients = csync.NewMap[string, *mcp.ClientSession]() mcpStates = csync.NewMap[string, MCPClientInfo]() mcpBroker = pubsub.NewBroker[MCPEvent]() ) type McpTool struct { mcpName string - tool mcp.Tool + tool *mcp.Tool permissions permission.Service workingDir string } @@ -97,14 +98,9 @@ func (b *McpTool) Name() string { } func (b *McpTool) Info() tools.ToolInfo { - required := b.tool.InputSchema.Required - if required == nil { - required = make([]string, 0) - } - parameters := b.tool.InputSchema.Properties - if parameters == nil { - parameters = make(map[string]any) - } + input := b.tool.InputSchema.(map[string]any) + required, _ := input["required"].([]string) + parameters, _ := input["properties"].(map[string]any) return tools.ToolInfo{ Name: fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name), Description: b.tool.Description, @@ -123,11 +119,9 @@ func runTool(ctx context.Context, name, toolName string, input string) (tools.To if err != nil { return tools.NewTextErrorResponse(err.Error()), nil } - result, err := c.CallTool(ctx, mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: toolName, - Arguments: args, - }, + result, err := c.CallTool(ctx, &mcp.CallToolParams{ + Name: toolName, + Arguments: args, }) if err != nil { return tools.NewTextErrorResponse(err.Error()), nil @@ -135,8 +129,8 @@ func runTool(ctx context.Context, name, toolName string, input string) (tools.To output := make([]string, 0, len(result.Content)) for _, v := range result.Content { - if v, ok := v.(mcp.TextContent); ok { - output = append(output, v.Text) + if vv, ok := v.(*mcp.TextContent); ok { + output = append(output, vv.Text) } else { output = append(output, fmt.Sprintf("%v", v)) } @@ -144,8 +138,8 @@ func runTool(ctx context.Context, name, toolName string, input string) (tools.To return tools.NewTextResponse(strings.Join(output, "\n")), nil } -func getOrRenewClient(ctx context.Context, name string) (*client.Client, error) { - c, ok := mcpClients.Get(name) +func getOrRenewClient(ctx context.Context, name string) (*mcp.ClientSession, error) { + sess, ok := mcpClients.Get(name) if !ok { return nil, fmt.Errorf("mcp '%s' not available", name) } @@ -157,20 +151,20 @@ func getOrRenewClient(ctx context.Context, name string) (*client.Client, error) timeout := mcpTimeout(m) pingCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - err := c.Ping(pingCtx) + err := sess.Ping(pingCtx, nil) if err == nil { - return c, nil + return sess, nil } updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, state.ToolCount) - c, err = createAndInitializeClient(ctx, name, m, cfg.Resolver()) + sess, err = createMCPSession(ctx, name, m, cfg.Resolver()) if err != nil { return nil, err } - updateMCPState(name, MCPStateConnected, nil, c, state.ToolCount) - mcpClients.Set(name, c) - return c, nil + updateMCPState(name, MCPStateConnected, nil, sess, state.ToolCount) + mcpClients.Set(name, sess) + return sess, nil } func (b *McpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolResponse, error) { @@ -197,8 +191,8 @@ func (b *McpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolRes return runTool(ctx, b.mcpName, b.tool.Name, params.Input) } -func getTools(ctx context.Context, name string, permissions permission.Service, c *client.Client, workingDir string) ([]tools.BaseTool, error) { - result, err := c.ListTools(ctx, mcp.ListToolsRequest{}) +func getTools(ctx context.Context, name string, permissions permission.Service, c *mcp.ClientSession, workingDir string) ([]tools.BaseTool, error) { + result, err := c.ListTools(ctx, &mcp.ListToolsParams{}) if err != nil { return nil, err } @@ -230,7 +224,7 @@ func GetMCPState(name string) (MCPClientInfo, bool) { } // 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) { +func updateMCPState(name string, state MCPState, err error, client *mcp.ClientSession, toolCount int) { info := MCPClientInfo{ Name: name, State: state, @@ -257,19 +251,14 @@ func updateMCPState(name string, state MCPState, err error, client *client.Clien }) } -// publishMCPEventToolsListChanged publishes a tool list changed event -func publishMCPEventToolsListChanged(name string) { - mcpBroker.Publish(pubsub.UpdatedEvent, MCPEvent{ - Type: MCPEventToolsListChanged, - Name: name, - }) -} - // CloseMCPClients closes all MCP clients. This should be called during application shutdown. func CloseMCPClients() error { var errs []error for name, c := range mcpClients.Seq2() { - if err := c.Close(); err != nil { + if err := c.Close(); err != nil && + !errors.Is(err, io.EOF) && + !errors.Is(err, context.Canceled) && + err.Error() != "signal: killed" { errs = append(errs, fmt.Errorf("close mcp: %s: %w", name, err)) } } @@ -277,16 +266,6 @@ func CloseMCPClients() error { return errors.Join(errs...) } -var mcpInitRequest = mcp.InitializeRequest{ - Params: mcp.InitializeParams{ - ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION, - ClientInfo: mcp.Implementation{ - Name: "Crush", - Version: version.Version, - }, - }, -} - func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) { var wg sync.WaitGroup // Initialize states for all configured MCPs @@ -322,7 +301,7 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con ctx, cancel := context.WithTimeout(ctx, mcpTimeout(m)) defer cancel() - c, err := createAndInitializeClient(ctx, name, m, cfg.Resolver()) + c, err := createMCPSession(ctx, name, m, cfg.Resolver()) if err != nil { return } @@ -359,49 +338,46 @@ func updateMcpTools(mcpName string, tools []tools.BaseTool) { } } -func createAndInitializeClient(ctx context.Context, name string, m config.MCPConfig, resolver config.VariableResolver) (*client.Client, error) { - c, err := createMcpClient(name, m, resolver) +func createMCPSession(ctx context.Context, name string, m config.MCPConfig, resolver config.VariableResolver) (*mcp.ClientSession, error) { + timeout := mcpTimeout(m) + mcpCtx, cancel := context.WithCancel(ctx) + cancelTimer := time.AfterFunc(timeout, cancel) + + transport, err := createMCPTransport(mcpCtx, m, resolver) if err != nil { updateMCPState(name, MCPStateError, err, nil, 0) slog.Error("error creating mcp client", "error", err, "name", name) return nil, err } - c.OnNotification(func(n mcp.JSONRPCNotification) { - slog.Debug("Received MCP notification", "name", name, "notification", n) - switch n.Method { - case "notifications/tools/list_changed": - publishMCPEventToolsListChanged(name) - default: - slog.Debug("Unhandled MCP notification", "name", name, "method", n.Method) - } - }) - - // XXX: ideally we should be able to use context.WithTimeout here, but, - // the SSE MCP client will start failing once that context is canceled. - timeout := mcpTimeout(m) - mcpCtx, cancel := context.WithCancel(ctx) - cancelTimer := time.AfterFunc(timeout, cancel) + client := mcp.NewClient( + &mcp.Implementation{ + Name: "crush", + Version: version.Version, + Title: "Crush", + }, + &mcp.ClientOptions{ + ToolListChangedHandler: func(context.Context, *mcp.ToolListChangedRequest) { + mcpBroker.Publish(pubsub.UpdatedEvent, MCPEvent{ + Type: MCPEventToolsListChanged, + Name: name, + }) + }, + KeepAlive: time.Minute * 10, + }, + ) - if err := c.Start(mcpCtx); err != nil { + session, err := client.Connect(mcpCtx, transport, nil) + if err != nil { updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0) slog.Error("error starting mcp client", "error", err, "name", name) - _ = c.Close() - cancel() - return nil, err - } - - if _, err := c.Initialize(mcpCtx, mcpInitRequest); err != nil { - updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0) - slog.Error("error initializing mcp client", "error", err, "name", name) - _ = c.Close() cancel() return nil, err } cancelTimer.Stop() slog.Info("Initialized mcp client", "name", name) - return c, nil + return session, nil } func maybeTimeoutErr(err error, timeout time.Duration) error { @@ -411,7 +387,7 @@ func maybeTimeoutErr(err error, timeout time.Duration) error { return err } -func createMcpClient(name string, m config.MCPConfig, resolver config.VariableResolver) (*client.Client, error) { +func createMCPTransport(ctx context.Context, m config.MCPConfig, resolver config.VariableResolver) (mcp.Transport, error) { switch m.Type { case config.MCPStdio: command, err := resolver.ResolveValue(m.Command) @@ -421,44 +397,51 @@ func createMcpClient(name string, m config.MCPConfig, resolver config.VariableRe if strings.TrimSpace(command) == "" { return nil, fmt.Errorf("mcp stdio config requires a non-empty 'command' field") } - return client.NewStdioMCPClientWithOptions( - home.Long(command), - m.ResolvedEnv(), - m.Args, - transport.WithCommandLogger(mcpLogger{name: name}), - ) + cmd := exec.CommandContext(ctx, home.Long(command), m.Args...) + cmd.Env = m.ResolvedEnv() + return &mcp.CommandTransport{ + Command: cmd, + }, nil case config.MCPHttp: if strings.TrimSpace(m.URL) == "" { return nil, fmt.Errorf("mcp http config requires a non-empty 'url' field") } - return client.NewStreamableHttpClient( - m.URL, - transport.WithHTTPHeaders(m.ResolvedHeaders()), - transport.WithHTTPLogger(mcpLogger{name: name}), - ) - case config.MCPSse: + client := &http.Client{ + Transport: &headerRoundTripper{ + headers: m.ResolvedHeaders(), + }, + } + return &mcp.StreamableClientTransport{ + Endpoint: m.URL, + HTTPClient: client, + }, nil + case config.MCPSSE: if strings.TrimSpace(m.URL) == "" { return nil, fmt.Errorf("mcp sse config requires a non-empty 'url' field") } - return client.NewSSEMCPClient( - m.URL, - client.WithHeaders(m.ResolvedHeaders()), - transport.WithSSELogger(mcpLogger{name: name}), - ) + client := &http.Client{ + Transport: &headerRoundTripper{ + headers: m.ResolvedHeaders(), + }, + } + return &mcp.SSEClientTransport{ + Endpoint: m.URL, + HTTPClient: client, + }, nil default: return nil, fmt.Errorf("unsupported mcp type: %s", m.Type) } } -// for MCP's clients. -type mcpLogger struct{ name string } - -func (l mcpLogger) Errorf(format string, v ...any) { - slog.Error(fmt.Sprintf(format, v...), "name", l.name) +type headerRoundTripper struct { + headers map[string]string } -func (l mcpLogger) Infof(format string, v ...any) { - slog.Info(fmt.Sprintf(format, v...), "name", l.name) +func (rt headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + for k, v := range rt.headers { + req.Header.Set(k, v) + } + return http.DefaultTransport.RoundTrip(req) } func mcpTimeout(m config.MCPConfig) time.Duration { From 7ac96ef0686106b53324a6901fac10a9f00e8a4f Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 10 Oct 2025 11:45:12 -0300 Subject: [PATCH 44/94] fix(vertex): small fix for anthropic models via google vertex (#1214) --- internal/llm/provider/vertexai.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/llm/provider/vertexai.go b/internal/llm/provider/vertexai.go index 871ff092b058af70833ba615260efcdbc09f2514..e7591af70c24a528d48895c11e653f023ba86c89 100644 --- a/internal/llm/provider/vertexai.go +++ b/internal/llm/provider/vertexai.go @@ -30,7 +30,7 @@ func newVertexAIClient(opts providerClientOptions) VertexAIClient { } model := opts.model(opts.modelType) - if strings.Contains(model.ID, "anthropic") || strings.Contains(model.ID, "claude-sonnet") { + if strings.Contains(model.ID, "anthropic") || strings.Contains(model.ID, "claude") || strings.Contains(model.ID, "sonnet") { return newAnthropicClient(opts, AnthropicClientTypeVertex) } return &geminiClient{ From 4969c34d18747159dcdb2f6c8543afe3ef2c2e0d Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Sat, 11 Oct 2025 18:07:50 -0300 Subject: [PATCH 45/94] fix(tui): panic (#1220) Signed-off-by: Carlos Alexandro Becker --- internal/tui/tui.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 74d82e15514c70ee96b507a01b8f611d3ade6a4d..fe3875d256b3b7c7e3d2fdafecca57b20c3d2fbb 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -603,9 +603,9 @@ func (a *appModel) View() tea.View { view.Layer = canvas view.Cursor = cursor view.ProgressBar = tea.NewProgressBar(tea.ProgressBarNone, 0) - if a.app.CoderAgent.IsBusy() { - // use a random percentage to prevent the ghostty from hiding it after - // a timeout. + if a.app != nil && a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() { + // HACK: use a random percentage to prevent ghostty from hiding it + // after a timeout. view.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100)) } return view From a824240d128010ef52f870356dea6ba7c5b5f00e Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Sat, 11 Oct 2025 18:11:59 -0300 Subject: [PATCH 46/94] fix(tui): fix progress not cleaning up some times (#1219) Signed-off-by: Carlos Alexandro Becker --- go.mod | 2 +- go.sum | 4 ++-- internal/app/app.go | 8 ++++---- internal/tui/tui.go | 1 - 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index 843e7f231f729e86d4e299349fa1293005ad3971..22daf1ce6d08d57d64174fcc705c9f3026eb48e4 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/charlievieth/fastwalk v1.0.14 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 - github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250930175933-4cafc092c5e7 + github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619 github.com/charmbracelet/catwalk v0.6.3 github.com/charmbracelet/fang v0.4.3 github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018 diff --git a/go.sum b/go.sum index 563016cca9ffcec4a7be40aeed80822a105d1769..30ee83eba84c07b1135a8983fdfd04e162258c64 100644 --- a/go.sum +++ b/go.sum @@ -78,8 +78,8 @@ github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICg github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 h1:973OHYuq2Jx9deyuPwe/6lsuQrDCatOsjP8uCd02URE= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= -github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250930175933-4cafc092c5e7 h1:wH4F+UvxcZSDOxy8j45tghiRo8amrYHejbE9+1C6xv0= -github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250930175933-4cafc092c5e7/go.mod h1:5IzIGXU1n0foRc8bRAherC8ZuQCQURPlwx3ANLq1138= +github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619 h1:hjOhtqsxa+LVuCAkzhfA43wtusOaUPyQdSTg/wbRscw= +github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619/go.mod h1:5IzIGXU1n0foRc8bRAherC8ZuQCQURPlwx3ANLq1138= github.com/charmbracelet/catwalk v0.6.3 h1:RyL8Yqd4QsV3VyvBEsePScv1z2vKaZxPfQQ0XB5L5AA= github.com/charmbracelet/catwalk v0.6.3/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY= github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= diff --git a/internal/app/app.go b/internal/app/app.go index 29631c1be84e96617adfeb705b2e35e0b68725e5..8f305f765f3391e1a6afce294e3c42525ec65668 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -107,10 +107,6 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool ctx, cancel := context.WithCancel(ctx) defer cancel() - // Start progress bar and spinner - fmt.Printf(ansi.SetIndeterminateProgressBar) - defer fmt.Printf(ansi.ResetProgressBar) - var spinner *format.Spinner if !quiet { spinner = format.NewSpinner(ctx, cancel, "Generating") @@ -154,7 +150,11 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool messageEvents := app.Messages.Subscribe(ctx) messageReadBytes := make(map[string]int) + defer fmt.Printf(ansi.ResetProgressBar) for { + // HACK: add it again on every iteration so it doesn't get hidden by + // the terminal due to inactivity. + fmt.Printf(ansi.SetIndeterminateProgressBar) select { case result := <-done: stopSpinner() diff --git a/internal/tui/tui.go b/internal/tui/tui.go index fe3875d256b3b7c7e3d2fdafecca57b20c3d2fbb..26d23f46ee62aafe07d1bb6209a4fedea929c6e1 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -602,7 +602,6 @@ func (a *appModel) View() tea.View { view.Layer = canvas view.Cursor = cursor - view.ProgressBar = tea.NewProgressBar(tea.ProgressBarNone, 0) if a.app != nil && a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() { // HACK: use a random percentage to prevent ghostty from hiding it // after a timeout. From e9be0dfce158697df19ca28a28e766c8af9b8329 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Mon, 13 Oct 2025 02:56:30 -0300 Subject: [PATCH 47/94] chore(legal): @daps94 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 dc7d5873d3110320f09b8457b696a47d307ef41a..61a8b03447ae07a5dc775ca59a5eef7aacfe9c2b 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -703,6 +703,14 @@ "created_at": "2025-10-06T19:31:50Z", "repoId": 987670088, "pullRequestNo": 1200 + }, + { + "name": "daps94", + "id": 35882689, + "comment_id": 3395964275, + "created_at": "2025-10-13T05:56:20Z", + "repoId": 987670088, + "pullRequestNo": 1223 } ] } \ No newline at end of file From 8814881209fa099d2a8a373d077d0233637cd619 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 10:18:21 +0000 Subject: [PATCH 48/94] chore(deps): bump the all group with 4 updates (#1225) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 8 ++++---- go.sum | 20 ++++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/go.mod b/go.mod index 22daf1ce6d08d57d64174fcc705c9f3026eb48e4..5f32e148b92ac8e6c456157465061c759d267dd9 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/charlievieth/fastwalk v1.0.14 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619 - github.com/charmbracelet/catwalk v0.6.3 + github.com/charmbracelet/catwalk v0.6.4 github.com/charmbracelet/fang v0.4.3 github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea @@ -117,7 +117,7 @@ require ( github.com/ncruces/julianday v1.0.0 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/posthog/posthog-go v1.6.10 + github.com/posthog/posthog-go v1.6.11 github.com/rivo/uniseg v0.4.7 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect @@ -149,10 +149,10 @@ require ( golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.36.0 // indirect golang.org/x/term v0.35.0 // indirect - golang.org/x/text v0.29.0 + golang.org/x/text v0.30.0 golang.org/x/time v0.8.0 // indirect google.golang.org/api v0.211.0 // indirect - google.golang.org/genai v1.28.0 + google.golang.org/genai v1.30.0 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect google.golang.org/grpc v1.71.0 // indirect google.golang.org/protobuf v1.36.8 // indirect diff --git a/go.sum b/go.sum index 30ee83eba84c07b1135a8983fdfd04e162258c64..2d53e85a40001ea9241e4c7ee728baa734a889d9 100644 --- a/go.sum +++ b/go.sum @@ -80,8 +80,8 @@ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619 h1:hjOhtqsxa+LVuCAkzhfA43wtusOaUPyQdSTg/wbRscw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619/go.mod h1:5IzIGXU1n0foRc8bRAherC8ZuQCQURPlwx3ANLq1138= -github.com/charmbracelet/catwalk v0.6.3 h1:RyL8Yqd4QsV3VyvBEsePScv1z2vKaZxPfQQ0XB5L5AA= -github.com/charmbracelet/catwalk v0.6.3/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY= +github.com/charmbracelet/catwalk v0.6.4 h1:zFHtuP94mSDE48nST3DS3a37wfsQqNcVnsFkS3v6N6E= +github.com/charmbracelet/catwalk v0.6.4/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY= github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= github.com/charmbracelet/fang v0.4.3 h1:qXeMxnL4H6mSKBUhDefHu8NfikFbP/MBNTfqTrXvzmY= @@ -237,8 +237,8 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjL github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posthog/posthog-go v1.6.10 h1:OA6bkiUg89rI7f5cSXbcrH5+wLinyS6hHplnD92Pu/M= -github.com/posthog/posthog-go v1.6.10/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY= +github.com/posthog/posthog-go v1.6.11 h1:5G8Y3pxnOpc3S4+PK1z1dCmZRuldiWxBsqqvvSfC2+w= +github.com/posthog/posthog-go v1.6.11/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY= github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= github.com/qjebbs/go-jsons v1.0.0-alpha.4 h1:Qsb4ohRUHQODIUAsJKdKJ/SIDbsO7oGOzsfy+h1yQZs= @@ -410,8 +410,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 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.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= 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= @@ -420,13 +420,13 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.211.0 h1:IUpLjq09jxBSV1lACO33CGY3jsRcbctfGzhj+ZSE/Bg= google.golang.org/api v0.211.0/go.mod h1:XOloB4MXFH4UTlQSGuNUxw0UT74qdENK8d6JNsXKLi0= -google.golang.org/genai v1.28.0 h1:6qpUWFH3PkHPhxNnu3wjaCVJ6Jri1EIR7ks07f9IpIk= -google.golang.org/genai v1.28.0/go.mod h1:7pAilaICJlQBonjKKJNhftDFv3SREhZcTe9F6nRcjbg= +google.golang.org/genai v1.30.0 h1:7021aneIvl24nEBLbtQFEWleHsMbjzpcQvkT4WcJ1dc= +google.golang.org/genai v1.30.0/go.mod h1:7pAilaICJlQBonjKKJNhftDFv3SREhZcTe9F6nRcjbg= google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g= google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= From e5f171ba8640c1eff33db28e42914f7803311cc1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 10:20:28 +0000 Subject: [PATCH 49/94] chore(deps): bump stefanzweifel/git-auto-commit-action from 6.0.1 to 7.0.0 in the all group (#1226) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/schema-update.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/schema-update.yml b/.github/workflows/schema-update.yml index bc1a69c68273c007a764c268958858be3b62bcd2..466c3a25fb3698a183ed84436d5dca9813b2dcb6 100644 --- a/.github/workflows/schema-update.yml +++ b/.github/workflows/schema-update.yml @@ -17,7 +17,7 @@ jobs: with: go-version-file: go.mod - run: go run . schema > ./schema.json - - uses: stefanzweifel/git-auto-commit-action@778341af668090896ca464160c2def5d1d1a3eb0 # v5 + - uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v5 with: commit_message: "chore: auto-update generated files" branch: main From 9ffa58723de56761baef45b595f0a9e48266aa6d Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 14 Oct 2025 16:15:51 -0300 Subject: [PATCH 51/94] fix(ls): properly handle limits (#1230) Signed-off-by: Carlos Alexandro Becker --- internal/config/config.go | 4 ++-- internal/llm/tools/ls.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index b37b98cad717e789ad16237b3ca250a2f1555ba9..ff948b874ea1613ca126053547dcf9b7d4cc3297 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -143,7 +143,7 @@ type Completions struct { } func (c Completions) Limits() (depth, items int) { - return ptrValOr(c.MaxDepth, -1), ptrValOr(c.MaxItems, -1) + return ptrValOr(c.MaxDepth, 0), ptrValOr(c.MaxItems, 0) } type Permissions struct { @@ -269,7 +269,7 @@ type ToolLs struct { } func (t ToolLs) Limits() (depth, items int) { - return ptrValOr(t.MaxDepth, -1), ptrValOr(t.MaxItems, -1) + return ptrValOr(t.MaxDepth, 0), ptrValOr(t.MaxItems, 0) } // Config holds the configuration for crush. diff --git a/internal/llm/tools/ls.go b/internal/llm/tools/ls.go index 305f7f10249594ff06ac008a8bf81145d7d834de..af25259dd8c69ff8d52d467e20532612681b51b1 100644 --- a/internal/llm/tools/ls.go +++ b/internal/llm/tools/ls.go @@ -157,7 +157,7 @@ func ListDirectoryTree(searchPath string, params LSParams) (string, LSResponseMe ls := config.Get().Tools.Ls depth, limit := ls.Limits() - maxFiles := min(limit, maxLSFiles) + maxFiles := cmp.Or(limit, maxLSFiles) files, truncated, err := fsext.ListDirectory( searchPath, params.Ignore, From 02cd9ab3861d611cf20870b645983ee9fe073874 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 14 Oct 2025 15:25:18 -0400 Subject: [PATCH 52/94] chore(task): also push empty named commit in release (#1231) Co-authored-by: Andrey Nering --- Taskfile.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index 1c4225158fc21508e8dccac8d6f47610f7d81faf..540cb24bff123664a42eb86afcea593513c4f25f 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -101,7 +101,7 @@ tasks: - git commit --allow-empty -m "{{.NEXT}}" - git tag --annotate --sign {{.NEXT}} {{.CLI_ARGS}} - echo "Pushing {{.NEXT}}..." - - git push origin --tags + - git push origin main --follow-tags fetch-tags: cmds: From 595c9401bb70818601dda9a483bc96402408d950 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 14 Oct 2025 16:28:55 -0300 Subject: [PATCH 53/94] chore(task): set commit desc automatically --- Taskfile.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index 540cb24bff123664a42eb86afcea593513c4f25f..92b162dfbb847356e09eb17ea5996e6093a305b2 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -99,7 +99,7 @@ tasks: cmds: - task: fetch-tags - git commit --allow-empty -m "{{.NEXT}}" - - git tag --annotate --sign {{.NEXT}} {{.CLI_ARGS}} + - git tag --annotate -m "{{.NEXT}}" {{.NEXT}} {{.CLI_ARGS}} - echo "Pushing {{.NEXT}}..." - git push origin main --follow-tags From 8c9ce8e765d0b0db1e6cf8065983db19d2997a03 Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 15 Oct 2025 02:26:30 -0600 Subject: [PATCH 55/94] feat: paste/close bindings in user cmd dialog (#1221) Co-authored-by: Crush --- .../tui/components/dialogs/commands/arguments.go | 9 ++++++++- internal/tui/components/dialogs/commands/keys.go | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/internal/tui/components/dialogs/commands/arguments.go b/internal/tui/components/dialogs/commands/arguments.go index 03110eeaf2b8fbb909f1f9e4fbd57344699732e3..b1a274319719b9f550179b35aa98fd8310e0bb7b 100644 --- a/internal/tui/components/dialogs/commands/arguments.go +++ b/internal/tui/components/dialogs/commands/arguments.go @@ -128,12 +128,19 @@ func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { c.inputs[c.focusIndex].Blur() c.focusIndex = (c.focusIndex - 1 + len(c.inputs)) % len(c.inputs) c.inputs[c.focusIndex].Focus() - + case key.Matches(msg, c.keys.Paste): + return c, textinput.Paste + case key.Matches(msg, c.keys.Close): + return c, util.CmdHandler(dialogs.CloseDialogMsg{}) default: var cmd tea.Cmd c.inputs[c.focusIndex], cmd = c.inputs[c.focusIndex].Update(msg) return c, cmd } + case tea.PasteMsg: + var cmd tea.Cmd + c.inputs[c.focusIndex], cmd = c.inputs[c.focusIndex].Update(msg) + return c, cmd } return c, nil } diff --git a/internal/tui/components/dialogs/commands/keys.go b/internal/tui/components/dialogs/commands/keys.go index 7b79a29c28a024154a3b4d8c763969585409fd00..b704f227fe6f183a430bd25d3af62f4ef50b3365 100644 --- a/internal/tui/components/dialogs/commands/keys.go +++ b/internal/tui/components/dialogs/commands/keys.go @@ -76,6 +76,8 @@ type ArgumentsDialogKeyMap struct { Confirm key.Binding Next key.Binding Previous key.Binding + Paste key.Binding + Close key.Binding } func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap { @@ -93,6 +95,14 @@ func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap { key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "previous"), ), + Paste: key.NewBinding( + key.WithKeys("ctrl+v"), + key.WithHelp("ctrl+v", "paste"), + ), + Close: key.NewBinding( + key.WithKeys("esc", "alt+esc"), + key.WithHelp("esc", "cancel"), + ), } } @@ -102,6 +112,8 @@ func (k ArgumentsDialogKeyMap) KeyBindings() []key.Binding { k.Confirm, k.Next, k.Previous, + k.Paste, + k.Close, } } @@ -122,5 +134,7 @@ func (k ArgumentsDialogKeyMap) ShortHelp() []key.Binding { k.Confirm, k.Next, k.Previous, + k.Paste, + k.Close, } } From 69be8c20e2152d3a0c053d005013286d8bfe57c6 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 15 Oct 2025 06:57:21 -0300 Subject: [PATCH 56/94] fix(bedrock): detect credentials set by `aws configure` (#1232) --- internal/config/load.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/config/load.go b/internal/config/load.go index 9fb45028d6936a652f2657f51707b6cde73f4084..c63a9663613bdfdea6a9c9ccef9f53d375e35c74 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -605,6 +605,11 @@ func hasAWSCredentials(env env.Env) bool { env.Get("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" { return true } + + if _, err := os.Stat(filepath.Join(home.Dir(), ".aws/credentials")); err == nil { + return true + } + return false } From 1a40fbabbd8cfce50824072aa3b52bafc9a56a1c Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 15 Oct 2025 09:18:43 -0300 Subject: [PATCH 57/94] fix(grep): check mime type (#1228) Signed-off-by: Carlos Alexandro Becker --- internal/llm/tools/grep.go | 44 +++----- internal/llm/tools/grep_test.go | 192 ++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 29 deletions(-) diff --git a/internal/llm/tools/grep.go b/internal/llm/tools/grep.go index cbf50360b9355c05797690678a99d1310b19556f..237d4e18dab0bc518b9d4b6e2c73ef5035d2b348 100644 --- a/internal/llm/tools/grep.go +++ b/internal/llm/tools/grep.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "net/http" "os" "os/exec" "path/filepath" @@ -390,8 +391,8 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error } func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, string, error) { - // Quick binary file detection - if isBinaryFile(filePath) { + // Only search text files. + if !isTextFile(filePath) { return false, 0, "", nil } @@ -414,45 +415,30 @@ func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, st return false, 0, "", scanner.Err() } -var binaryExts = map[string]struct{}{ - ".exe": {}, ".dll": {}, ".so": {}, ".dylib": {}, - ".bin": {}, ".obj": {}, ".o": {}, ".a": {}, - ".zip": {}, ".tar": {}, ".gz": {}, ".bz2": {}, - ".jpg": {}, ".jpeg": {}, ".png": {}, ".gif": {}, - ".pdf": {}, ".doc": {}, ".docx": {}, ".xls": {}, - ".mp3": {}, ".mp4": {}, ".avi": {}, ".mov": {}, -} - -// isBinaryFile performs a quick check to determine if a file is binary -func isBinaryFile(filePath string) bool { - // Check file extension first (fastest) - ext := strings.ToLower(filepath.Ext(filePath)) - if _, isBinary := binaryExts[ext]; isBinary { - return true - } - - // Quick content check for files without clear extensions +// isTextFile checks if a file is a text file by examining its MIME type. +func isTextFile(filePath string) bool { file, err := os.Open(filePath) if err != nil { - return false // If we can't open it, let the caller handle the error + return false } defer file.Close() - // Read first 512 bytes to check for null bytes + // Read first 512 bytes for MIME type detection. buffer := make([]byte, 512) n, err := file.Read(buffer) if err != nil && err != io.EOF { return false } - // Check for null bytes (common in binary files) - for i := range n { - if buffer[i] == 0 { - return true - } - } + // Detect content type. + contentType := http.DetectContentType(buffer[:n]) - return false + // Check if it's a text MIME type. + return strings.HasPrefix(contentType, "text/") || + contentType == "application/json" || + contentType == "application/xml" || + contentType == "application/javascript" || + contentType == "application/x-sh" } func globToRegex(glob string) string { diff --git a/internal/llm/tools/grep_test.go b/internal/llm/tools/grep_test.go index 53c96b22df444adfba59c6b13995a104411a57be..435b3045b93a8e1297ff2aaeff9ee8977b974b56 100644 --- a/internal/llm/tools/grep_test.go +++ b/internal/llm/tools/grep_test.go @@ -198,3 +198,195 @@ func BenchmarkRegexCacheVsCompile(b *testing.B) { } }) } + +func TestIsTextFile(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + + tests := []struct { + name string + filename string + content []byte + wantText bool + }{ + { + name: "go file", + filename: "test.go", + content: []byte("package main\n\nfunc main() {}\n"), + wantText: true, + }, + { + name: "yaml file", + filename: "config.yaml", + content: []byte("key: value\nlist:\n - item1\n - item2\n"), + wantText: true, + }, + { + name: "yml file", + filename: "config.yml", + content: []byte("key: value\n"), + wantText: true, + }, + { + name: "json file", + filename: "data.json", + content: []byte(`{"key": "value"}`), + wantText: true, + }, + { + name: "javascript file", + filename: "script.js", + content: []byte("console.log('hello');\n"), + wantText: true, + }, + { + name: "typescript file", + filename: "script.ts", + content: []byte("const x: string = 'hello';\n"), + wantText: true, + }, + { + name: "markdown file", + filename: "README.md", + content: []byte("# Title\n\nSome content\n"), + wantText: true, + }, + { + name: "shell script", + filename: "script.sh", + content: []byte("#!/bin/bash\necho 'hello'\n"), + wantText: true, + }, + { + name: "python file", + filename: "script.py", + content: []byte("print('hello')\n"), + wantText: true, + }, + { + name: "xml file", + filename: "data.xml", + content: []byte("\n\n"), + wantText: true, + }, + { + name: "plain text", + filename: "file.txt", + content: []byte("plain text content\n"), + wantText: true, + }, + { + name: "css file", + filename: "style.css", + content: []byte("body { color: red; }\n"), + wantText: true, + }, + { + name: "scss file", + filename: "style.scss", + content: []byte("$primary: blue;\nbody { color: $primary; }\n"), + wantText: true, + }, + { + name: "sass file", + filename: "style.sass", + content: []byte("$primary: blue\nbody\n color: $primary\n"), + wantText: true, + }, + { + name: "rust file", + filename: "main.rs", + content: []byte("fn main() {\n println!(\"Hello, world!\");\n}\n"), + wantText: true, + }, + { + name: "zig file", + filename: "main.zig", + content: []byte("const std = @import(\"std\");\npub fn main() void {}\n"), + wantText: true, + }, + { + name: "java file", + filename: "Main.java", + content: []byte("public class Main {\n public static void main(String[] args) {}\n}\n"), + wantText: true, + }, + { + name: "c file", + filename: "main.c", + content: []byte("#include \nint main() { return 0; }\n"), + wantText: true, + }, + { + name: "cpp file", + filename: "main.cpp", + content: []byte("#include \nint main() { return 0; }\n"), + wantText: true, + }, + { + name: "fish shell", + filename: "script.fish", + content: []byte("#!/usr/bin/env fish\necho 'hello'\n"), + wantText: true, + }, + { + name: "powershell file", + filename: "script.ps1", + content: []byte("Write-Host 'Hello, World!'\n"), + wantText: true, + }, + { + name: "cmd batch file", + filename: "script.bat", + content: []byte("@echo off\necho Hello, World!\n"), + wantText: true, + }, + { + name: "cmd file", + filename: "script.cmd", + content: []byte("@echo off\necho Hello, World!\n"), + wantText: true, + }, + { + name: "binary exe", + filename: "binary.exe", + content: []byte{0x4D, 0x5A, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00}, + wantText: false, + }, + { + name: "png image", + filename: "image.png", + content: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, + wantText: false, + }, + { + name: "jpeg image", + filename: "image.jpg", + content: []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46}, + wantText: false, + }, + { + name: "zip archive", + filename: "archive.zip", + content: []byte{0x50, 0x4B, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00}, + wantText: false, + }, + { + name: "pdf file", + filename: "document.pdf", + content: []byte("%PDF-1.4\n%âãÏÓ\n"), + wantText: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + filePath := filepath.Join(tempDir, tt.filename) + require.NoError(t, os.WriteFile(filePath, tt.content, 0o644)) + + got := isTextFile(filePath) + require.Equal(t, tt.wantText, got, "isTextFile(%s) = %v, want %v", tt.filename, got, tt.wantText) + }) + } +} From 23e0fd441709aa040dda95d00de19ff2770ab037 Mon Sep 17 00:00:00 2001 From: Evan Date: Wed, 15 Oct 2025 15:58:56 -0400 Subject: [PATCH 58/94] fix(mcp): add type assertion guards (#1239) Signed-off-by: Evan Wies --- internal/llm/agent/mcp-tools.go | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 67f0b39ccfb6eb8aad3abd337e7545a59766d872..038cd43f4469953779799b70850355ef5dcda45f 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -98,9 +98,26 @@ func (b *McpTool) Name() string { } func (b *McpTool) Info() tools.ToolInfo { - input := b.tool.InputSchema.(map[string]any) - required, _ := input["required"].([]string) - parameters, _ := input["properties"].(map[string]any) + var parameters map[string]any + var required []string + + if input, ok := b.tool.InputSchema.(map[string]any); ok { + if props, ok := input["properties"].(map[string]any); ok { + parameters = props + } + if req, ok := input["required"].([]any); ok { + // Convert []any -> []string when elements are strings + for _, v := range req { + if s, ok := v.(string); ok { + required = append(required, s) + } + } + } else if reqStr, ok := input["required"].([]string); ok { + // Handle case where it's already []string + required = reqStr + } + } + return tools.ToolInfo{ Name: fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name), Description: b.tool.Description, From 05457d52a5467a1168676b8bdff802bac9cddd54 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 15 Oct 2025 16:00:50 -0400 Subject: [PATCH 59/94] refactor: use clamp from /x/exp/ordered (#1236) --- go.mod | 2 ++ go.sum | 2 ++ internal/tui/components/chat/messages/messages.go | 5 +++-- internal/tui/exp/list/list.go | 5 +++-- internal/tui/util/util.go | 7 ------- 5 files changed, 10 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index 5f32e148b92ac8e6c456157465061c759d267dd9..15ad5f22d82ad649decda39907f0911650a8b5f5 100644 --- a/go.mod +++ b/go.mod @@ -46,6 +46,8 @@ require ( mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5 ) +require github.com/charmbracelet/x/exp/ordered v0.1.0 + require ( cloud.google.com/go v0.116.0 // indirect cloud.google.com/go/auth v0.13.0 // indirect diff --git a/go.sum b/go.sum index 2d53e85a40001ea9241e4c7ee728baa734a889d9..d1c0349a66d5e8c0e9bf6968d849cb0cbf6d26c5 100644 --- a/go.sum +++ b/go.sum @@ -102,6 +102,8 @@ github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw= github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d h1:H2oh4WlSsXy8qwLd7I3eAvPd/X3S40aM9l+h47WF1eA= github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms= github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4 h1:ZhDGU688EHQXslD9KphRpXwK0pKP03egUoZAATUDlV0= diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 296b02478a7d0738fef2f60ae6b2211d44424a2f..d931ba7e179255d6639db78ebea5e82b57af1504 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -12,6 +12,7 @@ import ( "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/ordered" "github.com/google/uuid" "github.com/atotto/clipboard" @@ -271,7 +272,7 @@ func (m *messageCmp) renderThinkingContent() string { } } fullContent := content.String() - height := util.Clamp(lipgloss.Height(fullContent), 1, 10) + height := ordered.Clamp(lipgloss.Height(fullContent), 1, 10) m.thinkingViewport.SetHeight(height) m.thinkingViewport.SetWidth(m.textWidth()) m.thinkingViewport.SetContent(fullContent) @@ -344,7 +345,7 @@ func (m *messageCmp) GetSize() (int, int) { // SetSize updates the width of the message component for text wrapping func (m *messageCmp) SetSize(width int, height int) tea.Cmd { - m.width = util.Clamp(width, 1, 120) + m.width = ordered.Clamp(width, 1, 120) m.thinkingViewport.SetWidth(m.width - 4) return nil } diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index fd789f90b89b016abb9b9fb5c79227da7ef30fd9..e18b88348959c59190f1741698f76c33f04571db 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -15,6 +15,7 @@ import ( "github.com/charmbracelet/lipgloss/v2" uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/ordered" "github.com/rivo/uniseg" ) @@ -1283,14 +1284,14 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd { newItem, ok := l.renderedItems.Get(item.ID()) if ok { newLines := newItem.height - oldItem.height - l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1) + l.offset = ordered.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1) } } } else if hasOldItem && l.offset > oldItem.start { newItem, ok := l.renderedItems.Get(item.ID()) if ok { newLines := newItem.height - oldItem.height - l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1) + l.offset = ordered.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1) } } } diff --git a/internal/tui/util/util.go b/internal/tui/util/util.go index 1f4ea30c49c8fb0517a5068d3b7f05970638743a..eb19ad89544b281af2e836f667ac63aaa6414e01 100644 --- a/internal/tui/util/util.go +++ b/internal/tui/util/util.go @@ -60,10 +60,3 @@ type ( } ClearStatusMsg struct{} ) - -func Clamp(v, low, high int) int { - if high < low { - low, high = high, low - } - return min(high, max(low, v)) -} From 1932bcebd41811e8605dc626158d6ec2e3e9118b Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 15 Oct 2025 17:01:29 -0300 Subject: [PATCH 60/94] chore: go mod tidy Signed-off-by: Carlos Alexandro Becker --- go.mod | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 15ad5f22d82ad649decda39907f0911650a8b5f5..e0b92a9380af54233306de80c826a5191878298a 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/charmbracelet/x/ansi v0.10.2 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a + github.com/charmbracelet/x/exp/ordered v0.1.0 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec github.com/google/uuid v1.6.0 github.com/invopop/jsonschema v0.13.0 @@ -46,8 +47,6 @@ require ( mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5 ) -require github.com/charmbracelet/x/exp/ordered v0.1.0 - require ( cloud.google.com/go v0.116.0 // indirect cloud.google.com/go/auth v0.13.0 // indirect From 4b1001cf3391473faf43233a10cc4240ec147eb9 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 15 Oct 2025 17:13:14 -0300 Subject: [PATCH 61/94] fix(tui): paste on arguments input (#1240) Signed-off-by: Carlos Alexandro Becker --- internal/tui/components/dialogs/commands/arguments.go | 2 -- internal/tui/components/dialogs/commands/keys.go | 7 ------- 2 files changed, 9 deletions(-) diff --git a/internal/tui/components/dialogs/commands/arguments.go b/internal/tui/components/dialogs/commands/arguments.go index b1a274319719b9f550179b35aa98fd8310e0bb7b..72677bc934864970c2cbded87b31853ad702a6ed 100644 --- a/internal/tui/components/dialogs/commands/arguments.go +++ b/internal/tui/components/dialogs/commands/arguments.go @@ -128,8 +128,6 @@ func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { c.inputs[c.focusIndex].Blur() c.focusIndex = (c.focusIndex - 1 + len(c.inputs)) % len(c.inputs) c.inputs[c.focusIndex].Focus() - case key.Matches(msg, c.keys.Paste): - return c, textinput.Paste case key.Matches(msg, c.keys.Close): return c, util.CmdHandler(dialogs.CloseDialogMsg{}) default: diff --git a/internal/tui/components/dialogs/commands/keys.go b/internal/tui/components/dialogs/commands/keys.go index b704f227fe6f183a430bd25d3af62f4ef50b3365..65d4af84c22c87117bf5a08427027da5ee0e244f 100644 --- a/internal/tui/components/dialogs/commands/keys.go +++ b/internal/tui/components/dialogs/commands/keys.go @@ -76,7 +76,6 @@ type ArgumentsDialogKeyMap struct { Confirm key.Binding Next key.Binding Previous key.Binding - Paste key.Binding Close key.Binding } @@ -95,10 +94,6 @@ func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap { key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "previous"), ), - Paste: key.NewBinding( - key.WithKeys("ctrl+v"), - key.WithHelp("ctrl+v", "paste"), - ), Close: key.NewBinding( key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "cancel"), @@ -112,7 +107,6 @@ func (k ArgumentsDialogKeyMap) KeyBindings() []key.Binding { k.Confirm, k.Next, k.Previous, - k.Paste, k.Close, } } @@ -134,7 +128,6 @@ func (k ArgumentsDialogKeyMap) ShortHelp() []key.Binding { k.Confirm, k.Next, k.Previous, - k.Paste, k.Close, } } From ce72a48378780d5fc76da7333353348aa05502fe Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 16 Oct 2025 08:58:56 -0300 Subject: [PATCH 62/94] fix(mcp): append to os.Environ() (#1242) Signed-off-by: Carlos Alexandro Becker --- internal/llm/agent/mcp-tools.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 038cd43f4469953779799b70850355ef5dcda45f..6209efa29fb7d9a3a488ca730f6f8175e3b08a60 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -10,6 +10,7 @@ import ( "log/slog" "maps" "net/http" + "os" "os/exec" "strings" "sync" @@ -415,7 +416,7 @@ func createMCPTransport(ctx context.Context, m config.MCPConfig, resolver config return nil, fmt.Errorf("mcp stdio config requires a non-empty 'command' field") } cmd := exec.CommandContext(ctx, home.Long(command), m.Args...) - cmd.Env = m.ResolvedEnv() + cmd.Env = append(os.Environ(), m.ResolvedEnv()...) return &mcp.CommandTransport{ Command: cmd, }, nil From 2aa53614d2970fba5674d439a0fec0e531fd6d93 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 16 Oct 2025 10:11:52 -0300 Subject: [PATCH 63/94] test: add tests for the dirs cmd (#1243) Signed-off-by: Carlos Alexandro Becker --- internal/cmd/dirs_test.go | 46 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 internal/cmd/dirs_test.go diff --git a/internal/cmd/dirs_test.go b/internal/cmd/dirs_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2d68f45481a61b4ee9cf9ddc31b8d86d8a69a51f --- /dev/null +++ b/internal/cmd/dirs_test.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func init() { + os.Setenv("XDG_CONFIG_HOME", "/tmp/fakeconfig") + os.Setenv("XDG_DATA_HOME", "/tmp/fakedata") +} + +func TestDirs(t *testing.T) { + var b bytes.Buffer + dirsCmd.SetOut(&b) + dirsCmd.SetErr(&b) + dirsCmd.SetIn(bytes.NewReader(nil)) + dirsCmd.Run(dirsCmd, nil) + expected := filepath.FromSlash("/tmp/fakeconfig/crush") + "\n" + + filepath.FromSlash("/tmp/fakedata/crush") + "\n" + require.Equal(t, expected, b.String()) +} + +func TestConfigDir(t *testing.T) { + var b bytes.Buffer + configDirCmd.SetOut(&b) + configDirCmd.SetErr(&b) + configDirCmd.SetIn(bytes.NewReader(nil)) + configDirCmd.Run(configDirCmd, nil) + expected := filepath.FromSlash("/tmp/fakeconfig/crush") + "\n" + require.Equal(t, expected, b.String()) +} + +func TestDataDir(t *testing.T) { + var b bytes.Buffer + dataDirCmd.SetOut(&b) + dataDirCmd.SetErr(&b) + dataDirCmd.SetIn(bytes.NewReader(nil)) + dataDirCmd.Run(dataDirCmd, nil) + expected := filepath.FromSlash("/tmp/fakedata/crush") + "\n" + require.Equal(t, expected, b.String()) +} From 6166fc6fc9a57b9b69a447560563d61abe7e8d46 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Thu, 16 Oct 2025 14:30:16 -0300 Subject: [PATCH 64/94] chore(legal): @BrunoKrugel 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 61a8b03447ae07a5dc775ca59a5eef7aacfe9c2b..12b86fb4c2936e871a1a022a150385b3744b23cf 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -711,6 +711,14 @@ "created_at": "2025-10-13T05:56:20Z", "repoId": 987670088, "pullRequestNo": 1223 + }, + { + "name": "BrunoKrugel", + "id": 30608179, + "comment_id": 3411978929, + "created_at": "2025-10-16T17:30:07Z", + "repoId": 987670088, + "pullRequestNo": 1245 } ] } \ No newline at end of file From b896a2584775d3b4f4179a2774350fee4b6313cf Mon Sep 17 00:00:00 2001 From: Bruno Krugel Date: Thu, 16 Oct 2025 14:34:50 -0300 Subject: [PATCH 65/94] fix(mcp): avoid nil errors for tool parameters (#1245) --- CRUSH.md | 2 +- internal/llm/agent/mcp-tools.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CRUSH.md b/CRUSH.md index 102ad43ca5758beee6515ab9da4054ddc92b9a9f..dee2e7ba62baeb2af691828ed67dddf3446d4525 100644 --- a/CRUSH.md +++ b/CRUSH.md @@ -54,7 +54,7 @@ func TestYourFunction(t *testing.T) { ## Formatting - ALWAYS format any Go code you write. - - First, try `goftumpt -w .`. + - First, try `gofumpt -w .`. - If `gofumpt` is not available, use `goimports`. - If `goimports` is not available, use `gofmt`. - You can also use `task fmt` to run `gofumpt -w .` on the entire project, diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 6209efa29fb7d9a3a488ca730f6f8175e3b08a60..041cff490a59f1de51505e833cc7ee7866aa7644 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -99,8 +99,8 @@ func (b *McpTool) Name() string { } func (b *McpTool) Info() tools.ToolInfo { - var parameters map[string]any - var required []string + parameters := make(map[string]any) + required := make([]string, 0) if input, ok := b.tool.InputSchema.(map[string]any); ok { if props, ok := input["properties"].(map[string]any); ok { From 3a9954297f6d5c20e53d4a64335d5bccb9323792 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 16 Oct 2025 14:36:58 -0300 Subject: [PATCH 66/94] fix(mcp): improve STDIO error handling (#1244) Signed-off-by: Carlos Alexandro Becker --- internal/llm/agent/mcp-tools.go | 34 +++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 041cff490a59f1de51505e833cc7ee7866aa7644..d2ff6454e9a7135ee9404ef665495772b90ba86c 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -387,6 +387,7 @@ func createMCPSession(ctx context.Context, name string, m config.MCPConfig, reso session, err := client.Connect(mcpCtx, transport, nil) if err != nil { + err = maybeStdioErr(err, transport) updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0) slog.Error("error starting mcp client", "error", err, "name", name) cancel() @@ -398,6 +399,27 @@ func createMCPSession(ctx context.Context, name string, m config.MCPConfig, reso return session, nil } +// maybeStdioErr if a stdio mcp prints an error in non-json format, it'll fail +// to parse, and the cli will then close it, causing the EOF error. +// so, if we got an EOF err, and the transport is STDIO, we try to exec it +// again with a timeout and collect the output so we can add details to the +// error. +// this happens particularly when starting things with npx, e.g. if node can't +// be found or some other error like that. +func maybeStdioErr(err error, transport mcp.Transport) error { + if !errors.Is(err, io.EOF) { + return err + } + ct, ok := transport.(*mcp.CommandTransport) + if !ok { + return err + } + if err2 := stdioMCPCheck(ct.Command); err2 != nil { + err = errors.Join(err, err2) + } + return err +} + func maybeTimeoutErr(err error, timeout time.Duration) error { if errors.Is(err, context.Canceled) { return fmt.Errorf("timed out after %s", timeout) @@ -465,3 +487,15 @@ func (rt headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error func mcpTimeout(m config.MCPConfig) time.Duration { return time.Duration(cmp.Or(m.Timeout, 15)) * time.Second } + +func stdioMCPCheck(old *exec.Cmd) error { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + cmd := exec.CommandContext(ctx, old.Path, old.Args...) + cmd.Env = old.Env + out, err := cmd.CombinedOutput() + if err == nil || errors.Is(ctx.Err(), context.DeadlineExceeded) { + return nil + } + return fmt.Errorf("%w: %s", err, string(out)) +} From 015632a146db8ce0d04c989b4394f67490ad0c21 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 16 Oct 2025 19:36:29 -0300 Subject: [PATCH 68/94] fix(mcp): make sure to cancel context on error (#1246) Signed-off-by: Carlos Alexandro Becker --- Taskfile.yaml | 2 +- internal/llm/agent/mcp-tools.go | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index 92b162dfbb847356e09eb17ea5996e6093a305b2..65a7e2d42fe8dcb307bced7c1fa9a0326b35ccc1 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -99,7 +99,7 @@ tasks: cmds: - task: fetch-tags - git commit --allow-empty -m "{{.NEXT}}" - - git tag --annotate -m "{{.NEXT}}" {{.NEXT}} {{.CLI_ARGS}} + - git tag --annotate --sign -m "{{.NEXT}}" {{.NEXT}} {{.CLI_ARGS}} - echo "Pushing {{.NEXT}}..." - git push origin main --follow-tags diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index d2ff6454e9a7135ee9404ef665495772b90ba86c..6838c54ab4dc8cface0eb311e0fb933a5c18aae6 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -365,6 +365,8 @@ func createMCPSession(ctx context.Context, name string, m config.MCPConfig, reso if err != nil { updateMCPState(name, MCPStateError, err, nil, 0) slog.Error("error creating mcp client", "error", err, "name", name) + cancel() + cancelTimer.Stop() return nil, err } @@ -391,6 +393,7 @@ func createMCPSession(ctx context.Context, name string, m config.MCPConfig, reso updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0) slog.Error("error starting mcp client", "error", err, "name", name) cancel() + cancelTimer.Stop() return nil, err } From a64a4def3ea855ac2c84cd0c12d165fe5098b1a5 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 17 Oct 2025 09:56:58 -0300 Subject: [PATCH 69/94] feat(lsp): find references tool (#1233) Signed-off-by: Carlos Alexandro Becker --- go.mod | 2 +- go.sum | 4 +- internal/llm/agent/agent.go | 2 +- internal/llm/tools/diagnostics.go | 2 +- internal/llm/tools/grep.go | 108 +++++++------- internal/llm/tools/grep_test.go | 29 ++++ internal/llm/tools/references.go | 214 +++++++++++++++++++++++++++ internal/llm/tools/references.md | 36 +++++ internal/llm/tools/rg.go | 2 +- internal/llm/tools/testdata/grep.txt | 3 + internal/lsp/client.go | 10 ++ 11 files changed, 348 insertions(+), 64 deletions(-) create mode 100644 internal/llm/tools/references.go create mode 100644 internal/llm/tools/references.md create mode 100644 internal/llm/tools/testdata/grep.txt diff --git a/go.mod b/go.mod index e0b92a9380af54233306de80c826a5191878298a..c0bc32fe29ac100f98c589edf7697f104aa854a5 100644 --- a/go.mod +++ b/go.mod @@ -77,7 +77,7 @@ require ( github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d - github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4 + github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 github.com/charmbracelet/x/term v0.2.1 github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect diff --git a/go.sum b/go.sum index d1c0349a66d5e8c0e9bf6968d849cb0cbf6d26c5..0fa4e9f695cf5d60a60be753aaee9a0b2e14c192 100644 --- a/go.sum +++ b/go.sum @@ -106,8 +106,8 @@ github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sB github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d h1:H2oh4WlSsXy8qwLd7I3eAvPd/X3S40aM9l+h47WF1eA= github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms= -github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4 h1:ZhDGU688EHQXslD9KphRpXwK0pKP03egUoZAATUDlV0= -github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4= +github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc= +github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index e338eef782912bdfea48ca72ebfd33c4cd981f62..b2b222db1a481b1eb4c7e945467bd5c74506d5ab 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -525,7 +525,7 @@ func (a *agent) getAllTools() ([]tools.BaseTool, error) { if a.agentCfg.ID == "coder" { allTools = slices.AppendSeq(allTools, a.mcpTools.Seq()) if a.lspClients.Len() > 0 { - allTools = append(allTools, tools.NewDiagnosticsTool(a.lspClients)) + allTools = append(allTools, tools.NewDiagnosticsTool(a.lspClients), tools.NewReferencesTool(a.lspClients)) } } if a.agentToolFn != nil { diff --git a/internal/llm/tools/diagnostics.go b/internal/llm/tools/diagnostics.go index 8e0c332cef76e40d5e24e74ed3260b95aab8b04b..c2625e9495963f1de467656b2d74e71e0b3c78fa 100644 --- a/internal/llm/tools/diagnostics.go +++ b/internal/llm/tools/diagnostics.go @@ -23,7 +23,7 @@ type diagnosticsTool struct { lspClients *csync.Map[string, *lsp.Client] } -const DiagnosticsToolName = "diagnostics" +const DiagnosticsToolName = "lsp_diagnostics" //go:embed diagnostics.md var diagnosticsDescription []byte diff --git a/internal/llm/tools/grep.go b/internal/llm/tools/grep.go index 237d4e18dab0bc518b9d4b6e2c73ef5035d2b348..ed844b6c10081deab6a314f380da72e0893102ca 100644 --- a/internal/llm/tools/grep.go +++ b/internal/llm/tools/grep.go @@ -2,6 +2,7 @@ package tools import ( "bufio" + "bytes" "context" _ "embed" "encoding/json" @@ -13,7 +14,6 @@ import ( "path/filepath" "regexp" "sort" - "strconv" "strings" "sync" "time" @@ -82,6 +82,7 @@ type grepMatch struct { path string modTime time.Time lineNum int + charNum int lineText string } @@ -189,7 +190,11 @@ func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) fmt.Fprintf(&output, "%s:\n", match.path) } if match.lineNum > 0 { - fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, match.lineText) + if match.charNum > 0 { + fmt.Fprintf(&output, " Line %d, Char %d: %s\n", match.lineNum, match.charNum, match.lineText) + } else { + fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, match.lineText) + } } else { fmt.Fprintf(&output, " %s\n", match.path) } @@ -252,66 +257,51 @@ func searchWithRipgrep(ctx context.Context, pattern, path, include string) ([]gr return nil, err } - lines := strings.Split(strings.TrimSpace(string(output)), "\n") - matches := make([]grepMatch, 0, len(lines)) - - for _, line := range lines { - if line == "" { + var matches []grepMatch + for line := range bytes.SplitSeq(bytes.TrimSpace(output), []byte{'\n'}) { + if len(line) == 0 { continue } - - // Parse ripgrep output using null separation - filePath, lineNumStr, lineText, ok := parseRipgrepLine(line) - if !ok { + var match ripgrepMatch + if err := json.Unmarshal(line, &match); err != nil { continue } - - lineNum, err := strconv.Atoi(lineNumStr) - if err != nil { + if match.Type != "match" { continue } - - fileInfo, err := os.Stat(filePath) - if err != nil { - continue // Skip files we can't access + for _, m := range match.Data.Submatches { + fi, err := os.Stat(match.Data.Path.Text) + if err != nil { + continue // Skip files we can't access + } + matches = append(matches, grepMatch{ + path: match.Data.Path.Text, + modTime: fi.ModTime(), + lineNum: match.Data.LineNumber, + charNum: m.Start + 1, // ensure 1-based + lineText: strings.TrimSpace(match.Data.Lines.Text), + }) + // only get the first match of each line + break } - - matches = append(matches, grepMatch{ - path: filePath, - modTime: fileInfo.ModTime(), - lineNum: lineNum, - lineText: lineText, - }) } - return matches, nil } -// parseRipgrepLine parses ripgrep output with null separation to handle Windows paths -func parseRipgrepLine(line string) (filePath, lineNum, lineText string, ok bool) { - // Split on null byte first to separate filename from rest - parts := strings.SplitN(line, "\x00", 2) - if len(parts) != 2 { - return "", "", "", false - } - - filePath = parts[0] - remainder := parts[1] - - // Now split the remainder on first colon: "linenum:content" - colonIndex := strings.Index(remainder, ":") - if colonIndex == -1 { - return "", "", "", false - } - - lineNumStr := remainder[:colonIndex] - lineText = remainder[colonIndex+1:] - - if _, err := strconv.Atoi(lineNumStr); err != nil { - return "", "", "", false - } - - return filePath, lineNumStr, lineText, true +type ripgrepMatch struct { + Type string `json:"type"` + Data struct { + Path struct { + Text string `json:"text"` + } `json:"path"` + Lines struct { + Text string `json:"text"` + } `json:"lines"` + LineNumber int `json:"line_number"` + Submatches []struct { + Start int `json:"start"` + } `json:"submatches"` + } `json:"data"` } func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) { @@ -363,7 +353,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error return nil } - match, lineNum, lineText, err := fileContainsPattern(path, regex) + match, lineNum, charNum, lineText, err := fileContainsPattern(path, regex) if err != nil { return nil // Skip files we can't read } @@ -373,6 +363,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error path: path, modTime: info.ModTime(), lineNum: lineNum, + charNum: charNum, lineText: lineText, }) @@ -390,15 +381,15 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error return matches, nil } -func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, string, error) { +func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, int, string, error) { // Only search text files. if !isTextFile(filePath) { - return false, 0, "", nil + return false, 0, 0, "", nil } file, err := os.Open(filePath) if err != nil { - return false, 0, "", err + return false, 0, 0, "", err } defer file.Close() @@ -407,12 +398,13 @@ func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, st for scanner.Scan() { lineNum++ line := scanner.Text() - if pattern.MatchString(line) { - return true, lineNum, line, nil + if loc := pattern.FindStringIndex(line); loc != nil { + charNum := loc[0] + 1 + return true, lineNum, charNum, line, nil } } - return false, 0, "", scanner.Err() + return false, 0, 0, "", scanner.Err() } // isTextFile checks if a file is a text file by examining its MIME type. diff --git a/internal/llm/tools/grep_test.go b/internal/llm/tools/grep_test.go index 435b3045b93a8e1297ff2aaeff9ee8977b974b56..753ee05942b78578fd2e9170384cac3fd5d9496e 100644 --- a/internal/llm/tools/grep_test.go +++ b/internal/llm/tools/grep_test.go @@ -390,3 +390,32 @@ func TestIsTextFile(t *testing.T) { }) } } + +func TestColumnMatch(t *testing.T) { + t.Parallel() + + // Test both implementations + for name, fn := range map[string]func(pattern, path, include string) ([]grepMatch, error){ + "regex": searchFilesWithRegex, + "rg": func(pattern, path, include string) ([]grepMatch, error) { + return searchWithRipgrep(t.Context(), pattern, path, include) + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if name == "rg" && getRg() == "" { + t.Skip("rg is not in $PATH") + } + + matches, err := fn("THIS", "./testdata/", "") + require.NoError(t, err) + require.Len(t, matches, 1) + match := matches[0] + require.Equal(t, 2, match.lineNum) + require.Equal(t, 14, match.charNum) + require.Equal(t, "I wanna grep THIS particular word", match.lineText) + require.Equal(t, "testdata/grep.txt", filepath.ToSlash(filepath.Clean(match.path))) + }) + } +} diff --git a/internal/llm/tools/references.go b/internal/llm/tools/references.go new file mode 100644 index 0000000000000000000000000000000000000000..a1bc393cd5d28755f5f0b694c1b2df40bee1a39e --- /dev/null +++ b/internal/llm/tools/references.go @@ -0,0 +1,214 @@ +package tools + +import ( + "cmp" + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "log/slog" + "maps" + "path/filepath" + "regexp" + "slices" + "sort" + "strings" + + "github.com/charmbracelet/crush/internal/csync" + "github.com/charmbracelet/crush/internal/lsp" + "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" +) + +type ReferencesParams struct { + Symbol string `json:"symbol"` + Path string `json:"path"` +} + +type referencesTool struct { + lspClients *csync.Map[string, *lsp.Client] +} + +const ReferencesToolName = "lsp_references" + +//go:embed references.md +var referencesDescription []byte + +func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) BaseTool { + return &referencesTool{ + lspClients, + } +} + +func (r *referencesTool) Name() string { + return ReferencesToolName +} + +func (r *referencesTool) Info() ToolInfo { + return ToolInfo{ + Name: ReferencesToolName, + Description: string(referencesDescription), + Parameters: map[string]any{ + "symbol": map[string]any{ + "type": "string", + "description": "The symbol name to search for (e.g., function name, variable name, type name).", + }, + "path": map[string]any{ + "type": "string", + "description": "The directory to search in. Should be the entire project most of the time. Defaults to the current working directory.", + }, + }, + Required: []string{"symbol"}, + } +} + +func (r *referencesTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) { + var params ReferencesParams + if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil { + return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil + } + + if params.Symbol == "" { + return NewTextErrorResponse("symbol is required"), nil + } + + if r.lspClients.Len() == 0 { + return NewTextErrorResponse("no LSP clients available"), nil + } + + workingDir := cmp.Or(params.Path, ".") + + matches, _, err := searchFiles(ctx, regexp.QuoteMeta(params.Symbol), workingDir, "", 100) + if err != nil { + return NewTextErrorResponse(fmt.Sprintf("failed to search for symbol: %s", err)), nil + } + + if len(matches) == 0 { + return NewTextResponse(fmt.Sprintf("Symbol '%s' not found", params.Symbol)), nil + } + + var allLocations []protocol.Location + var allErrs error + for _, match := range matches { + locations, err := r.find(ctx, params.Symbol, match) + if err != nil { + if strings.Contains(err.Error(), "no identifier found") { + // grep probably matched a comment, string value, or something else that's irrelevant + continue + } + slog.Error("Failed to find references", "error", err, "symbol", params.Symbol, "path", match.path, "line", match.lineNum, "char", match.charNum) + allErrs = errors.Join(allErrs, err) + continue + } + allLocations = append(allLocations, locations...) + // XXX: should we break here or look for all results? + } + + if len(allLocations) > 0 { + output := formatReferences(cleanupLocations(allLocations)) + return NewTextResponse(output), nil + } + + if allErrs != nil { + return NewTextErrorResponse(allErrs.Error()), nil + } + return NewTextResponse(fmt.Sprintf("No references found for symbol '%s'", params.Symbol)), nil +} + +func (r *referencesTool) find(ctx context.Context, symbol string, match grepMatch) ([]protocol.Location, error) { + absPath, err := filepath.Abs(match.path) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path: %s", err) + } + + var client *lsp.Client + for c := range r.lspClients.Seq() { + if c.HandlesFile(absPath) { + client = c + break + } + } + + if client == nil { + slog.Warn("No LSP clients to handle", "path", match.path) + return nil, nil + } + + return client.FindReferences( + ctx, + absPath, + match.lineNum, + match.charNum+getSymbolOffset(symbol), + true, + ) +} + +// getSymbolOffset returns the character offset to the actual symbol name +// in a qualified symbol (e.g., "Bar" in "foo.Bar" or "method" in "Class::method"). +func getSymbolOffset(symbol string) int { + // Check for :: separator (Rust, C++, Ruby modules/classes, PHP static). + if idx := strings.LastIndex(symbol, "::"); idx != -1 { + return idx + 2 + } + // Check for . separator (Go, Python, JavaScript, Java, C#, Ruby methods). + if idx := strings.LastIndex(symbol, "."); idx != -1 { + return idx + 1 + } + // Check for \ separator (PHP namespaces). + if idx := strings.LastIndex(symbol, "\\"); idx != -1 { + return idx + 1 + } + return 0 +} + +func cleanupLocations(locations []protocol.Location) []protocol.Location { + slices.SortFunc(locations, func(a, b protocol.Location) int { + if a.URI != b.URI { + return strings.Compare(string(a.URI), string(b.URI)) + } + if a.Range.Start.Line != b.Range.Start.Line { + return cmp.Compare(a.Range.Start.Line, b.Range.Start.Line) + } + return cmp.Compare(a.Range.Start.Character, b.Range.Start.Character) + }) + return slices.CompactFunc(locations, func(a, b protocol.Location) bool { + return a.URI == b.URI && + a.Range.Start.Line == b.Range.Start.Line && + a.Range.Start.Character == b.Range.Start.Character + }) +} + +func groupByFilename(locations []protocol.Location) map[string][]protocol.Location { + files := make(map[string][]protocol.Location) + for _, loc := range locations { + path, err := loc.URI.Path() + if err != nil { + slog.Error("Failed to convert location URI to path", "uri", loc.URI, "error", err) + continue + } + files[path] = append(files[path], loc) + } + return files +} + +func formatReferences(locations []protocol.Location) string { + fileRefs := groupByFilename(locations) + files := slices.Collect(maps.Keys(fileRefs)) + sort.Strings(files) + + var output strings.Builder + output.WriteString(fmt.Sprintf("Found %d reference(s) in %d file(s):\n\n", len(locations), len(files))) + + for _, file := range files { + refs := fileRefs[file] + output.WriteString(fmt.Sprintf("%s (%d reference(s)):\n", file, len(refs))) + for _, ref := range refs { + line := ref.Range.Start.Line + 1 + char := ref.Range.Start.Character + 1 + output.WriteString(fmt.Sprintf(" Line %d, Column %d\n", line, char)) + } + output.WriteString("\n") + } + + return output.String() +} diff --git a/internal/llm/tools/references.md b/internal/llm/tools/references.md new file mode 100644 index 0000000000000000000000000000000000000000..951ce71a68b9d62060649cda999107ab9243f42a --- /dev/null +++ b/internal/llm/tools/references.md @@ -0,0 +1,36 @@ +Find all references to/usage of a symbol by name using the Language Server Protocol (LSP). + +WHEN TO USE THIS TOOL: + +- **ALWAYS USE THIS FIRST** when searching for where a function, method, variable, type, or constant is used +- **DO NOT use grep/glob for symbol searches** - this tool is semantic-aware and much more accurate +- Use when you need to find all usages of a specific symbol (function, variable, type, class, method, etc.) +- More accurate than grep because it understands code semantics and scope +- Finds only actual references, not string matches in comments or unrelated code +- Helpful for understanding where a symbol is used throughout the codebase +- Useful for refactoring or analyzing code dependencies +- Good for finding all call sites of a function, method, type, package, constant, variable, etc. + +HOW TO USE: + +- Provide the symbol name (e.g., "MyFunction", "myVariable", "MyType") +- Optionally specify a path to narrow the search to a specific directory +- The tool will automatically find the symbol and locate all references + +FEATURES: + +- Returns all references grouped by file +- Shows line and column numbers for each reference +- Supports multiple programming languages through LSP +- Automatically finds the symbol without needing exact position + +LIMITATIONS: + +- May not find references in files that haven't been opened or indexed +- Results depend on the LSP server's capabilities + +TIPS: + +- **Use this tool instead of grep when looking for symbol references** - it's more accurate and semantic-aware +- Simply provide the symbol name and let the tool find it for you +- This tool understands code structure, so it won't match unrelated strings or comments diff --git a/internal/llm/tools/rg.go b/internal/llm/tools/rg.go index 8809b57c8db30b4ac1ed6c070df5a7218c59e233..76dbb5daf2234669ac3d90552cbbc5af5cc003d0 100644 --- a/internal/llm/tools/rg.go +++ b/internal/llm/tools/rg.go @@ -43,7 +43,7 @@ func getRgSearchCmd(ctx context.Context, pattern, path, include string) *exec.Cm return nil } // Use -n to show line numbers, -0 for null separation to handle Windows paths - args := []string{"-H", "-n", "-0", pattern} + args := []string{"--json", "-H", "-n", "-0", pattern} if include != "" { args = append(args, "--glob", include) } diff --git a/internal/llm/tools/testdata/grep.txt b/internal/llm/tools/testdata/grep.txt new file mode 100644 index 0000000000000000000000000000000000000000..edac9ec894634e3b924fb9a0928a272ac4f29e7e --- /dev/null +++ b/internal/llm/tools/testdata/grep.txt @@ -0,0 +1,3 @@ +test file for grep +I wanna grep THIS particular word +and nothing else diff --git a/internal/lsp/client.go b/internal/lsp/client.go index ff9a3ac9b5249663c151fb2df04a4acb168e4de4..afbe95cc2deb1c37b64c9e9b68fb705a4a0a59f9 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -445,6 +445,16 @@ func (c *Client) WaitForDiagnostics(ctx context.Context, d time.Duration) { } } +// FindReferences finds all references to the symbol at the given position. +func (c *Client) FindReferences(ctx context.Context, filepath string, line, character int, includeDeclaration bool) ([]protocol.Location, error) { + if err := c.OpenFileOnDemand(ctx, filepath); err != nil { + return nil, err + } + // NOTE: line and character should be 0-based. + // See: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position + return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration) +} + // HasRootMarkers checks if any of the specified root marker patterns exist in the given directory. // Uses glob patterns to match files, allowing for more flexible matching. func HasRootMarkers(dir string, rootMarkers []string) bool { From 29f9dbbb368ee074bc507a96e4da8c7d49f1d354 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Sat, 18 Oct 2025 16:24:11 -0300 Subject: [PATCH 70/94] chore(legal): @dpolishuk 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 12b86fb4c2936e871a1a022a150385b3744b23cf..e71c57185db1ec3540a5082a7d9ec7daf33a3379 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -719,6 +719,14 @@ "created_at": "2025-10-16T17:30:07Z", "repoId": 987670088, "pullRequestNo": 1245 + }, + { + "name": "dpolishuk", + "id": 466424, + "comment_id": 3418756045, + "created_at": "2025-10-18T19:24:00Z", + "repoId": 987670088, + "pullRequestNo": 1254 } ] } \ No newline at end of file From 7fd91ba6d1556bd334e83c6e82f76c4c5dc9e9b3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Oct 2025 10:19:31 +0000 Subject: [PATCH 71/94] chore(deps): bump the all group with 3 updates (#1265) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index c0bc32fe29ac100f98c589edf7697f104aa854a5..b73666f15b444b4ab3b671e868090b7f02c75701 100644 --- a/go.mod +++ b/go.mod @@ -7,14 +7,14 @@ require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/PuerkitoBio/goquery v1.10.3 github.com/alecthomas/chroma/v2 v2.20.0 - github.com/anthropics/anthropic-sdk-go v1.13.0 + github.com/anthropics/anthropic-sdk-go v1.14.0 github.com/atotto/clipboard v0.1.4 github.com/aymanbagabas/go-udiff v0.3.1 github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/charlievieth/fastwalk v1.0.14 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619 - github.com/charmbracelet/catwalk v0.6.4 + github.com/charmbracelet/catwalk v0.7.0 github.com/charmbracelet/fang v0.4.3 github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea @@ -153,7 +153,7 @@ require ( golang.org/x/text v0.30.0 golang.org/x/time v0.8.0 // indirect google.golang.org/api v0.211.0 // indirect - google.golang.org/genai v1.30.0 + google.golang.org/genai v1.31.0 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect google.golang.org/grpc v1.71.0 // indirect google.golang.org/protobuf v1.36.8 // indirect diff --git a/go.sum b/go.sum index 0fa4e9f695cf5d60a60be753aaee9a0b2e14c192..fc96feb71bd75fe1b0f07b7ac739d3550ed1be4e 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,8 @@ github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW5 github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= -github.com/anthropics/anthropic-sdk-go v1.13.0 h1:Bhbe8sRoDPtipttg8bQYrMCKe2b79+q6rFW1vOKEUKI= -github.com/anthropics/anthropic-sdk-go v1.13.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +github.com/anthropics/anthropic-sdk-go v1.14.0 h1:EzNQvnZlaDHe2UPkoUySDz3ixRgNbwKdH8KtFpv7pi4= +github.com/anthropics/anthropic-sdk-go v1.14.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY= @@ -80,8 +80,8 @@ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619 h1:hjOhtqsxa+LVuCAkzhfA43wtusOaUPyQdSTg/wbRscw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619/go.mod h1:5IzIGXU1n0foRc8bRAherC8ZuQCQURPlwx3ANLq1138= -github.com/charmbracelet/catwalk v0.6.4 h1:zFHtuP94mSDE48nST3DS3a37wfsQqNcVnsFkS3v6N6E= -github.com/charmbracelet/catwalk v0.6.4/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY= +github.com/charmbracelet/catwalk v0.7.0 h1:qhLv56aeel5Q+2G/YFh9k5FhTqsozsn4HYViuAQ/Rio= +github.com/charmbracelet/catwalk v0.7.0/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY= github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= github.com/charmbracelet/fang v0.4.3 h1:qXeMxnL4H6mSKBUhDefHu8NfikFbP/MBNTfqTrXvzmY= @@ -427,8 +427,8 @@ golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.211.0 h1:IUpLjq09jxBSV1lACO33CGY3jsRcbctfGzhj+ZSE/Bg= google.golang.org/api v0.211.0/go.mod h1:XOloB4MXFH4UTlQSGuNUxw0UT74qdENK8d6JNsXKLi0= -google.golang.org/genai v1.30.0 h1:7021aneIvl24nEBLbtQFEWleHsMbjzpcQvkT4WcJ1dc= -google.golang.org/genai v1.30.0/go.mod h1:7pAilaICJlQBonjKKJNhftDFv3SREhZcTe9F6nRcjbg= +google.golang.org/genai v1.31.0 h1:R7xDt/Dosz11vcXbZ4IgisGnzUGGau2PZOIOAnXsYjw= +google.golang.org/genai v1.31.0/go.mod h1:7pAilaICJlQBonjKKJNhftDFv3SREhZcTe9F6nRcjbg= google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g= google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= From 5e3155381308f5dbb39ebdc3804d9c8c6d6e41d2 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 20 Oct 2025 16:26:09 -0300 Subject: [PATCH 72/94] chore(deps): pin `anthropic-sdk-go` to our branch with fixes --- go.mod | 4 ++++ go.sum | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index b73666f15b444b4ab3b671e868090b7f02c75701..344e0835b91133c506216098c6aaac94b171b758 100644 --- a/go.mod +++ b/go.mod @@ -161,3 +161,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5 ) + +// NOTE(@andreynering): Temporarily pinning branch with fixes: +// https://github.com/charmbracelet/anthropic-sdk-go/commits/fantasy/ +replace github.com/anthropics/anthropic-sdk-go => github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251020200929-fdb68318b7af diff --git a/go.sum b/go.sum index fc96feb71bd75fe1b0f07b7ac739d3550ed1be4e..9411b6803932f87185b9e8122daf034c0d9d6bbc 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,6 @@ github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW5 github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= -github.com/anthropics/anthropic-sdk-go v1.14.0 h1:EzNQvnZlaDHe2UPkoUySDz3ixRgNbwKdH8KtFpv7pi4= -github.com/anthropics/anthropic-sdk-go v1.14.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY= @@ -76,6 +74,8 @@ github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMU github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICgnWlhAyg= github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY= +github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251020200929-fdb68318b7af h1:iPwFVe5v46OfhqxKXSJ4J0YWf8XzthTnWyrim2yGFnU= +github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251020200929-fdb68318b7af/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 h1:973OHYuq2Jx9deyuPwe/6lsuQrDCatOsjP8uCd02URE= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619 h1:hjOhtqsxa+LVuCAkzhfA43wtusOaUPyQdSTg/wbRscw= From 2708121abecf192a1c7b5e2d38ba3cdd51d3de08 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 20 Oct 2025 16:27:38 -0300 Subject: [PATCH 73/94] feat(bedrock): add support for `AWS_BEARER_TOKEN_BEDROCK` for bedrock This adds an alternative authorization method vs. `aws configure`. --- internal/config/load.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/config/load.go b/internal/config/load.go index c63a9663613bdfdea6a9c9ccef9f53d375e35c74..a219b7d1c848b3eea76809fca96e8e4049838365 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -589,6 +589,10 @@ func hasVertexCredentials(env env.Env) bool { } func hasAWSCredentials(env env.Env) bool { + if env.Get("AWS_BEARER_TOKEN_BEDROCK") != "" { + return true + } + if env.Get("AWS_ACCESS_KEY_ID") != "" && env.Get("AWS_SECRET_ACCESS_KEY") != "" { return true } From f478c4c009aa01f42c41e614ccb5463248c7825e Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 20 Oct 2025 16:29:11 -0300 Subject: [PATCH 74/94] docs: update aws bedrock docs on readme * Removed duplicated `AWS_REGION` in table. * Add documentation for `AWS_BEARER_TOKEN_BEDROCK`. --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7f28c5c049cdb6c45bc83ec59f94f4310c13b7c5..6ed20ff1acbf2384d9daaaa184b2feffa939ceaf 100644 --- a/README.md +++ b/README.md @@ -189,8 +189,8 @@ That said, you can also set environment variables for preferred providers. | `AWS_ACCESS_KEY_ID` | AWS Bedrock (Claude) | | `AWS_SECRET_ACCESS_KEY` | AWS Bedrock (Claude) | | `AWS_REGION` | AWS Bedrock (Claude) | -| `AWS_PROFILE` | Custom AWS Profile | -| `AWS_REGION` | AWS Region | +| `AWS_PROFILE` | AWS Bedrock (Custom Profile) | +| `AWS_BEARER_TOKEN_BEDROCK` | AWS Bedrock | | `AZURE_OPENAI_API_ENDPOINT` | Azure OpenAI models | | `AZURE_OPENAI_API_KEY` | Azure OpenAI models (optional when using Entra ID) | | `AZURE_OPENAI_API_VERSION` | Azure OpenAI models | @@ -479,6 +479,7 @@ Crush currently supports running Anthropic models through Bedrock, with caching - 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` +- Alternatively to `aws configure`, you can also just set `AWS_BEARER_TOKEN_BEDROCK` ### Vertex AI Platform From 4519e198cc50fc8950198a19b8e6f39d68e33930 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 20 Oct 2025 21:27:09 -0300 Subject: [PATCH 75/94] fix(mcp): improve cache hits when using MCPs (#1271) --- internal/llm/agent/agent.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index b2b222db1a481b1eb4c7e945467bd5c74506d5ab..6825da22ac13dd107731abb1a506b49bec8a5271 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -535,6 +535,10 @@ func (a *agent) getAllTools() ([]tools.BaseTool, error) { } allTools = append(allTools, agentTool) } + + slices.SortFunc(allTools, func(a, b tools.BaseTool) int { + return strings.Compare(a.Name(), b.Name()) + }) return allTools, nil } From beb3bc0d8a751bdcf3a0782e118222ddc62e96bd Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 21 Oct 2025 04:45:56 -0300 Subject: [PATCH 76/94] fix(tui): remove ctrl+d deny keybind (#1269) Signed-off-by: Carlos Alexandro Becker --- internal/tui/components/dialogs/permissions/keys.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/components/dialogs/permissions/keys.go b/internal/tui/components/dialogs/permissions/keys.go index 4b7660ceb2310595fc0ad7d1ce51dade83169035..fc1810fc582dc4c25cada280b00b3f9515e43008 100644 --- a/internal/tui/components/dialogs/permissions/keys.go +++ b/internal/tui/components/dialogs/permissions/keys.go @@ -42,7 +42,7 @@ func DefaultKeyMap() KeyMap { key.WithHelp("s", "allow session"), ), Deny: key.NewBinding( - key.WithKeys("d", "D", "ctrl+d", "esc"), + key.WithKeys("d", "D", "esc"), key.WithHelp("d", "deny"), ), Select: key.NewBinding( From 6dbb5b9b3596f53cb7386a043e3244333bdf31d6 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 21 Oct 2025 14:06:20 -0300 Subject: [PATCH 78/94] chore(deps): use our anthropic's fork directly instead of `replace` (#1277) --- go.mod | 6 +----- go.sum | 4 ++-- internal/llm/provider/anthropic.go | 8 ++++---- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index 344e0835b91133c506216098c6aaac94b171b758..7c1cf2b1b5033a3e4fb404fa0a09c83f77db8367 100644 --- a/go.mod +++ b/go.mod @@ -7,11 +7,11 @@ require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/PuerkitoBio/goquery v1.10.3 github.com/alecthomas/chroma/v2 v2.20.0 - github.com/anthropics/anthropic-sdk-go v1.14.0 github.com/atotto/clipboard v0.1.4 github.com/aymanbagabas/go-udiff v0.3.1 github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/charlievieth/fastwalk v1.0.14 + github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251021163913-d29170d047bf github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619 github.com/charmbracelet/catwalk v0.7.0 @@ -161,7 +161,3 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5 ) - -// NOTE(@andreynering): Temporarily pinning branch with fixes: -// https://github.com/charmbracelet/anthropic-sdk-go/commits/fantasy/ -replace github.com/anthropics/anthropic-sdk-go => github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251020200929-fdb68318b7af diff --git a/go.sum b/go.sum index 9411b6803932f87185b9e8122daf034c0d9d6bbc..bbb776c23ee5a38737e960af5f17764ea977aaab 100644 --- a/go.sum +++ b/go.sum @@ -74,8 +74,8 @@ github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMU github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICgnWlhAyg= github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY= -github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251020200929-fdb68318b7af h1:iPwFVe5v46OfhqxKXSJ4J0YWf8XzthTnWyrim2yGFnU= -github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251020200929-fdb68318b7af/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251021163913-d29170d047bf h1:toCE1GpniOr8JPJII2GH1AffivFVOzq8Rs2S0FUrkNU= +github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251021163913-d29170d047bf/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 h1:973OHYuq2Jx9deyuPwe/6lsuQrDCatOsjP8uCd02URE= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619 h1:hjOhtqsxa+LVuCAkzhfA43wtusOaUPyQdSTg/wbRscw= diff --git a/internal/llm/provider/anthropic.go b/internal/llm/provider/anthropic.go index 981ff4590fd7db92288ff11b3d8f607e594cb0fd..1f1965fde3fd04dad759f368ce2e232543f86c8e 100644 --- a/internal/llm/provider/anthropic.go +++ b/internal/llm/provider/anthropic.go @@ -13,10 +13,10 @@ import ( "strings" "time" - "github.com/anthropics/anthropic-sdk-go" - "github.com/anthropics/anthropic-sdk-go/bedrock" - "github.com/anthropics/anthropic-sdk-go/option" - "github.com/anthropics/anthropic-sdk-go/vertex" + "github.com/charmbracelet/anthropic-sdk-go" + "github.com/charmbracelet/anthropic-sdk-go/bedrock" + "github.com/charmbracelet/anthropic-sdk-go/option" + "github.com/charmbracelet/anthropic-sdk-go/vertex" "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/llm/tools" From 941b8e9fd19a94f53ecdadb64035adc2dd16b99c Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 21 Oct 2025 15:17:22 -0300 Subject: [PATCH 79/94] ci: remove vx.y.z from the release notes (#1276) Signed-off-by: Carlos Alexandro Becker --- .goreleaser.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.goreleaser.yml b/.goreleaser.yml index aabf2f7606462ebb540fd6ebe9efb302a6855e5f..28539bc1681353065ea542a1e4de711a2d425585 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -303,6 +303,7 @@ changelog: - "^docs: update$" - "^test:" - "^test\\(" + - "^v\\d.*" - "merge conflict" - "merge conflict" - Merge branch From 092b3515ce644dbb4fe09f5bea12414e13e4832a Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 21 Oct 2025 18:10:30 -0300 Subject: [PATCH 80/94] fix: always fetch providers live and not in background (#1281) --- internal/config/provider.go | 34 +------------------------------- internal/config/provider_test.go | 2 +- 2 files changed, 2 insertions(+), 34 deletions(-) diff --git a/internal/config/provider.go b/internal/config/provider.go index 671c348f71da3a79f65c14c624bdaf2adc011411..108d6a667794e4f8f1beebe6997848a65d8fd6e6 100644 --- a/internal/config/provider.go +++ b/internal/config/provider.go @@ -126,7 +126,7 @@ func Providers(cfg *Config) ([]catwalk.Provider, error) { } func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string) ([]catwalk.Provider, error) { - cacheIsStale, cacheExists := isCacheStale(path) + _, cacheExists := isCacheStale(path) catwalkGetAndSave := func() ([]catwalk.Provider, error) { providers, err := client.GetProviders() @@ -142,25 +142,6 @@ func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string) return providers, nil } - backgroundCacheUpdate := func() { - go func() { - slog.Info("Updating providers cache in background", "path", path) - - providers, err := client.GetProviders() - if err != nil { - slog.Error("Failed to fetch providers in background from Catwalk", "error", err) - return - } - if len(providers) == 0 { - slog.Error("Empty providers list from Catwalk") - return - } - if err := saveProvidersInCache(path, providers); err != nil { - slog.Error("Failed to update providers.json in background", "error", err) - } - }() - } - switch { case autoUpdateDisabled: slog.Warn("Providers auto-update is disabled") @@ -177,19 +158,6 @@ func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string) } return providers, nil - case cacheExists && !cacheIsStale: - slog.Info("Recent providers cache is available.", "path", path) - - providers, err := loadProvidersFromCache(path) - if err != nil { - return nil, err - } - if len(providers) == 0 { - return catwalkGetAndSave() - } - backgroundCacheUpdate() - return providers, nil - default: slog.Info("Cache is not available or is stale. Fetching providers from Catwalk.", "path", path) diff --git a/internal/config/provider_test.go b/internal/config/provider_test.go index 8b499919bca666915a89d38c1e5014a911f4d2d1..1262b60ef42050b9061c9f7c6be4dc431efe3548 100644 --- a/internal/config/provider_test.go +++ b/internal/config/provider_test.go @@ -57,7 +57,7 @@ func TestProvider_loadProvidersWithIssues(t *testing.T) { if err != nil { t.Fatalf("Failed to write old providers to file: %v", err) } - providers, err := loadProviders(false, client, tmpPath) + providers, err := loadProviders(true, client, tmpPath) require.NoError(t, err) require.NotNil(t, providers) require.Len(t, providers, 1) From 23417aaec66ce592b6f47982df54ca3f1d61a402 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 21 Oct 2025 18:39:13 -0300 Subject: [PATCH 81/94] fix: only debug if enabled (#1279) --- internal/log/http.go | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/internal/log/http.go b/internal/log/http.go index 46c4b42af599f1809478a5c3f083c6249a3e13d0..a4564ffdc50335e3944c44ccf0a9a562e2f6454a 100644 --- a/internal/log/http.go +++ b/internal/log/http.go @@ -39,12 +39,14 @@ func (h *HTTPRoundTripLogger) RoundTrip(req *http.Request) (*http.Response, erro return nil, err } - slog.Debug( - "HTTP Request", - "method", req.Method, - "url", req.URL, - "body", bodyToString(save), - ) + if slog.Default().Enabled(req.Context(), slog.LevelDebug) { + slog.Debug( + "HTTP Request", + "method", req.Method, + "url", req.URL, + "body", bodyToString(save), + ) + } start := time.Now() resp, err := h.Transport.RoundTrip(req) @@ -61,16 +63,18 @@ func (h *HTTPRoundTripLogger) RoundTrip(req *http.Request) (*http.Response, erro } save, resp.Body, err = drainBody(resp.Body) - slog.Debug( - "HTTP Response", - "status_code", resp.StatusCode, - "status", resp.Status, - "headers", formatHeaders(resp.Header), - "body", bodyToString(save), - "content_length", resp.ContentLength, - "duration_ms", duration.Milliseconds(), - "error", err, - ) + if slog.Default().Enabled(req.Context(), slog.LevelDebug) { + slog.Debug( + "HTTP Response", + "status_code", resp.StatusCode, + "status", resp.Status, + "headers", formatHeaders(resp.Header), + "body", bodyToString(save), + "content_length", resp.ContentLength, + "duration_ms", duration.Milliseconds(), + "error", err, + ) + } return resp, err } @@ -84,7 +88,7 @@ func bodyToString(body io.ReadCloser) string { return "" } var b bytes.Buffer - if json.Compact(&b, bytes.TrimSpace(src)) != nil { + if json.Indent(&b, bytes.TrimSpace(src), "", " ") != nil { // not json probably return string(src) } From 7eef0b18d2548c1db74550914d38e2541d6b36d7 Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 22 Oct 2025 08:23:25 -0600 Subject: [PATCH 82/94] chore: embed version in build/install tasks (#1278) --- Taskfile.yaml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index 65a7e2d42fe8dcb307bced7c1fa9a0326b35ccc1..9e0f214fb1f7081ffb90d53e7a62eab120950b0d 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -2,6 +2,10 @@ version: "3" +vars: + VERSION: + sh: git describe --long 2>/dev/null || echo "" + env: CGO_ENABLED: 0 GOEXPERIMENT: greenteagc @@ -30,8 +34,10 @@ tasks: build: desc: Run build + vars: + LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}' cmds: - - go build . + - go build {{.LDFLAGS}} . generates: - crush @@ -59,8 +65,10 @@ tasks: install: desc: Install the application + vars: + LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}' cmds: - - go install -v . + - go install {{.LDFLAGS}} -v . profile:cpu: desc: 10s CPU profile From 1f004dcb22e77aa41b3a670de85375929b5d096c Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 22 Oct 2025 11:47:37 -0400 Subject: [PATCH 83/94] docs(readme): move slack url --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6ed20ff1acbf2384d9daaaa184b2feffa939ceaf..435c999d334a71187464670373b00effd23a8e1a 100644 --- a/README.md +++ b/README.md @@ -650,8 +650,8 @@ See the [contributing guide](https://github.com/charmbracelet/crush?tab=contribu We’d love to hear your thoughts on this project. Need help? We gotchu. You can find us on: - [Twitter](https://twitter.com/charmcli) -- [Discord][discord] - [Slack](https://charm.land/slack) +- [Discord][discord] - [The Fediverse](https://mastodon.social/@charmcli) - [Bluesky](https://bsky.app/profile/charm.land) From a292b299e74ba280154a8eac5b5fb660bf4ff124 Mon Sep 17 00:00:00 2001 From: James Trew <66286082+jamestrew@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:18:01 -0400 Subject: [PATCH 84/94] perf(list): optimize filter performance and limit results (#1193) --- internal/tui/components/chat/editor/editor.go | 2 + .../tui/components/completions/completions.go | 2 + internal/tui/exp/list/filterable.go | 47 +++++++++++-------- 3 files changed, 32 insertions(+), 19 deletions(-) diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index f70a0a3dbe63a9473f552efa233e03bd4efc0ee1..9ccd453d3f6f200c43012b61a7545fb3c08a4e6a 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -86,6 +86,7 @@ var DeleteKeyMaps = DeleteAttachmentKeyMaps{ const ( maxAttachments = 5 + maxFileResults = 25 ) type OpenEditorMsg struct { @@ -500,6 +501,7 @@ func (m *editorCmp) startCompletions() tea.Msg { Completions: completionItems, X: x, Y: y, + MaxResults: maxFileResults, } } diff --git a/internal/tui/components/completions/completions.go b/internal/tui/components/completions/completions.go index ae3c233e4f21b089f59b7effb88ddc3300277d16..1d8a0a854197d3b7ba9e26426bde8a2679f79573 100644 --- a/internal/tui/components/completions/completions.go +++ b/internal/tui/components/completions/completions.go @@ -22,6 +22,7 @@ type OpenCompletionsMsg struct { Completions []Completion X int // X position for the completions popup Y int // Y position for the completions popup + MaxResults int // Maximum number of results to render, 0 for no limit } type FilterCompletionsMsg struct { @@ -192,6 +193,7 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } c.width = width c.height = max(min(maxCompletionsHeight, len(items)), 1) // Ensure at least 1 item height + c.list.SetResultsSize(msg.MaxResults) return c, tea.Batch( c.list.SetItems(items), c.list.SetSize(c.width, c.height), diff --git a/internal/tui/exp/list/filterable.go b/internal/tui/exp/list/filterable.go index e639786db5777aaeda237e959dffe36d9c6a7583..d5c47b01083cdc1becaed9aac4fb8a5d3e9f3b47 100644 --- a/internal/tui/exp/list/filterable.go +++ b/internal/tui/exp/list/filterable.go @@ -3,8 +3,6 @@ package list import ( "regexp" "slices" - "sort" - "strings" "github.com/charmbracelet/bubbles/v2/key" "github.com/charmbracelet/bubbles/v2/textinput" @@ -28,7 +26,9 @@ type FilterableList[T FilterableItem] interface { Cursor() *tea.Cursor SetInputWidth(int) SetInputPlaceholder(string) + SetResultsSize(int) Filter(q string) tea.Cmd + fuzzy.Source } type HasMatchIndexes interface { @@ -47,10 +47,11 @@ type filterableList[T FilterableItem] struct { *filterableOptions width, height int // stores all available items - items []T - input textinput.Model - inputWidth int - query string + items []T + resultsSize int + input textinput.Model + inputWidth int + query string } type filterableListOption func(*filterableOptions) @@ -246,22 +247,18 @@ func (f *filterableList[T]) Filter(query string) tea.Cmd { return f.list.SetItems(f.items) } - words := make([]string, len(f.items)) - for i, item := range f.items { - words[i] = strings.ToLower(item.FilterValue()) - } - - matches := fuzzy.Find(query, words) - - sort.SliceStable(matches, func(i, j int) bool { - return matches[i].Score > matches[j].Score - }) + matches := fuzzy.FindFrom(query, f) var matchedItems []T - for _, match := range matches { + resultSize := len(matches) + if f.resultsSize > 0 && resultSize > f.resultsSize { + resultSize = f.resultsSize + } + for i := range resultSize { + match := matches[i] item := f.items[match.Index] - if i, ok := any(item).(HasMatchIndexes); ok { - i.MatchIndexes(match.MatchedIndexes) + if it, ok := any(item).(HasMatchIndexes); ok { + it.MatchIndexes(match.MatchedIndexes) } matchedItems = append(matchedItems, item) } @@ -307,3 +304,15 @@ func (f *filterableList[T]) SetInputWidth(w int) { func (f *filterableList[T]) SetInputPlaceholder(ph string) { f.placeholder = ph } + +func (f *filterableList[T]) SetResultsSize(size int) { + f.resultsSize = size +} + +func (f *filterableList[T]) String(i int) string { + return f.items[i].FilterValue() +} + +func (f *filterableList[T]) Len() int { + return len(f.items) +} From 9f2c7d096bcc8b524f25ed011620f8f2d08c212d Mon Sep 17 00:00:00 2001 From: James Trew <66286082+jamestrew@users.noreply.github.com> Date: Thu, 23 Oct 2025 07:39:09 -0400 Subject: [PATCH 86/94] fix(logs): disable color output when stdout is not a tty (#1286) --- internal/cmd/logs.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/cmd/logs.go b/internal/cmd/logs.go index e7160f4a1307406be20f1fe00a59e93de5232d67..4372083189701e1410c83690c18fbd371f778169 100644 --- a/internal/cmd/logs.go +++ b/internal/cmd/logs.go @@ -10,8 +10,10 @@ import ( "slices" "time" + "github.com/charmbracelet/colorprofile" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/log/v2" + "github.com/charmbracelet/x/term" "github.com/nxadm/tail" "github.com/spf13/cobra" ) @@ -45,6 +47,9 @@ var logsCmd = &cobra.Command{ log.SetLevel(log.DebugLevel) log.SetOutput(os.Stdout) + if !term.IsTerminal(os.Stdout.Fd()) { + log.SetColorProfile(colorprofile.NoTTY) + } cfg, err := config.Load(cwd, dataDir, false) if err != nil { From 14f2f9c703b917cb9492dcba141a4d7a2d4be2a0 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 23 Oct 2025 12:09:14 -0400 Subject: [PATCH 87/94] refactor: use v2 declarative API (#1229) --- go.mod | 6 ++-- go.sum | 12 +++---- internal/cmd/root.go | 5 +-- internal/format/spinner.go | 4 +-- internal/tui/components/anim/anim.go | 3 +- internal/tui/components/chat/chat.go | 2 +- internal/tui/components/chat/editor/editor.go | 2 +- internal/tui/components/chat/header/header.go | 2 +- .../tui/components/chat/messages/messages.go | 6 ++-- internal/tui/components/chat/messages/tool.go | 6 ++-- .../tui/components/chat/sidebar/sidebar.go | 2 +- internal/tui/components/chat/splash/splash.go | 2 +- .../tui/components/completions/completions.go | 2 +- internal/tui/components/core/status/status.go | 2 +- .../components/dialogs/commands/arguments.go | 2 +- .../components/dialogs/commands/commands.go | 2 +- .../tui/components/dialogs/compact/compact.go | 2 +- internal/tui/components/dialogs/dialogs.go | 10 ++++-- .../dialogs/filepicker/filepicker.go | 2 +- .../tui/components/dialogs/models/apikey.go | 3 +- .../tui/components/dialogs/models/models.go | 2 +- .../dialogs/permissions/permissions.go | 2 +- internal/tui/components/dialogs/quit/quit.go | 2 +- .../components/dialogs/reasoning/reasoning.go | 2 +- .../components/dialogs/sessions/sessions.go | 2 +- internal/tui/exp/list/filterable.go | 3 +- internal/tui/exp/list/filterable_group.go | 3 +- internal/tui/exp/list/grouped.go | 2 +- internal/tui/exp/list/items.go | 5 +-- internal/tui/exp/list/list.go | 4 +-- internal/tui/exp/list/list_test.go | 5 +-- internal/tui/page/chat/chat.go | 2 +- internal/tui/tui.go | 36 ++++++------------- internal/tui/util/util.go | 5 +-- 34 files changed, 72 insertions(+), 80 deletions(-) diff --git a/go.mod b/go.mod index 7c1cf2b1b5033a3e4fb404fa0a09c83f77db8367..a8927a4b2fde67a249a81feafa49d5c8fe2c395e 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/charlievieth/fastwalk v1.0.14 github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251021163913-d29170d047bf github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 - github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619 + github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.5 github.com/charmbracelet/catwalk v0.7.0 github.com/charmbracelet/fang v0.4.3 github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018 @@ -74,7 +74,7 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/charmbracelet/colorprofile v0.3.2 - github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef + github.com/charmbracelet/ultraviolet v0.0.0-20251017140847-d4ace4d6e731 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 @@ -148,7 +148,7 @@ require ( golang.org/x/net v0.43.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.36.0 // indirect + golang.org/x/sys v0.37.0 // indirect golang.org/x/term v0.35.0 // indirect golang.org/x/text v0.30.0 golang.org/x/time v0.8.0 // indirect diff --git a/go.sum b/go.sum index bbb776c23ee5a38737e960af5f17764ea977aaab..1d3160e24bcaf521b52c99e300e3ba221be4d656 100644 --- a/go.sum +++ b/go.sum @@ -78,8 +78,8 @@ github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251021163913-d29170d047bf h1: github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251021163913-d29170d047bf/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 h1:973OHYuq2Jx9deyuPwe/6lsuQrDCatOsjP8uCd02URE= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= -github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619 h1:hjOhtqsxa+LVuCAkzhfA43wtusOaUPyQdSTg/wbRscw= -github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619/go.mod h1:5IzIGXU1n0foRc8bRAherC8ZuQCQURPlwx3ANLq1138= +github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.5 h1:oAChAeh730gtLKK/BpaTeJHzmj3KFuEfQ7AZgf2VGHM= +github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.5/go.mod h1:SUTLq+/pGQ5qntHgt0JswfVJFfgJgWDqyvyiSLVlmbo= github.com/charmbracelet/catwalk v0.7.0 h1:qhLv56aeel5Q+2G/YFh9k5FhTqsozsn4HYViuAQ/Rio= github.com/charmbracelet/catwalk v0.7.0/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY= github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= @@ -92,8 +92,8 @@ github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea/go.mod h1:ngHerf1JLJXBrDXdphn5gFrBPriCL437uwukd5c93pM= github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 h1:WkwO6Ks3mSIGnGuSdKl9qDSyfbYK50z2wc2gGMggegE= github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706/go.mod h1:mjJGp00cxcfvD5xdCa+bso251Jt4owrQvuimJtVmEmM= -github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef h1:VrWaUi2LXYLjfjCHowdSOEc6dQ9Ro14KY7Bw4IWd19M= -github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef/go.mod h1:AThRsQH1t+dfyOKIwXRoJBniYFQUkUpQq4paheHMc2o= +github.com/charmbracelet/ultraviolet v0.0.0-20251017140847-d4ace4d6e731 h1:Lr+igmzKpLPdb8yUZBP9noYWwCZP042z2nWPrJZTc+8= +github.com/charmbracelet/ultraviolet v0.0.0-20251017140847-d4ace4d6e731/go.mod h1:KfWwUa0Oe//D72YlhbOq/g40L7UiGtATrvsGI3cciG8= github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw= github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a h1:zYSNtEJM9jwHbJts2k+Hroj+xQwsW1yxc4Wopdv7KaI= @@ -388,8 +388,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= diff --git a/internal/cmd/root.go b/internal/cmd/root.go index d6a26d818643a05704f554223a7b7960792970c5..005f2e86f7012b265fb619580c7cc2eec2e4de03 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -83,11 +83,8 @@ crush -y // Set up the TUI. program := tea.NewProgram( tui.New(app), - tea.WithAltScreen(), tea.WithContext(cmd.Context()), - tea.WithMouseCellMotion(), // Use cell motion instead of all motion to reduce event flooding - tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state - ) + tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state go app.Subscribe(program) diff --git a/internal/format/spinner.go b/internal/format/spinner.go index 69e443d0f67adadd1e3f9b9a13129850324b6184..d1d9805f4b7bd511270316c4b1f0dafdfe9401b3 100644 --- a/internal/format/spinner.go +++ b/internal/format/spinner.go @@ -23,8 +23,8 @@ type model struct { anim *anim.Anim } -func (m model) Init() tea.Cmd { return m.anim.Init() } -func (m model) View() string { return m.anim.View() } +func (m model) Init() tea.Cmd { return m.anim.Init() } +func (m model) View() tea.View { return tea.NewView(m.anim.View()) } // Update implements tea.Model. func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go index 05ac4da98281248d1774a10e95f4d8e2f177e048..d04176ba9e07c2ce15427e9496cf0896222ba930 100644 --- a/internal/tui/components/anim/anim.go +++ b/internal/tui/components/anim/anim.go @@ -16,6 +16,7 @@ import ( "github.com/lucasb-eyer/go-colorful" "github.com/charmbracelet/crush/internal/csync" + "github.com/charmbracelet/crush/internal/tui/util" ) const ( @@ -318,7 +319,7 @@ func (a *Anim) Init() tea.Cmd { } // Update processes animation steps (or not). -func (a *Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (a *Anim) Update(msg tea.Msg) (util.Model, tea.Cmd) { switch msg := msg.(type) { case StepMsg: if msg.id != a.id { diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 8688f7e24c94290c74ae4344499acff61b43ac39..aaaf683494a8dd1608d9ebae4f07dae6037def26 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -101,7 +101,7 @@ func (m *messageListCmp) Init() tea.Cmd { } // Update handles incoming messages and updates the component state. -func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { var cmds []tea.Cmd if m.session.ID != "" && m.app.CoderAgent != nil { queueSize := m.app.CoderAgent.QueuedPrompts(m.session.ID) diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 9ccd453d3f6f200c43012b61a7545fb3c08a4e6a..92c6bea70c3e43af1b92f03c30ba3e15af0f5e4d 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -172,7 +172,7 @@ func (m *editorCmp) repositionCompletions() tea.Msg { return completions.RepositionCompletionsMsg{X: x, Y: y} } -func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd switch msg := msg.(type) { diff --git a/internal/tui/components/chat/header/header.go b/internal/tui/components/chat/header/header.go index 21861a4a2eda1340f6e01c0748f24cb713f15398..6bea86690b4ffe813799ad6e1ba01359562ed791 100644 --- a/internal/tui/components/chat/header/header.go +++ b/internal/tui/components/chat/header/header.go @@ -44,7 +44,7 @@ func (h *header) Init() tea.Cmd { return nil } -func (h *header) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (h *header) Update(msg tea.Msg) (util.Model, tea.Cmd) { switch msg := msg.(type) { case pubsub.Event[session.Session]: if msg.Type == pubsub.UpdatedEvent { diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index d931ba7e179255d6639db78ebea5e82b57af1504..eb0e9e84c3cd1342126b8de9acef6d145a15bb62 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -35,7 +35,7 @@ var ClearSelectionKey = key.NewBinding(key.WithKeys("esc", "alt+esc"), key.WithH // MessageCmp defines the interface for message components in the chat interface. // It combines standard UI model interfaces with message-specific functionality. type MessageCmp interface { - util.Model // Basic Bubble Tea model interface + util.Model // Basic Bubble util.Model interface layout.Sizeable // Width/height management layout.Focusable // Focus state management GetMessage() message.Message // Access to underlying message data @@ -94,7 +94,7 @@ func (m *messageCmp) Init() tea.Cmd { // Update handles incoming messages and updates the component state. // Manages animation updates for spinning messages and stops animation when appropriate. -func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *messageCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { switch msg := msg.(type) { case anim.StepMsg: m.spinning = m.shouldSpin() @@ -384,7 +384,7 @@ func (m *assistantSectionModel) Init() tea.Cmd { return nil } -func (m *assistantSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) { +func (m *assistantSectionModel) Update(tea.Msg) (util.Model, tea.Cmd) { return m, nil } diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index 7e03674f97243e7d9e569b341fe1c6f1d2450b93..1899a47ec14ab185f06f64063223a0fc5d24826f 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/internal/tui/components/chat/messages/tool.go @@ -27,7 +27,7 @@ import ( // ToolCallCmp defines the interface for tool call components in the chat interface. // It manages the display of tool execution including pending states, results, and errors. type ToolCallCmp interface { - util.Model // Basic Bubble Tea model interface + util.Model // Basic Bubble util.Model interface layout.Sizeable // Width/height management layout.Focusable // Focus state management GetToolCall() message.ToolCall // Access to tool call data @@ -147,7 +147,7 @@ func (m *toolCallCmp) Init() tea.Cmd { // Update handles incoming messages and updates the component state. // Manages animation updates for pending tool calls. -func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *toolCallCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { switch msg := msg.(type) { case anim.StepMsg: var cmds []tea.Cmd @@ -160,7 +160,7 @@ func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.spinning { u, cmd := m.anim.Update(msg) - m.anim = u.(util.Model) + m.anim = u cmds = append(cmds, cmd) } return m, tea.Batch(cmds...) diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go index b50a78c7f8697e4f4db19649a01794cfe7a23bac..28808e0a8e57df881263d2fb90d25dfe8d02b83b 100644 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ b/internal/tui/components/chat/sidebar/sidebar.go @@ -88,7 +88,7 @@ func (m *sidebarCmp) Init() tea.Cmd { return nil } -func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *sidebarCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { switch msg := msg.(type) { case SessionFilesMsg: m.files = csync.NewMap[string, SessionFile]() diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 187fc35e6ec47a858b99f35e135a8cef3500fbf1..e6446a981754665ba32beca48dc4a395addc5b93 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -135,7 +135,7 @@ func (s *splashCmp) SetSize(width int, height int) tea.Cmd { } // Update implements SplashPage. -func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: return s, s.SetSize(msg.Width, msg.Height) diff --git a/internal/tui/components/completions/completions.go b/internal/tui/components/completions/completions.go index 1d8a0a854197d3b7ba9e26426bde8a2679f79573..93c1b6498f418c23a17ef0738d5748e25d04a685 100644 --- a/internal/tui/components/completions/completions.go +++ b/internal/tui/components/completions/completions.go @@ -112,7 +112,7 @@ func (c *completionsCmp) Init() tea.Cmd { } // Update implements Completions. -func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (c *completionsCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: c.wWidth, c.wHeight = msg.Width, msg.Height diff --git a/internal/tui/components/core/status/status.go b/internal/tui/components/core/status/status.go index b01873a22b18f87d798757bb5a6ba799ae0e7a81..effbaac9d48c8600c2b9b0e7dce94b9bbf5b429b 100644 --- a/internal/tui/components/core/status/status.go +++ b/internal/tui/components/core/status/status.go @@ -36,7 +36,7 @@ func (m *statusCmp) Init() tea.Cmd { return nil } -func (m *statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *statusCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width diff --git a/internal/tui/components/dialogs/commands/arguments.go b/internal/tui/components/dialogs/commands/arguments.go index 72677bc934864970c2cbded87b31853ad702a6ed..66ad3f7ba06ae41fa2a4d0e033906ceda5298c22 100644 --- a/internal/tui/components/dialogs/commands/arguments.go +++ b/internal/tui/components/dialogs/commands/arguments.go @@ -92,7 +92,7 @@ func (c *commandArgumentsDialogCmp) Init() tea.Cmd { } // Update implements CommandArgumentsDialog. -func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: c.wWidth = msg.Width diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go index 664158fc392a87d8a7725bfa964748f7ef4f8e67..d05ec8fea44415ab83158849319cde62f96ef329 100644 --- a/internal/tui/components/dialogs/commands/commands.go +++ b/internal/tui/components/dialogs/commands/commands.go @@ -116,7 +116,7 @@ func (c *commandDialogCmp) Init() tea.Cmd { return c.SetCommandType(c.commandType) } -func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (c *commandDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: c.wWidth = msg.Width diff --git a/internal/tui/components/dialogs/compact/compact.go b/internal/tui/components/dialogs/compact/compact.go index ecde402fd8dfe1f31791834cd4e4bae13ec45e00..12cea72b55b4b3ad4f11c2f756ad7961ba3c8f87 100644 --- a/internal/tui/components/dialogs/compact/compact.go +++ b/internal/tui/components/dialogs/compact/compact.go @@ -61,7 +61,7 @@ func (c *compactDialogCmp) Init() tea.Cmd { return nil } -func (c *compactDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (c *compactDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: c.wWidth = msg.Width diff --git a/internal/tui/components/dialogs/dialogs.go b/internal/tui/components/dialogs/dialogs.go index 99e14e51fdd271a9cee0c27528c7608ea28fa24e..d5ad83c160e0e618e637dabe2b5e297ff0c1cd65 100644 --- a/internal/tui/components/dialogs/dialogs.go +++ b/internal/tui/components/dialogs/dialogs.go @@ -32,7 +32,7 @@ type CloseDialogMsg struct{} // DialogCmp manages a stack of dialogs with keyboard navigation. type DialogCmp interface { - tea.Model + util.Model Dialogs() []DialogModel HasDialogs() bool @@ -62,7 +62,7 @@ func (d dialogCmp) Init() tea.Cmd { } // Update handles dialog lifecycle and forwards messages to the active dialog. -func (d dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (d dialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: var cmds []tea.Cmd @@ -98,7 +98,11 @@ func (d dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return d, nil } -func (d dialogCmp) handleOpen(msg OpenDialogMsg) (tea.Model, tea.Cmd) { +func (d dialogCmp) View() string { + return "" +} + +func (d dialogCmp) handleOpen(msg OpenDialogMsg) (util.Model, tea.Cmd) { if d.HasDialogs() { dialog := d.dialogs[len(d.dialogs)-1] if dialog.ID() == msg.Model.ID() { diff --git a/internal/tui/components/dialogs/filepicker/filepicker.go b/internal/tui/components/dialogs/filepicker/filepicker.go index fcec2fc8b6e3e606e555c55949049f397a30f921..85a391ce5ceba7689148fbdcd016b73c1e100f54 100644 --- a/internal/tui/components/dialogs/filepicker/filepicker.go +++ b/internal/tui/components/dialogs/filepicker/filepicker.go @@ -88,7 +88,7 @@ func (m *model) Init() tea.Cmd { return m.filePicker.Init() } -func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *model) Update(msg tea.Msg) (util.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.wWidth = msg.Width diff --git a/internal/tui/components/dialogs/models/apikey.go b/internal/tui/components/dialogs/models/apikey.go index 0490335f9ad745839a94de0460a0fc5c1b6f125c..1c4ee0c14a77e2006d2bd43e40947b6852fa1736 100644 --- a/internal/tui/components/dialogs/models/apikey.go +++ b/internal/tui/components/dialogs/models/apikey.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/home" "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" ) @@ -75,7 +76,7 @@ func (a *APIKeyInput) Init() tea.Cmd { return a.spinner.Tick } -func (a *APIKeyInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (a *APIKeyInput) Update(msg tea.Msg) (util.Model, tea.Cmd) { switch msg := msg.(type) { case spinner.TickMsg: if a.state == APIKeyInputStateVerifying { diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go index 7c2863706c29180cffcfb88c385a012e39df464c..2e0b68cc3640c9ee5ed411eb10a07e9dc3bc0635 100644 --- a/internal/tui/components/dialogs/models/models.go +++ b/internal/tui/components/dialogs/models/models.go @@ -98,7 +98,7 @@ func (m *modelDialogCmp) Init() tea.Cmd { return tea.Batch(m.modelList.Init(), m.apiKeyInput.Init()) } -func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.wWidth = msg.Width diff --git a/internal/tui/components/dialogs/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go index 9e0a6b05d7385c354f8faba3110b1c0951f9a97d..7705edd394bd91466220326c474ea2b8ef55ffc7 100644 --- a/internal/tui/components/dialogs/permissions/permissions.go +++ b/internal/tui/components/dialogs/permissions/permissions.go @@ -95,7 +95,7 @@ func (p *permissionDialogCmp) supportsDiffView() bool { return p.permission.ToolName == tools.EditToolName || p.permission.ToolName == tools.WriteToolName || p.permission.ToolName == tools.MultiEditToolName } -func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (p *permissionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { diff --git a/internal/tui/components/dialogs/quit/quit.go b/internal/tui/components/dialogs/quit/quit.go index 763dc842d386a072176e1a26741d8b68c1e2993b..a8857104550886abc5f70956bf384ab2df6ec302 100644 --- a/internal/tui/components/dialogs/quit/quit.go +++ b/internal/tui/components/dialogs/quit/quit.go @@ -40,7 +40,7 @@ func (q *quitDialogCmp) Init() tea.Cmd { } // Update handles keyboard input for the quit dialog. -func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (q *quitDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: q.wWidth = msg.Width diff --git a/internal/tui/components/dialogs/reasoning/reasoning.go b/internal/tui/components/dialogs/reasoning/reasoning.go index ba49abd8c58a0e7eb84235e7b68f5f5193a96b1b..81f521c4bd31daa25fad5ccfb127a80ea2f20eba 100644 --- a/internal/tui/components/dialogs/reasoning/reasoning.go +++ b/internal/tui/components/dialogs/reasoning/reasoning.go @@ -172,7 +172,7 @@ func (r *reasoningDialogCmp) populateEffortOptions() tea.Cmd { return nil } -func (r *reasoningDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (r *reasoningDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: r.wWidth = msg.Width diff --git a/internal/tui/components/dialogs/sessions/sessions.go b/internal/tui/components/dialogs/sessions/sessions.go index 037eb5ebb727a24b8ab9bfda2e2c72943120e819..7f01f3ba4dacfe408fed0e8f5a2f34b39d8b2edd 100644 --- a/internal/tui/components/dialogs/sessions/sessions.go +++ b/internal/tui/components/dialogs/sessions/sessions.go @@ -81,7 +81,7 @@ func (s *sessionDialogCmp) Init() tea.Cmd { return tea.Sequence(cmds...) } -func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (s *sessionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: var cmds []tea.Cmd diff --git a/internal/tui/exp/list/filterable.go b/internal/tui/exp/list/filterable.go index d5c47b01083cdc1becaed9aac4fb8a5d3e9f3b47..b93f8cc3309f66fb957c40e0d6b25419ef51d4e7 100644 --- a/internal/tui/exp/list/filterable.go +++ b/internal/tui/exp/list/filterable.go @@ -9,6 +9,7 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" "github.com/sahilm/fuzzy" ) @@ -116,7 +117,7 @@ func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption return f } -func (f *filterableList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (f *filterableList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch { diff --git a/internal/tui/exp/list/filterable_group.go b/internal/tui/exp/list/filterable_group.go index 6e9a5dc7eaad66d32ec34baf7e41d35ab3233048..15084cce28be5190367eba861a491231139af53f 100644 --- a/internal/tui/exp/list/filterable_group.go +++ b/internal/tui/exp/list/filterable_group.go @@ -11,6 +11,7 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" "github.com/sahilm/fuzzy" ) @@ -65,7 +66,7 @@ func NewFilterableGroupedList[T FilterableItem](items []Group[T], opts ...filter return f } -func (f *filterableGroupList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (f *filterableGroupList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch { diff --git a/internal/tui/exp/list/grouped.go b/internal/tui/exp/list/grouped.go index cb54628a70e84cb80eeb162a0d9f836f14271641..43223602dbfbeaa0ae60d0368b95a4f455228f96 100644 --- a/internal/tui/exp/list/grouped.go +++ b/internal/tui/exp/list/grouped.go @@ -58,7 +58,7 @@ func (g *groupedList[T]) Init() tea.Cmd { return g.render() } -func (l *groupedList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (l *groupedList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) { u, cmd := l.list.Update(msg) l.list = u.(*list[Item]) return l, cmd diff --git a/internal/tui/exp/list/items.go b/internal/tui/exp/list/items.go index 9e7259dc10a61c95e970d9f1fc93b0d61d7a65a8..143908d5416be744424cc30965b8d663ca2a2c68 100644 --- a/internal/tui/exp/list/items.go +++ b/internal/tui/exp/list/items.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/x/ansi" "github.com/google/uuid" @@ -97,7 +98,7 @@ func (c *completionItemCmp[T]) Init() tea.Cmd { } // Update implements CommandItem. -func (c *completionItemCmp[T]) Update(tea.Msg) (tea.Model, tea.Cmd) { +func (c *completionItemCmp[T]) Update(tea.Msg) (util.Model, tea.Cmd) { return c, nil } @@ -348,7 +349,7 @@ func (m *itemSectionModel) Init() tea.Cmd { return nil } -func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) { +func (m *itemSectionModel) Update(tea.Msg) (util.Model, tea.Cmd) { return m, nil } diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index e18b88348959c59190f1741698f76c33f04571db..ea04b0c7d640f7801ba320d26071e21eafbbd90c 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -217,7 +217,7 @@ func (l *list[T]) Init() tea.Cmd { } // Update implements List. -func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (l *list[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) { switch msg := msg.(type) { case tea.MouseWheelMsg: if l.enableMouse { @@ -277,7 +277,7 @@ func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return l, nil } -func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) { +func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (util.Model, tea.Cmd) { var cmd tea.Cmd switch msg.Button { case tea.MouseWheelDown: diff --git a/internal/tui/exp/list/list_test.go b/internal/tui/exp/list/list_test.go index 63cfa599e8ce1c96aad1cae67243caa2b097ee0b..4e6d8e3110d8c585b26293b7ef1f1e80e06c8b50 100644 --- a/internal/tui/exp/list/list_test.go +++ b/internal/tui/exp/list/list_test.go @@ -7,6 +7,7 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/tui/components/core/layout" + "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/x/exp/golden" "github.com/google/uuid" @@ -602,7 +603,7 @@ func (s *simpleItem) Init() tea.Cmd { return nil } -func (s *simpleItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (s *simpleItem) Update(msg tea.Msg) (util.Model, tea.Cmd) { return s, nil } @@ -644,7 +645,7 @@ func (s *selectableItem) IsFocused() bool { return s.focused } -func execCmd(m tea.Model, cmd tea.Cmd) { +func execCmd(m util.Model, cmd tea.Cmd) { for cmd != nil { msg := cmd() m, cmd = m.Update(msg) diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 2918925068cb2f012bead47bbf44260c6255288c..1559d314d052d118019797f85eedd91a7e0f6d00 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -163,7 +163,7 @@ func (p *chatPage) Init() tea.Cmd { ) } -func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyboardEnhancementsMsg: diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 26d23f46ee62aafe07d1bb6209a4fedea929c6e1..efaf1dbb9431bc3a69fb08278b65f9b34ac281fa 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -91,8 +91,6 @@ func (a appModel) Init() tea.Cmd { cmd = a.status.Init() cmds = append(cmds, cmd) - cmds = append(cmds, tea.EnableMouseAllMotion) - return tea.Batch(cmds...) } @@ -106,9 +104,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyboardEnhancementsMsg: for id, page := range a.pages { m, pageCmd := page.Update(msg) - if model, ok := m.(util.Model); ok { - a.pages[id] = model - } + a.pages[id] = m if pageCmd != nil { cmds = append(cmds, pageCmd) @@ -232,9 +228,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Forward to view. updated, itemCmd := item.Update(msg) - if model, ok := updated.(util.Model); ok { - a.pages[a.currentPage] = model - } + a.pages[a.currentPage] = updated return a, itemCmd case pubsub.Event[permission.PermissionRequest]: @@ -292,9 +286,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.isConfigured = config.HasInitialDataConfig() updated, pageCmd := item.Update(msg) - if model, ok := updated.(util.Model); ok { - a.pages[a.currentPage] = model - } + a.pages[a.currentPage] = updated cmds = append(cmds, pageCmd) return a, tea.Batch(cmds...) @@ -314,9 +306,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } updated, pageCmd := item.Update(msg) - if model, ok := updated.(util.Model); ok { - a.pages[a.currentPage] = model - } + a.pages[a.currentPage] = updated cmds = append(cmds, pageCmd) } @@ -336,9 +326,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } updated, pageCmd := item.Update(msg) - if model, ok := updated.(util.Model); ok { - a.pages[a.currentPage] = model - } + a.pages[a.currentPage] = updated cmds = append(cmds, pageCmd) } @@ -353,9 +341,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } updated, cmd := item.Update(msg) - if model, ok := updated.(util.Model); ok { - a.pages[a.currentPage] = model - } + a.pages[a.currentPage] = updated if a.dialog.HasDialogs() { u, dialogCmd := a.dialog.Update(msg) @@ -391,9 +377,7 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd { // Update the current view. for p, page := range a.pages { updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height}) - if model, ok := updated.(util.Model); ok { - a.pages[p] = model - } + a.pages[p] = updated cmds = append(cmds, pageCmd) } @@ -496,9 +480,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { } updated, cmd := item.Update(msg) - if model, ok := updated.(util.Model); ok { - a.pages[a.currentPage] = model - } + a.pages[a.currentPage] = updated return cmd } } @@ -602,6 +584,8 @@ func (a *appModel) View() tea.View { view.Layer = canvas view.Cursor = cursor + view.MouseMode = tea.MouseModeCellMotion + view.AltScreen = true if a.app != nil && a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() { // HACK: use a random percentage to prevent ghostty from hiding it // after a timeout. diff --git a/internal/tui/util/util.go b/internal/tui/util/util.go index eb19ad89544b281af2e836f667ac63aaa6414e01..c3ce1dbf7ad94cc89def5e6a11da94b540f7b38e 100644 --- a/internal/tui/util/util.go +++ b/internal/tui/util/util.go @@ -12,8 +12,9 @@ type Cursor interface { } type Model interface { - tea.Model - tea.ViewModel + Init() tea.Cmd + Update(tea.Msg) (Model, tea.Cmd) + View() string } func CmdHandler(msg tea.Msg) tea.Cmd { From eba309cfb7b6ecac0a88828a53f64c02f91d1574 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 23 Oct 2025 16:28:36 -0300 Subject: [PATCH 88/94] fix: diagnostics log double quotting Signed-off-by: Carlos Alexandro Becker --- internal/llm/tools/diagnostics.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/llm/tools/diagnostics.go b/internal/llm/tools/diagnostics.go index c2625e9495963f1de467656b2d74e71e0b3c78fa..3418a08e5b13d3a7a795fe0a5872d4c62899ff7f 100644 --- a/internal/llm/tools/diagnostics.go +++ b/internal/llm/tools/diagnostics.go @@ -122,7 +122,7 @@ func getDiagnostics(filePath string, lsps *csync.Map[string, *lsp.Client]) strin } out := output.String() - slog.Info("Diagnostics", "output", fmt.Sprintf("%q", out)) + slog.Info("Diagnostics", "output", out) return out } From 8db8ca21304974180f8c13eafc63a8880ef03185 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Thu, 23 Oct 2025 18:50:31 -0300 Subject: [PATCH 90/94] chore(legal): @Jesssullivan 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 e71c57185db1ec3540a5082a7d9ec7daf33a3379..aee9e2879ea918718feec95f920bf36d245d78e2 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -727,6 +727,14 @@ "created_at": "2025-10-18T19:24:00Z", "repoId": 987670088, "pullRequestNo": 1254 + }, + { + "name": "Jesssullivan", + "id": 37297218, + "comment_id": 3439361465, + "created_at": "2025-10-23T21:50:17Z", + "repoId": 987670088, + "pullRequestNo": 1292 } ] } \ No newline at end of file From 67cf754b6593cbeeab34b95b624977b0bf47ed5c Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Thu, 23 Oct 2025 22:33:01 -0300 Subject: [PATCH 91/94] chore(legal): @mmangkad 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 aee9e2879ea918718feec95f920bf36d245d78e2..ffed059a5a234d727bf2946d54a0866d6b18cc72 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -735,6 +735,14 @@ "created_at": "2025-10-23T21:50:17Z", "repoId": 987670088, "pullRequestNo": 1292 + }, + { + "name": "mmangkad", + "id": 176301910, + "comment_id": 3440286180, + "created_at": "2025-10-24T01:32:47Z", + "repoId": 987670088, + "pullRequestNo": 1294 } ] } \ No newline at end of file From 9d5a6d0384d4ff8b11cface340d001433e5d570d Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 24 Oct 2025 15:53:41 -0300 Subject: [PATCH 92/94] fix(bedrock): update anthropic sdk with fix for aws sso (#1297) A fix for authentication was made on this commit: https://github.com/anthropics/anthropic-sdk-go/commit/21d6f3d9a904cc4ffcd61ecbd77cf7ae6ce7f44c --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index a8927a4b2fde67a249a81feafa49d5c8fe2c395e..27ca8d5dbfd8a6f4511390234f38bab625581db1 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/aymanbagabas/go-udiff v0.3.1 github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/charlievieth/fastwalk v1.0.14 - github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251021163913-d29170d047bf + github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.5 github.com/charmbracelet/catwalk v0.7.0 diff --git a/go.sum b/go.sum index 1d3160e24bcaf521b52c99e300e3ba221be4d656..f390f506f9b0c12d7597c3ee84efaff2ce986b02 100644 --- a/go.sum +++ b/go.sum @@ -74,8 +74,8 @@ github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMU github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICgnWlhAyg= github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY= -github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251021163913-d29170d047bf h1:toCE1GpniOr8JPJII2GH1AffivFVOzq8Rs2S0FUrkNU= -github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251021163913-d29170d047bf/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4= +github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw= +github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 h1:973OHYuq2Jx9deyuPwe/6lsuQrDCatOsjP8uCd02URE= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.5 h1:oAChAeh730gtLKK/BpaTeJHzmj3KFuEfQ7AZgf2VGHM= From c60a438a5e791144b7c241c609f900f5c2c119ac Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Fri, 24 Oct 2025 16:05:32 -0300 Subject: [PATCH 94/94] chore(legal): @blouflab 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 ffed059a5a234d727bf2946d54a0866d6b18cc72..4ba10c80f7a179932218e7d730709c22d2630212 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -743,6 +743,14 @@ "created_at": "2025-10-24T01:32:47Z", "repoId": 987670088, "pullRequestNo": 1294 + }, + { + "name": "blouflab", + "id": 227565774, + "comment_id": 3444483981, + "created_at": "2025-10-24T18:59:01Z", + "repoId": 987670088, + "pullRequestNo": 1296 } ] } \ No newline at end of file