From 5590161f86806f00bc8e4702b0c67d2abcaa9c62 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 7 Jan 2026 13:18:45 -0300 Subject: [PATCH 01/58] feat: remove claude code support (#1783) --- internal/agent/agent.go | 18 -- internal/agent/coordinator.go | 8 +- internal/cmd/login.go | 65 +---- internal/config/config.go | 41 ++- internal/config/load.go | 11 +- internal/oauth/claude/challenge.go | 28 -- internal/oauth/claude/oauth.go | 126 --------- internal/tui/components/chat/splash/splash.go | 202 +------------ .../tui/components/dialogs/claude/method.go | 115 -------- .../tui/components/dialogs/claude/oauth.go | 267 ------------------ .../tui/components/dialogs/models/keys.go | 57 ---- .../tui/components/dialogs/models/models.go | 122 -------- internal/tui/page/chat/chat.go | 52 +--- 13 files changed, 34 insertions(+), 1078 deletions(-) delete mode 100644 internal/oauth/claude/challenge.go delete mode 100644 internal/oauth/claude/oauth.go delete mode 100644 internal/tui/components/dialogs/claude/method.go delete mode 100644 internal/tui/components/dialogs/claude/oauth.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 79c8fbeecf2224712e10ddde2453459f3c3e8dc7..759a9274f2f4cc8c306ac0cc042de89cd1a25097 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -833,10 +833,6 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user modelConfig.CostPer1MIn/1e6*float64(resp.TotalUsage.InputTokens) + modelConfig.CostPer1MOut/1e6*float64(resp.TotalUsage.OutputTokens) - if a.isClaudeCode() { - cost = 0 - } - // Use override cost if available (e.g., from OpenRouter). if openrouterCost != nil { cost = *openrouterCost @@ -874,10 +870,6 @@ func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, modelConfig.CostPer1MIn/1e6*float64(usage.InputTokens) + modelConfig.CostPer1MOut/1e6*float64(usage.OutputTokens) - if a.isClaudeCode() { - cost = 0 - } - a.eventTokensUsed(session.ID, model, usage, cost) if overrideCost != nil { @@ -985,19 +977,9 @@ func (a *sessionAgent) Model() Model { } func (a *sessionAgent) promptPrefix() string { - if a.isClaudeCode() { - return "You are Claude Code, Anthropic's official CLI for Claude." - } return a.systemPromptPrefix } -// XXX: this should be generalized to cover other subscription plans, like Copilot. -func (a *sessionAgent) isClaudeCode() bool { - cfg := config.Get() - pc, ok := cfg.Providers.Get(a.largeModel.ModelCfg.Provider) - return ok && pc.ID == string(catwalk.InferenceProviderAnthropic) && pc.OAuthToken != nil -} - // convertToToolResult converts a fantasy tool result to a message tool result. func (a *sessionAgent) convertToToolResult(result fantasy.ToolResultContent) message.ToolResult { baseResult := message.ToolResult{ diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index a6ec70fbbd6aec71087c250ac8635fd4ffcc7159..b13603bb131090c86eaff3f6ea9527cb1b9dacf6 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -518,13 +518,13 @@ func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Mo }, nil } -func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string, isOauth bool) (fantasy.Provider, error) { +func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) { var opts []anthropic.Option - if isOauth { + if strings.HasPrefix(apiKey, "Bearer ") { // NOTE: Prevent the SDK from picking up the API key from env. os.Setenv("ANTHROPIC_API_KEY", "") - headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey) + headers["Authorization"] = apiKey } else if apiKey != "" { // X-Api-Key header opts = append(opts, anthropic.WithAPIKey(apiKey)) @@ -731,7 +731,7 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con case openai.Name: return c.buildOpenaiProvider(baseURL, apiKey, headers) case anthropic.Name: - return c.buildAnthropicProvider(baseURL, apiKey, headers, providerCfg.OAuthToken != nil) + return c.buildAnthropicProvider(baseURL, apiKey, headers) case openrouter.Name: return c.buildOpenrouterProvider(baseURL, apiKey, headers) case azure.Name: diff --git a/internal/cmd/login.go b/internal/cmd/login.go index 0d6c910f407e63d9a52e14878769a0381779cb46..07cc90d320ebd4817474a1a14553558caca5e950 100644 --- a/internal/cmd/login.go +++ b/internal/cmd/login.go @@ -6,14 +6,12 @@ import ( "fmt" "os" "os/signal" - "strings" "charm.land/lipgloss/v2" "github.com/atotto/clipboard" hyperp "github.com/charmbracelet/crush/internal/agent/hyper" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/oauth" - "github.com/charmbracelet/crush/internal/oauth/claude" "github.com/charmbracelet/crush/internal/oauth/copilot" "github.com/charmbracelet/crush/internal/oauth/hyper" "github.com/pkg/browser" @@ -26,21 +24,16 @@ var loginCmd = &cobra.Command{ Short: "Login Crush to a platform", Long: `Login Crush to a specified platform. The platform should be provided as an argument. -Available platforms are: hyper, claude, copilot.`, +Available platforms are: hyper, copilot.`, Example: ` # Authenticate with Charm Hyper crush login -# Authenticate with Claude Code Max -crush login claude - # Authenticate with GitHub Copilot crush login copilot `, ValidArgs: []cobra.Completion{ "hyper", - "claude", - "anthropic", "copilot", "github", "github-copilot", @@ -60,8 +53,6 @@ crush login copilot switch provider { case "hyper": return loginHyper() - case "anthropic", "claude": - return loginClaude() case "copilot", "github", "github-copilot": return loginCopilot() default: @@ -133,60 +124,6 @@ func loginHyper() error { return nil } -func loginClaude() error { - ctx := getLoginContext() - - cfg := config.Get() - if cfg.HasConfigField("providers.anthropic.oauth") { - fmt.Println("You are already logged in to Claude.") - return nil - } - - verifier, challenge, err := claude.GetChallenge() - if err != nil { - return err - } - url, err := claude.AuthorizeURL(verifier, challenge) - if err != nil { - return err - } - fmt.Println("Open the following URL and follow the instructions to authenticate with Claude Code Max:") - fmt.Println() - fmt.Println(lipgloss.NewStyle().Hyperlink(url, "id=claude").Render(url)) - fmt.Println() - fmt.Println("Press enter to continue...") - if _, err := fmt.Scanln(); err != nil { - return err - } - - fmt.Println("Now paste and code from Anthropic and press enter...") - fmt.Println() - fmt.Print("> ") - var code string - for code == "" { - _, _ = fmt.Scanln(&code) - code = strings.TrimSpace(code) - } - - fmt.Println() - fmt.Println("Exchanging authorization code...") - token, err := claude.ExchangeToken(ctx, code, verifier) - if err != nil { - return err - } - - if err := cmp.Or( - cfg.SetConfigField("providers.anthropic.api_key", token.AccessToken), - cfg.SetConfigField("providers.anthropic.oauth", token), - ); err != nil { - return err - } - - fmt.Println() - fmt.Println("You're now authenticated with Claude Code Max!") - return nil -} - func loginCopilot() error { ctx := getLoginContext() diff --git a/internal/config/config.go b/internal/config/config.go index e68ad8c27ca7e3c2313a3b18b48bcbedc3d677e9..901562420e61fe3950886bebba7ff094eb8c91b6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,7 +19,6 @@ import ( "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/env" "github.com/charmbracelet/crush/internal/oauth" - "github.com/charmbracelet/crush/internal/oauth/claude" "github.com/charmbracelet/crush/internal/oauth/copilot" "github.com/charmbracelet/crush/internal/oauth/hyper" "github.com/invopop/jsonschema" @@ -155,21 +154,6 @@ func (pc *ProviderConfig) ToProvider() catwalk.Provider { return provider } -func (pc *ProviderConfig) SetupClaudeCode() { - pc.SystemPromptPrefix = "You are Claude Code, Anthropic's official CLI for Claude." - pc.ExtraHeaders["anthropic-version"] = "2023-06-01" - - value := pc.ExtraHeaders["anthropic-beta"] - const want = "oauth-2025-04-20" - if !strings.Contains(value, want) { - if value != "" { - value += "," - } - value += want - } - pc.ExtraHeaders["anthropic-beta"] = value -} - func (pc *ProviderConfig) SetupGitHubCopilot() { maps.Copy(pc.ExtraHeaders, copilot.Headers()) } @@ -522,6 +506,25 @@ func (c *Config) SetConfigField(key string, value any) error { return nil } +func (c *Config) RemoveConfigField(key string) error { + data, err := os.ReadFile(c.dataConfigDir) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + newValue, err := sjson.Delete(string(data), key) + if err != nil { + return fmt.Errorf("failed to delete config field %s: %w", key, err) + } + if err := os.MkdirAll(filepath.Dir(c.dataConfigDir), 0o755); err != nil { + return fmt.Errorf("failed to create config directory %q: %w", c.dataConfigDir, err) + } + if err := os.WriteFile(c.dataConfigDir, []byte(newValue), 0o600); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + return nil +} + // RefreshOAuthToken refreshes the OAuth token for the given provider. func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error { providerConfig, exists := c.Providers.Get(providerID) @@ -536,8 +539,6 @@ func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error var newToken *oauth.Token var refreshErr error switch providerID { - case string(catwalk.InferenceProviderAnthropic): - newToken, refreshErr = claude.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken) case string(catwalk.InferenceProviderCopilot): newToken, refreshErr = copilot.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken) case hyperp.Name: @@ -554,8 +555,6 @@ func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error providerConfig.APIKey = newToken.AccessToken switch providerID { - case string(catwalk.InferenceProviderAnthropic): - providerConfig.SetupClaudeCode() case string(catwalk.InferenceProviderCopilot): providerConfig.SetupGitHubCopilot() } @@ -594,8 +593,6 @@ func (c *Config) SetProviderAPIKey(providerID string, apiKey any) error { providerConfig.APIKey = v.AccessToken providerConfig.OAuthToken = v switch providerID { - case string(catwalk.InferenceProviderAnthropic): - providerConfig.SetupClaudeCode() case string(catwalk.InferenceProviderCopilot): providerConfig.SetupGitHubCopilot() } diff --git a/internal/config/load.go b/internal/config/load.go index 1747e3ba8fe94700f2ca249443926491175f6f66..63904abc057e877c959991769c20f62a7ac8459a 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -202,11 +202,12 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know switch { case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil: - prepared.SetupClaudeCode() - case p.ID == catwalk.InferenceProviderCopilot: - if config.OAuthToken != nil { - prepared.SetupGitHubCopilot() - } + // Claude Code subscription is not supported anymore. Remove to show onboarding. + c.RemoveConfigField("providers.anthropic") + c.Providers.Del(string(p.ID)) + continue + case p.ID == catwalk.InferenceProviderCopilot && config.OAuthToken != nil: + prepared.SetupGitHubCopilot() } switch p.ID { diff --git a/internal/oauth/claude/challenge.go b/internal/oauth/claude/challenge.go deleted file mode 100644 index ec9ed3c5d17e91fc5dc8c33f44f3d6a4ce4aa244..0000000000000000000000000000000000000000 --- a/internal/oauth/claude/challenge.go +++ /dev/null @@ -1,28 +0,0 @@ -package claude - -import ( - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "strings" -) - -// GetChallenge generates a PKCE verifier and its corresponding challenge. -func GetChallenge() (verifier string, challenge string, err error) { - bytes := make([]byte, 32) - if _, err := rand.Read(bytes); err != nil { - return "", "", err - } - verifier = encodeBase64(bytes) - hash := sha256.Sum256([]byte(verifier)) - challenge = encodeBase64(hash[:]) - return verifier, challenge, nil -} - -func encodeBase64(input []byte) (encoded string) { - encoded = base64.StdEncoding.EncodeToString(input) - encoded = strings.ReplaceAll(encoded, "=", "") - encoded = strings.ReplaceAll(encoded, "+", "-") - encoded = strings.ReplaceAll(encoded, "/", "_") - return encoded -} diff --git a/internal/oauth/claude/oauth.go b/internal/oauth/claude/oauth.go deleted file mode 100644 index b3c47960453385395ec2b6988229d0d6e5e3eae4..0000000000000000000000000000000000000000 --- a/internal/oauth/claude/oauth.go +++ /dev/null @@ -1,126 +0,0 @@ -package claude - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" - - "github.com/charmbracelet/crush/internal/oauth" -) - -const clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" - -// AuthorizeURL returns the Claude Code Max OAuth2 authorization URL. -func AuthorizeURL(verifier, challenge string) (string, error) { - u, err := url.Parse("https://claude.ai/oauth/authorize") - if err != nil { - return "", err - } - q := u.Query() - q.Set("response_type", "code") - q.Set("client_id", clientId) - q.Set("redirect_uri", "https://console.anthropic.com/oauth/code/callback") - q.Set("scope", "org:create_api_key user:profile user:inference") - q.Set("code_challenge", challenge) - q.Set("code_challenge_method", "S256") - q.Set("state", verifier) - u.RawQuery = q.Encode() - return u.String(), nil -} - -// ExchangeToken exchanges the authorization code for an OAuth2 token. -func ExchangeToken(ctx context.Context, code, verifier string) (*oauth.Token, error) { - code = strings.TrimSpace(code) - parts := strings.SplitN(code, "#", 2) - pure := parts[0] - state := "" - if len(parts) > 1 { - state = parts[1] - } - - reqBody := map[string]string{ - "code": pure, - "state": state, - "grant_type": "authorization_code", - "client_id": clientId, - "redirect_uri": "https://console.anthropic.com/oauth/code/callback", - "code_verifier": verifier, - } - - resp, err := request(ctx, "POST", "https://console.anthropic.com/v1/oauth/token", reqBody) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("claude code max: failed to exchange token: status %d body %q", resp.StatusCode, string(body)) - } - - var token oauth.Token - if err := json.Unmarshal(body, &token); err != nil { - return nil, err - } - token.SetExpiresAt() - return &token, nil -} - -// RefreshToken refreshes the OAuth2 token using the provided refresh token. -func RefreshToken(ctx context.Context, refreshToken string) (*oauth.Token, error) { - reqBody := map[string]string{ - "grant_type": "refresh_token", - "refresh_token": refreshToken, - "client_id": clientId, - } - - resp, err := request(ctx, "POST", "https://console.anthropic.com/v1/oauth/token", reqBody) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("claude code max: failed to refresh token: status %d body %q", resp.StatusCode, string(body)) - } - - var token oauth.Token - if err := json.Unmarshal(body, &token); err != nil { - return nil, err - } - token.SetExpiresAt() - return &token, nil -} - -func request(ctx context.Context, method, url string, body any) (*http.Response, error) { - date, err := json.Marshal(body) - if err != nil { - return nil, err - } - - req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(date)) - if err != nil { - return nil, err - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", "anthropic") - - client := &http.Client{Timeout: 30 * time.Second} - return client.Do(req) -} diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 8a053294da3e342661c0db8b38cd371103c943b1..517f6d0930c46cf3d2e9f656c22515de4e9785fd 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -9,7 +9,6 @@ import ( "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "github.com/atotto/clipboard" "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/agent" hyperp "github.com/charmbracelet/crush/internal/agent/hyper" @@ -18,7 +17,6 @@ import ( "github.com/charmbracelet/crush/internal/tui/components/chat" "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude" "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot" "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper" "github.com/charmbracelet/crush/internal/tui/components/dialogs/models" @@ -47,18 +45,6 @@ type Splash interface { // IsAPIKeyValid returns whether the API key is valid IsAPIKeyValid() bool - // IsShowingClaudeAuthMethodChooser returns whether showing Claude auth method chooser - IsShowingClaudeAuthMethodChooser() bool - - // IsShowingClaudeOAuth2 returns whether showing Claude OAuth2 flow - IsShowingClaudeOAuth2() bool - - // IsClaudeOAuthURLState returns whether in OAuth URL state - IsClaudeOAuthURLState() bool - - // IsClaudeOAuthComplete returns whether Claude OAuth flow is complete - IsClaudeOAuthComplete() bool - // IsShowingClaudeOAuth2 returns whether showing Hyper OAuth2 flow IsShowingHyperOAuth2() bool @@ -103,12 +89,6 @@ type splashCmp struct { // Copilot device flow state copilotDeviceFlow *copilot.DeviceFlow showCopilotDeviceFlow bool - - // Claude state - claudeAuthMethodChooser *claude.AuthMethodChooser - claudeOAuth2 *claude.OAuth2 - showClaudeAuthMethodChooser bool - showClaudeOAuth2 bool } func New() Splash { @@ -134,9 +114,6 @@ func New() Splash { modelList: modelList, apiKeyInput: apiKeyInput, selectedNo: false, - - claudeAuthMethodChooser: claude.NewAuthMethodChooser(), - claudeOAuth2: claude.NewOAuth2(), } } @@ -158,8 +135,6 @@ func (s *splashCmp) Init() tea.Cmd { return tea.Batch( s.modelList.Init(), s.apiKeyInput.Init(), - s.claudeAuthMethodChooser.Init(), - s.claudeOAuth2.Init(), ) } @@ -176,7 +151,6 @@ func (s *splashCmp) SetSize(width int, height int) tea.Cmd { s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2 listWidth := min(60, width) s.apiKeyInput.SetWidth(width - 2) - s.claudeAuthMethodChooser.SetWidth(min(width-2, 60)) return s.modelList.SetSize(listWidth, s.listHeight) } @@ -185,24 +159,6 @@ 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) - case claude.ValidationCompletedMsg: - var cmds []tea.Cmd - u, cmd := s.claudeOAuth2.Update(msg) - s.claudeOAuth2 = u.(*claude.OAuth2) - cmds = append(cmds, cmd) - - if msg.State == claude.OAuthValidationStateValid { - cmds = append( - cmds, - s.saveAPIKeyAndContinue(msg.Token, false), - func() tea.Msg { - time.Sleep(5 * time.Second) - return claude.AuthenticationCompleteMsg{} - }, - ) - } - - return s, tea.Batch(cmds...) case hyper.DeviceFlowCompletedMsg: s.showHyperDeviceFlow = false return s, s.saveAPIKeyAndContinue(msg.Token, true) @@ -223,10 +179,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { case copilot.DeviceFlowCompletedMsg: s.showCopilotDeviceFlow = false return s, s.saveAPIKeyAndContinue(msg.Token, true) - case claude.AuthenticationCompleteMsg: - s.showClaudeAuthMethodChooser = false - s.showClaudeOAuth2 = false - return s, util.CmdHandler(OnboardingCompleteMsg{}) case models.APIKeyStateChangeMsg: u, cmd := s.apiKeyInput.Update(msg) s.apiKeyInput = u.(*models.APIKeyInput) @@ -246,34 +198,8 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { return s, s.hyperDeviceFlow.CopyCode() case key.Matches(msg, s.keyMap.Copy) && s.showCopilotDeviceFlow: return s, s.copilotDeviceFlow.CopyCode() - case key.Matches(msg, s.keyMap.Copy) && s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateURL: - return s, tea.Sequence( - tea.SetClipboard(s.claudeOAuth2.URL), - func() tea.Msg { - _ = clipboard.WriteAll(s.claudeOAuth2.URL) - return nil - }, - util.ReportInfo("URL copied to clipboard"), - ) - case key.Matches(msg, s.keyMap.Copy) && s.showClaudeAuthMethodChooser: - u, cmd := s.claudeAuthMethodChooser.Update(msg) - s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser) - return s, cmd - case key.Matches(msg, s.keyMap.Copy) && s.showClaudeOAuth2: - u, cmd := s.claudeOAuth2.Update(msg) - s.claudeOAuth2 = u.(*claude.OAuth2) - return s, cmd case key.Matches(msg, s.keyMap.Back): switch { - case s.showClaudeAuthMethodChooser: - s.claudeAuthMethodChooser.SetDefaults() - s.showClaudeAuthMethodChooser = false - return s, nil - case s.showClaudeOAuth2: - s.claudeOAuth2.SetDefaults() - s.showClaudeOAuth2 = false - s.showClaudeAuthMethodChooser = true - return s, nil case s.showHyperDeviceFlow: s.hyperDeviceFlow = nil s.showHyperDeviceFlow = false @@ -285,9 +211,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { case s.isAPIKeyValid: return s, nil case s.needsAPIKey: - if s.selectedModel.Provider.ID == catwalk.InferenceProviderAnthropic { - s.showClaudeAuthMethodChooser = true - } s.needsAPIKey = false s.selectedModel = nil s.isAPIKeyValid = false @@ -297,28 +220,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { } case key.Matches(msg, s.keyMap.Select): switch { - case s.showClaudeAuthMethodChooser: - selectedItem := s.modelList.SelectedModel() - if selectedItem == nil { - return s, nil - } - - switch s.claudeAuthMethodChooser.State { - case claude.AuthMethodAPIKey: - s.showClaudeAuthMethodChooser = false - s.needsAPIKey = true - s.selectedModel = selectedItem - s.apiKeyInput.SetProviderName(selectedItem.Provider.Name) - case claude.AuthMethodOAuth2: - s.selectedModel = selectedItem - s.showClaudeAuthMethodChooser = false - s.showClaudeOAuth2 = true - } - return s, nil - case s.showClaudeOAuth2: - m2, cmd2 := s.claudeOAuth2.ValidationConfirm() - s.claudeOAuth2 = m2.(*claude.OAuth2) - return s, cmd2 case s.showHyperDeviceFlow: return s, s.hyperDeviceFlow.CopyCodeAndOpenURL() case s.showCopilotDeviceFlow: @@ -336,9 +237,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{})) } else { switch selectedItem.Provider.ID { - case catwalk.InferenceProviderAnthropic: - s.showClaudeAuthMethodChooser = true - return s, nil case hyperp.Name: s.selectedModel = selectedItem s.showHyperDeviceFlow = true @@ -407,10 +305,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { return s, s.initializeProject() } case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight): - if s.showClaudeAuthMethodChooser { - s.claudeAuthMethodChooser.ToggleChoice() - return s, nil - } if s.needsAPIKey { u, cmd := s.apiKeyInput.Update(msg) s.apiKeyInput = u.(*models.APIKeyInput) @@ -452,14 +346,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { } default: switch { - case s.showClaudeAuthMethodChooser: - u, cmd := s.claudeAuthMethodChooser.Update(msg) - s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser) - return s, cmd - case s.showClaudeOAuth2: - u, cmd := s.claudeOAuth2.Update(msg) - s.claudeOAuth2 = u.(*claude.OAuth2) - return s, cmd case s.showHyperDeviceFlow: u, cmd := s.hyperDeviceFlow.Update(msg) s.hyperDeviceFlow = u.(*hyper.DeviceFlow) @@ -480,10 +366,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { } case tea.PasteMsg: switch { - case s.showClaudeOAuth2: - u, cmd := s.claudeOAuth2.Update(msg) - s.claudeOAuth2 = u.(*claude.OAuth2) - return s, cmd case s.showHyperDeviceFlow: u, cmd := s.hyperDeviceFlow.Update(msg) s.hyperDeviceFlow = u.(*hyper.DeviceFlow) @@ -503,10 +385,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { } case spinner.TickMsg: switch { - case s.showClaudeOAuth2: - u, cmd := s.claudeOAuth2.Update(msg) - s.claudeOAuth2 = u.(*claude.OAuth2) - return s, cmd case s.showHyperDeviceFlow: u, cmd := s.hyperDeviceFlow.Update(msg) s.hyperDeviceFlow = u.(*hyper.DeviceFlow) @@ -655,38 +533,6 @@ func (s *splashCmp) View() string { var content string switch { - case s.showClaudeAuthMethodChooser: - remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY - chooserView := s.claudeAuthMethodChooser.View() - authMethodSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( - lipgloss.JoinVertical( - lipgloss.Left, - t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Anthropic"), - "", - chooserView, - ), - ) - content = lipgloss.JoinVertical( - lipgloss.Left, - s.logoRendered, - authMethodSelector, - ) - case s.showClaudeOAuth2: - remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY - oauth2View := s.claudeOAuth2.View() - oauthSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( - lipgloss.JoinVertical( - lipgloss.Left, - t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Anthropic"), - "", - oauth2View, - ), - ) - content = lipgloss.JoinVertical( - lipgloss.Left, - s.logoRendered, - oauthSelector, - ) case s.showHyperDeviceFlow: remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY hyperView := s.hyperDeviceFlow.View() @@ -816,14 +662,6 @@ func (s *splashCmp) View() string { func (s *splashCmp) Cursor() *tea.Cursor { switch { - case s.showClaudeAuthMethodChooser: - return nil - case s.showClaudeOAuth2: - if cursor := s.claudeOAuth2.CodeInput.Cursor(); cursor != nil { - cursor.Y += 2 // FIXME(@andreynering): Why do we need this? - return s.moveCursor(cursor) - } - return nil case s.needsAPIKey: cursor := s.apiKeyInput.Cursor() if cursor != nil { @@ -894,16 +732,10 @@ func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { } // Calculate the correct Y offset based on current state logoHeight := lipgloss.Height(s.logoRendered) - if s.needsAPIKey || s.showClaudeOAuth2 { - var view string - if s.needsAPIKey { - view = s.apiKeyInput.View() - } else { - view = s.claudeOAuth2.View() - } + if s.needsAPIKey { infoSectionHeight := lipgloss.Height(s.infoSection()) baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight - remainingHeight := s.height - baseOffset - lipgloss.Height(view) - SplashScreenPaddingY + remainingHeight := s.height - baseOffset - lipgloss.Height(s.apiKeyInput.View()) - SplashScreenPaddingY offset := baseOffset + remainingHeight cursor.Y += offset cursor.X += 1 @@ -926,20 +758,6 @@ func (s *splashCmp) logoGap() int { // Bindings implements SplashPage. func (s *splashCmp) Bindings() []key.Binding { switch { - case s.showClaudeAuthMethodChooser: - return []key.Binding{ - s.keyMap.Select, - s.keyMap.Tab, - s.keyMap.Back, - } - case s.showClaudeOAuth2: - bindings := []key.Binding{ - s.keyMap.Select, - } - if s.claudeOAuth2.State == claude.OAuthStateURL { - bindings = append(bindings, s.keyMap.Copy) - } - return bindings case s.needsAPIKey: return []key.Binding{ s.keyMap.Select, @@ -1047,22 +865,6 @@ func (s *splashCmp) IsAPIKeyValid() bool { return s.isAPIKeyValid } -func (s *splashCmp) IsShowingClaudeAuthMethodChooser() bool { - return s.showClaudeAuthMethodChooser -} - -func (s *splashCmp) IsShowingClaudeOAuth2() bool { - return s.showClaudeOAuth2 -} - -func (s *splashCmp) IsClaudeOAuthURLState() bool { - return s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateURL -} - -func (s *splashCmp) IsClaudeOAuthComplete() bool { - return s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateCode && s.claudeOAuth2.ValidationState == claude.OAuthValidationStateValid -} - func (s *splashCmp) IsShowingHyperOAuth2() bool { return s.showHyperDeviceFlow } diff --git a/internal/tui/components/dialogs/claude/method.go b/internal/tui/components/dialogs/claude/method.go deleted file mode 100644 index 071d437799dcd2e3d5b9e60c33c7173c18577016..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/claude/method.go +++ /dev/null @@ -1,115 +0,0 @@ -package claude - -import ( - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" -) - -type AuthMethod int - -const ( - AuthMethodAPIKey AuthMethod = iota - AuthMethodOAuth2 -) - -type AuthMethodChooser struct { - State AuthMethod - width int - isOnboarding bool -} - -func NewAuthMethodChooser() *AuthMethodChooser { - return &AuthMethodChooser{ - State: AuthMethodOAuth2, - } -} - -func (a *AuthMethodChooser) Init() tea.Cmd { - return nil -} - -func (a *AuthMethodChooser) Update(msg tea.Msg) (util.Model, tea.Cmd) { - return a, nil -} - -func (a *AuthMethodChooser) View() string { - t := styles.CurrentTheme() - - white := lipgloss.NewStyle().Foreground(t.White) - primary := lipgloss.NewStyle().Foreground(t.Primary) - success := lipgloss.NewStyle().Foreground(t.Success) - - titleStyle := white - if a.isOnboarding { - titleStyle = primary - } - - question := lipgloss. - NewStyle(). - Margin(0, 1). - Render(titleStyle.Render("How would you like to authenticate with ") + success.Render("Anthropic") + titleStyle.Render("?")) - - squareWidth := (a.width - 2) / 2 - squareHeight := squareWidth / 3 - if isOdd(squareHeight) { - squareHeight++ - } - - square := lipgloss.NewStyle(). - Width(squareWidth). - Height(squareHeight). - Margin(0, 0). - Border(lipgloss.RoundedBorder()) - - squareText := lipgloss.NewStyle(). - Width(squareWidth - 2). - Height(squareHeight). - Align(lipgloss.Center). - AlignVertical(lipgloss.Center) - - oauthBorder := t.AuthBorderSelected - oauthText := t.AuthTextSelected - apiKeyBorder := t.AuthBorderUnselected - apiKeyText := t.AuthTextUnselected - - if a.State == AuthMethodAPIKey { - oauthBorder, apiKeyBorder = apiKeyBorder, oauthBorder - oauthText, apiKeyText = apiKeyText, oauthText - } - - return lipgloss.JoinVertical( - lipgloss.Left, - question, - "", - lipgloss.JoinHorizontal( - lipgloss.Center, - square.MarginLeft(1). - Inherit(oauthBorder).Render(squareText.Inherit(oauthText).Render("Claude Account\nwith Subscription")), - square.MarginRight(1). - Inherit(apiKeyBorder).Render(squareText.Inherit(apiKeyText).Render("API Key")), - ), - ) -} - -func (a *AuthMethodChooser) SetDefaults() { - a.State = AuthMethodOAuth2 -} - -func (a *AuthMethodChooser) SetWidth(w int) { - a.width = w -} - -func (a *AuthMethodChooser) ToggleChoice() { - switch a.State { - case AuthMethodAPIKey: - a.State = AuthMethodOAuth2 - case AuthMethodOAuth2: - a.State = AuthMethodAPIKey - } -} - -func isOdd(n int) bool { - return n%2 != 0 -} diff --git a/internal/tui/components/dialogs/claude/oauth.go b/internal/tui/components/dialogs/claude/oauth.go deleted file mode 100644 index f8da5b4fffbc75708676a1545f9a6719b7e2f198..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/claude/oauth.go +++ /dev/null @@ -1,267 +0,0 @@ -package claude - -import ( - "context" - "fmt" - "net/url" - - "charm.land/bubbles/v2/spinner" - "charm.land/bubbles/v2/textinput" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/oauth" - "github.com/charmbracelet/crush/internal/oauth/claude" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/pkg/browser" - "github.com/zeebo/xxh3" -) - -type OAuthState int - -const ( - OAuthStateURL OAuthState = iota - OAuthStateCode -) - -type OAuthValidationState int - -const ( - OAuthValidationStateNone OAuthValidationState = iota - OAuthValidationStateVerifying - OAuthValidationStateValid - OAuthValidationStateError -) - -type ValidationCompletedMsg struct { - State OAuthValidationState - Token *oauth.Token -} - -type AuthenticationCompleteMsg struct{} - -type OAuth2 struct { - State OAuthState - ValidationState OAuthValidationState - width int - isOnboarding bool - - // URL page - err error - verifier string - challenge string - URL string - urlId string - token *oauth.Token - - // Code input page - CodeInput textinput.Model - spinner spinner.Model -} - -func NewOAuth2() *OAuth2 { - return &OAuth2{ - State: OAuthStateURL, - } -} - -func (o *OAuth2) Init() tea.Cmd { - t := styles.CurrentTheme() - - verifier, challenge, err := claude.GetChallenge() - if err != nil { - o.err = err - return nil - } - - url, err := claude.AuthorizeURL(verifier, challenge) - if err != nil { - o.err = err - return nil - } - - o.verifier = verifier - o.challenge = challenge - o.URL = url - - h := xxh3.New() - _, _ = h.WriteString(o.URL) - o.urlId = fmt.Sprintf("id=%x", h.Sum(nil)) - - o.CodeInput = textinput.New() - o.CodeInput.Placeholder = "Paste or type" - o.CodeInput.SetVirtualCursor(false) - o.CodeInput.Prompt = "> " - o.CodeInput.SetStyles(t.S().TextInput) - o.CodeInput.SetWidth(50) - - o.spinner = spinner.New( - spinner.WithSpinner(spinner.Dot), - spinner.WithStyle(t.S().Base.Foreground(t.Green)), - ) - - return nil -} - -func (o *OAuth2) Update(msg tea.Msg) (util.Model, tea.Cmd) { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case ValidationCompletedMsg: - o.ValidationState = msg.State - o.token = msg.Token - switch o.ValidationState { - case OAuthValidationStateError: - o.CodeInput.Focus() - } - o.updatePrompt() - } - - if o.ValidationState == OAuthValidationStateVerifying { - var cmd tea.Cmd - o.spinner, cmd = o.spinner.Update(msg) - cmds = append(cmds, cmd) - o.updatePrompt() - } - { - var cmd tea.Cmd - o.CodeInput, cmd = o.CodeInput.Update(msg) - cmds = append(cmds, cmd) - } - - return o, tea.Batch(cmds...) -} - -func (o *OAuth2) ValidationConfirm() (util.Model, tea.Cmd) { - var cmds []tea.Cmd - - switch { - case o.State == OAuthStateURL: - _ = browser.OpenURL(o.URL) - o.State = OAuthStateCode - cmds = append(cmds, o.CodeInput.Focus()) - case o.ValidationState == OAuthValidationStateNone || o.ValidationState == OAuthValidationStateError: - o.CodeInput.Blur() - o.ValidationState = OAuthValidationStateVerifying - cmds = append(cmds, o.spinner.Tick, o.validateCode) - case o.ValidationState == OAuthValidationStateValid: - cmds = append(cmds, func() tea.Msg { return AuthenticationCompleteMsg{} }) - } - - o.updatePrompt() - return o, tea.Batch(cmds...) -} - -func (o *OAuth2) View() string { - t := styles.CurrentTheme() - - whiteStyle := lipgloss.NewStyle().Foreground(t.White) - primaryStyle := lipgloss.NewStyle().Foreground(t.Primary) - successStyle := lipgloss.NewStyle().Foreground(t.Success) - errorStyle := lipgloss.NewStyle().Foreground(t.Error) - - titleStyle := whiteStyle - if o.isOnboarding { - titleStyle = primaryStyle - } - - switch { - case o.err != nil: - return lipgloss.NewStyle(). - Margin(0, 1). - Foreground(t.Error). - Render(o.err.Error()) - case o.State == OAuthStateURL: - heading := lipgloss. - NewStyle(). - Margin(0, 1). - Render(titleStyle.Render("Press enter key to open the following ") + successStyle.Render("URL") + titleStyle.Render(":")) - - return lipgloss.JoinVertical( - lipgloss.Left, - heading, - "", - lipgloss.NewStyle(). - Margin(0, 1). - Foreground(t.FgMuted). - Hyperlink(o.URL, o.urlId). - Render(o.displayUrl()), - ) - case o.State == OAuthStateCode: - var heading string - - switch o.ValidationState { - case OAuthValidationStateNone: - st := lipgloss.NewStyle().Margin(0, 1) - heading = st.Render(titleStyle.Render("Enter the ") + successStyle.Render("code") + titleStyle.Render(" you received.")) - case OAuthValidationStateVerifying: - heading = titleStyle.Margin(0, 1).Render("Verifying...") - case OAuthValidationStateValid: - heading = successStyle.Margin(0, 1).Render("Validated.") - case OAuthValidationStateError: - heading = errorStyle.Margin(0, 1).Render("Invalid. Try again?") - } - - return lipgloss.JoinVertical( - lipgloss.Left, - heading, - "", - " "+o.CodeInput.View(), - ) - default: - panic("claude oauth2: invalid state") - } -} - -func (o *OAuth2) SetDefaults() { - o.State = OAuthStateURL - o.ValidationState = OAuthValidationStateNone - o.CodeInput.SetValue("") - o.err = nil -} - -func (o *OAuth2) SetWidth(w int) { - o.width = w - o.CodeInput.SetWidth(w - 4) -} - -func (o *OAuth2) SetError(err error) { - o.err = err -} - -func (o *OAuth2) validateCode() tea.Msg { - token, err := claude.ExchangeToken(context.Background(), o.CodeInput.Value(), o.verifier) - if err != nil || token == nil { - return ValidationCompletedMsg{State: OAuthValidationStateError} - } - return ValidationCompletedMsg{State: OAuthValidationStateValid, Token: token} -} - -func (o *OAuth2) updatePrompt() { - switch o.ValidationState { - case OAuthValidationStateNone: - o.CodeInput.Prompt = "> " - case OAuthValidationStateVerifying: - o.CodeInput.Prompt = o.spinner.View() + " " - case OAuthValidationStateValid: - o.CodeInput.Prompt = styles.CheckIcon + " " - case OAuthValidationStateError: - o.CodeInput.Prompt = styles.ErrorIcon + " " - } -} - -// Remove query params for display -// e.g., "https://claude.ai/oauth/authorize?..." -> "https://claude.ai/oauth/authorize..." -func (o *OAuth2) displayUrl() string { - parsed, err := url.Parse(o.URL) - if err != nil { - return o.URL - } - - if parsed.RawQuery != "" { - parsed.RawQuery = "" - return parsed.String() + "..." - } - - return o.URL -} diff --git a/internal/tui/components/dialogs/models/keys.go b/internal/tui/components/dialogs/models/keys.go index eda235aebb858fef21c582921cfb9e305a6fed19..ff81404b1f1937fff09d917bf3a9e3b24f4d38c9 100644 --- a/internal/tui/components/dialogs/models/keys.go +++ b/internal/tui/components/dialogs/models/keys.go @@ -18,11 +18,6 @@ type KeyMap struct { isHyperDeviceFlow bool isCopilotDeviceFlow bool isCopilotUnavailable bool - - isClaudeAuthChoiceHelp bool - isClaudeOAuthHelp bool - isClaudeOAuthURLState bool - isClaudeOAuthHelpComplete bool } func DefaultKeyMap() KeyMap { @@ -100,58 +95,6 @@ func (k KeyMap) ShortHelp() []key.Binding { k.Close, } } - if k.isClaudeAuthChoiceHelp { - return []key.Binding{ - key.NewBinding( - key.WithKeys("left", "right", "h", "l"), - key.WithHelp("←→", "choose"), - ), - key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "accept"), - ), - key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "back"), - ), - } - } - if k.isClaudeOAuthHelp { - if k.isClaudeOAuthHelpComplete { - return []key.Binding{ - key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "close"), - ), - } - } - - enterHelp := "submit" - if k.isClaudeOAuthURLState { - enterHelp = "open" - } - - bindings := []key.Binding{ - key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", enterHelp), - ), - } - - if k.isClaudeOAuthURLState { - bindings = append(bindings, key.NewBinding( - key.WithKeys("c"), - key.WithHelp("c", "copy url"), - )) - } - - bindings = append(bindings, key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "back"), - )) - - return bindings - } if k.isAPIKeyHelp && !k.isAPIKeyValid { return []key.Binding{ key.NewBinding( diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go index afca44ecd5e64e42e3b375311d3c5ff8efaedd5b..b06b4b475a9ababbda9e0702fc5552b0959741ba 100644 --- a/internal/tui/components/dialogs/models/models.go +++ b/internal/tui/components/dialogs/models/models.go @@ -10,13 +10,11 @@ import ( "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "github.com/atotto/clipboard" "github.com/charmbracelet/catwalk/pkg/catwalk" hyperp "github.com/charmbracelet/crush/internal/agent/hyper" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude" "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot" "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper" "github.com/charmbracelet/crush/internal/tui/exp/list" @@ -81,12 +79,6 @@ type modelDialogCmp struct { // Copilot device flow state copilotDeviceFlow *copilot.DeviceFlow showCopilotDeviceFlow bool - - // Claude state - claudeAuthMethodChooser *claude.AuthMethodChooser - claudeOAuth2 *claude.OAuth2 - showClaudeAuthMethodChooser bool - showClaudeOAuth2 bool } func NewModelDialogCmp() ModelDialog { @@ -111,9 +103,6 @@ func NewModelDialogCmp() ModelDialog { width: defaultWidth, keyMap: DefaultKeyMap(), help: help, - - claudeAuthMethodChooser: claude.NewAuthMethodChooser(), - claudeOAuth2: claude.NewOAuth2(), } } @@ -121,8 +110,6 @@ func (m *modelDialogCmp) Init() tea.Cmd { return tea.Batch( m.modelList.Init(), m.apiKeyInput.Init(), - m.claudeAuthMethodChooser.Init(), - m.claudeOAuth2.Init(), ) } @@ -133,7 +120,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { m.wHeight = msg.Height m.apiKeyInput.SetWidth(m.width - 2) m.help.SetWidth(m.width - 2) - m.claudeAuthMethodChooser.SetWidth(m.width - 2) return m, m.modelList.SetSize(m.listWidth(), m.listHeight()) case APIKeyStateChangeMsg: u, cmd := m.apiKeyInput.Update(msg) @@ -157,20 +143,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { return m, nil case copilot.DeviceFlowCompletedMsg: return m, m.saveOauthTokenAndContinue(msg.Token, true) - case claude.ValidationCompletedMsg: - var cmds []tea.Cmd - u, cmd := m.claudeOAuth2.Update(msg) - m.claudeOAuth2 = u.(*claude.OAuth2) - cmds = append(cmds, cmd) - - if msg.State == claude.OAuthValidationStateValid { - cmds = append(cmds, m.saveOauthTokenAndContinue(msg.Token, false)) - m.keyMap.isClaudeOAuthHelpComplete = true - } - - return m, tea.Batch(cmds...) - case claude.AuthenticationCompleteMsg: - return m, util.CmdHandler(dialogs.CloseDialogMsg{}) case tea.KeyPressMsg: switch { // Handle Hyper device flow keys @@ -178,18 +150,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { return m, m.hyperDeviceFlow.CopyCode() case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showCopilotDeviceFlow: return m, m.copilotDeviceFlow.CopyCode() - case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showClaudeOAuth2 && m.claudeOAuth2.State == claude.OAuthStateURL: - return m, tea.Sequence( - tea.SetClipboard(m.claudeOAuth2.URL), - func() tea.Msg { - _ = clipboard.WriteAll(m.claudeOAuth2.URL) - return nil - }, - util.ReportInfo("URL copied to clipboard"), - ) - case key.Matches(msg, m.keyMap.Choose) && m.showClaudeAuthMethodChooser: - m.claudeAuthMethodChooser.ToggleChoice() - return m, nil case key.Matches(msg, m.keyMap.Select): // If showing device flow, enter copies code and opens URL if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil { @@ -209,37 +169,15 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { } askForApiKey := func() { - m.keyMap.isClaudeAuthChoiceHelp = false - m.keyMap.isClaudeOAuthHelp = false m.keyMap.isAPIKeyHelp = true m.showHyperDeviceFlow = false m.showCopilotDeviceFlow = false - m.showClaudeAuthMethodChooser = false m.needsAPIKey = true m.selectedModel = selectedItem m.selectedModelType = modelType m.apiKeyInput.SetProviderName(selectedItem.Provider.Name) } - if m.showClaudeAuthMethodChooser { - switch m.claudeAuthMethodChooser.State { - case claude.AuthMethodAPIKey: - askForApiKey() - case claude.AuthMethodOAuth2: - m.selectedModel = selectedItem - m.selectedModelType = modelType - m.showClaudeAuthMethodChooser = false - m.showClaudeOAuth2 = true - m.keyMap.isClaudeAuthChoiceHelp = false - m.keyMap.isClaudeOAuthHelp = true - } - return m, nil - } - if m.showClaudeOAuth2 { - m2, cmd2 := m.claudeOAuth2.ValidationConfirm() - m.claudeOAuth2 = m2.(*claude.OAuth2) - return m, cmd2 - } if m.isAPIKeyValid { return m, m.saveOauthTokenAndContinue(m.apiKeyValue, true) } @@ -298,10 +236,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { ) } switch selectedItem.Provider.ID { - case catwalk.InferenceProviderAnthropic: - m.showClaudeAuthMethodChooser = true - m.keyMap.isClaudeAuthChoiceHelp = true - return m, nil case hyperp.Name: m.showHyperDeviceFlow = true m.selectedModel = selectedItem @@ -327,9 +261,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { return m, nil case key.Matches(msg, m.keyMap.Tab): switch { - case m.showClaudeAuthMethodChooser: - m.claudeAuthMethodChooser.ToggleChoice() - return m, nil case m.needsAPIKey: u, cmd := m.apiKeyInput.Update(msg) m.apiKeyInput = u.(*APIKeyInput) @@ -355,12 +286,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { } m.showCopilotDeviceFlow = false m.selectedModel = nil - case m.showClaudeAuthMethodChooser: - m.claudeAuthMethodChooser.SetDefaults() - m.showClaudeAuthMethodChooser = false - m.keyMap.isClaudeAuthChoiceHelp = false - m.keyMap.isClaudeOAuthHelp = false - return m, nil case m.needsAPIKey: if m.isAPIKeyValid { return m, nil @@ -377,14 +302,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { } default: switch { - case m.showClaudeAuthMethodChooser: - u, cmd := m.claudeAuthMethodChooser.Update(msg) - m.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser) - return m, cmd - case m.showClaudeOAuth2: - u, cmd := m.claudeOAuth2.Update(msg) - m.claudeOAuth2 = u.(*claude.OAuth2) - return m, cmd case m.needsAPIKey: u, cmd := m.apiKeyInput.Update(msg) m.apiKeyInput = u.(*APIKeyInput) @@ -397,10 +314,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { } case tea.PasteMsg: switch { - case m.showClaudeOAuth2: - u, cmd := m.claudeOAuth2.Update(msg) - m.claudeOAuth2 = u.(*claude.OAuth2) - return m, cmd case m.needsAPIKey: u, cmd := m.apiKeyInput.Update(msg) m.apiKeyInput = u.(*APIKeyInput) @@ -433,10 +346,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { u, cmd := m.copilotDeviceFlow.Update(msg) m.copilotDeviceFlow = u.(*copilot.DeviceFlow) return m, cmd - case m.showClaudeOAuth2: - u, cmd := m.claudeOAuth2.Update(msg) - m.claudeOAuth2 = u.(*claude.OAuth2) - return m, cmd default: u, cmd := m.apiKeyInput.Update(msg) m.apiKeyInput = u.(*APIKeyInput) @@ -483,27 +392,6 @@ func (m *modelDialogCmp) View() string { m.keyMap.isCopilotUnavailable = false switch { - case m.showClaudeAuthMethodChooser: - chooserView := m.claudeAuthMethodChooser.View() - content := lipgloss.JoinVertical( - lipgloss.Left, - t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Let's Auth Anthropic", m.width-4)), - chooserView, - "", - t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)), - ) - return m.style().Render(content) - case m.showClaudeOAuth2: - m.keyMap.isClaudeOAuthURLState = m.claudeOAuth2.State == claude.OAuthStateURL - oauth2View := m.claudeOAuth2.View() - content := lipgloss.JoinVertical( - lipgloss.Left, - t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Let's Auth Anthropic", m.width-4)), - oauth2View, - "", - t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)), - ) - return m.style().Render(content) case m.needsAPIKey: // Show API key input m.keyMap.isAPIKeyHelp = true @@ -540,16 +428,6 @@ func (m *modelDialogCmp) Cursor() *tea.Cursor { if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil { return m.copilotDeviceFlow.Cursor() } - if m.showClaudeAuthMethodChooser { - return nil - } - if m.showClaudeOAuth2 { - if cursor := m.claudeOAuth2.CodeInput.Cursor(); cursor != nil { - cursor.Y += 2 // FIXME(@andreynering): Why do we need this? - return m.moveCursor(cursor) - } - return nil - } if m.needsAPIKey { cursor := m.apiKeyInput.Cursor() if cursor != nil { diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index d86e60c8cdcb0f6d87b7c97a6e40e83bddffeace..9a4b69f5507fbb62b7ee93df6326f94cf79d22ad 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -29,7 +29,6 @@ import ( "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude" "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot" "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" @@ -337,9 +336,7 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) { cmds = append(cmds, cmd) return p, tea.Batch(cmds...) - case claude.ValidationCompletedMsg, - claude.AuthenticationCompleteMsg, - hyper.DeviceFlowCompletedMsg, + case hyper.DeviceFlowCompletedMsg, hyper.DeviceAuthInitiatedMsg, hyper.DeviceFlowErrorMsg, copilot.DeviceAuthInitiatedMsg, @@ -1037,53 +1034,8 @@ func (p *chatPage) Help() help.KeyMap { var shortList []key.Binding var fullList [][]key.Binding switch { - case p.isOnboarding && p.splash.IsShowingClaudeAuthMethodChooser(): - shortList = append(shortList, - // Choose auth method - key.NewBinding( - key.WithKeys("left", "right", "tab"), - key.WithHelp("←→/tab", "choose"), - ), - // Accept selection - key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "accept"), - ), - // Go back - key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "back"), - ), - // Quit - key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "quit"), - ), - ) - // keep them the same - for _, v := range shortList { - fullList = append(fullList, []key.Binding{v}) - } - case p.isOnboarding && p.splash.IsShowingClaudeOAuth2(): + case p.isOnboarding: switch { - case p.splash.IsClaudeOAuthURLState(): - shortList = append(shortList, - key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "open"), - ), - key.NewBinding( - key.WithKeys("c"), - key.WithHelp("c", "copy url"), - ), - ) - case p.splash.IsClaudeOAuthComplete(): - shortList = append(shortList, - key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "continue"), - ), - ) case p.splash.IsShowingHyperOAuth2() || p.splash.IsShowingCopilotOAuth2(): shortList = append(shortList, key.NewBinding( From a4f5d722761e9d3a8e90c5d9c3662b57ada6fe71 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 7 Jan 2026 12:24:46 -0500 Subject: [PATCH 03/58] docs(README): add FreeBSD installation instructions --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4d876ef648b8237a4e2a172c23acfe5e05ec386b..4135ddea8c6209f05989e64ddc356a2244efbb03 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,9 @@ yay -S crush-bin # Nix nix run github:numtide/nix-ai-tools#crush + +# FreeBSD +pkg install crush ``` Windows users: @@ -52,9 +55,9 @@ scoop install crush
Nix (NUR) -Crush is available via [NUR](https://github.com/nix-community/NUR) in `nur.repos.charmbracelet.crush`. +Crush is available via the offical Charm [NUR](https://github.com/nix-community/NUR) in `nur.repos.charmbracelet.crush`, which is the most up-to-date way to get Crush in Nix. -You can also try out Crush via `nix-shell`: +You can also try out Crush via the NUR with `nix-shell`: ```bash # Add the NUR channel. From 666fabd1cfeffdb9d8d2b0a2d42aa876e52a45a7 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:10:00 -0300 Subject: [PATCH 04/58] chore(legal): @mohaanymo 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 7ab467b0f9ea6d6f052e7acb7b35b43a59bc6e49..2d61c78213aef0bafa4066451f321487263e6e6e 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1007,6 +1007,14 @@ "created_at": "2026-01-01T21:00:07Z", "repoId": 987670088, "pullRequestNo": 1748 + }, + { + "name": "mohaanymo", + "id": 244024658, + "comment_id": 3725028621, + "created_at": "2026-01-08T18:01:11Z", + "repoId": 987670088, + "pullRequestNo": 1799 } ] } \ No newline at end of file From 15de35288a79a2b4d979e6f81663c81a83a749cf Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Thu, 8 Jan 2026 18:44:14 -0300 Subject: [PATCH 05/58] chore(legal): @zyriab 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 2d61c78213aef0bafa4066451f321487263e6e6e..e7e6a072edd662f7a22223846d31347ed10f405e 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1015,6 +1015,14 @@ "created_at": "2026-01-08T18:01:11Z", "repoId": 987670088, "pullRequestNo": 1799 + }, + { + "name": "zyriab", + "id": 2111910, + "comment_id": 3725966281, + "created_at": "2026-01-08T21:44:05Z", + "repoId": 987670088, + "pullRequestNo": 1801 } ] } \ No newline at end of file From 467418d5346b5c253dc141ef44d60ca3e1f1f6f8 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 9 Jan 2026 08:44:02 -0300 Subject: [PATCH 06/58] feat: open editor in the right position (#1804) Signed-off-by: Carlos Alexandro Becker --- go.mod | 3 ++- go.sum | 6 +++-- internal/tui/components/chat/editor/editor.go | 27 +++++++++---------- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/go.mod b/go.mod index 8877336bd0a42158b2a96b3c34c6544d190f2151..a5fe985d15007fcf5176ca4e3c9fadb0095a0905 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/charmbracelet/crush go 1.25.5 require ( - charm.land/bubbles/v2 v2.0.0-rc.1 + charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66 charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e charm.land/fantasy v0.6.0 charm.land/glamour/v2 v2.0.0-20251110203732-69649f93d3b1 @@ -23,6 +23,7 @@ require ( github.com/charmbracelet/fang v0.4.4 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560 github.com/charmbracelet/x/ansi v0.11.3 + github.com/charmbracelet/x/editor v0.2.0 github.com/charmbracelet/x/etag v0.2.0 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f diff --git a/go.sum b/go.sum index 29ac00482dedaeb5f7444810b7194a367c6f67a9..3131993a7dd1473522938031ebb2be23b906e061 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -charm.land/bubbles/v2 v2.0.0-rc.1 h1:EiIFVAc3Zi/yY86td+79mPhHR7AqZ1OxF+6ztpOCRaM= -charm.land/bubbles/v2 v2.0.0-rc.1/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4= +charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66 h1:2BdJynsAW+8rv9xq6ZS+x0mtacfxpxjIK1KUIeTqBOs= +charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4= charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e h1:tXwTmgGpwZT7ParKF5xbEQBVjM2e1uKhKi/GpfU3mYQ= charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e/go.mod h1:pDM18flq3Z4njKZPA3zCvyVSSIJbMcoqlE82BdGUtL8= charm.land/fantasy v0.6.0 h1:0PZfZ/w6c70UdlumGGFW6s9zTV6f4xAV/bXo6vGuZsc= @@ -104,6 +104,8 @@ github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560 h1:j3PW2 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560/go.mod h1:VWATWLRwYP06VYCEur7FsNR2B1xAo7Y+xl1PTbd1ePc= github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI= github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI= +github.com/charmbracelet/x/editor v0.2.0 h1:7XLUKtaRaB8jN7bWU2p2UChiySyaAuIfYiIRg8gGWwk= +github.com/charmbracelet/x/editor v0.2.0/go.mod h1:p3oQ28TSL3YPd+GKJ1fHWcp+7bVGpedHpXmo0D6t1dY= github.com/charmbracelet/x/etag v0.2.0 h1:Euj1VkheoHfTYA9y+TCwkeXF/hN8Fb9l4LqZl79pt04= github.com/charmbracelet/x/etag v0.2.0/go.mod h1:C1B7/bsgvzzxpfu0Rabbd+rTHJa5TmC/qgTseCf6DF0= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1xwHZg6eMZ9Wv5TE1UGub6ARubyOd1Lo5kPUI/6VL50= diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 972824be0599fb37651f8b607a90114387a73f3c..8f9b326b9f941bb99cfbaad992830c3173ea41c4 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -1,14 +1,12 @@ package editor import ( - "context" "errors" "fmt" "math/rand" "net/http" "os" "path/filepath" - "runtime" "slices" "strings" "unicode" @@ -32,6 +30,7 @@ import ( "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/editor" ) type Editor interface { @@ -94,16 +93,6 @@ type OpenEditorMsg struct { } func (m *editorCmp) openEditor(value string) tea.Cmd { - editor := os.Getenv("EDITOR") - if editor == "" { - // Use platform-appropriate default editor - if runtime.GOOS == "windows" { - editor = "notepad" - } else { - editor = "nvim" - } - } - tmpfile, err := os.CreateTemp("", "msg_*.md") if err != nil { return util.ReportError(err) @@ -112,8 +101,18 @@ func (m *editorCmp) openEditor(value string) tea.Cmd { if _, err := tmpfile.WriteString(value); err != nil { return util.ReportError(err) } - cmdStr := editor + " " + tmpfile.Name() - return util.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg { + cmd, err := editor.Command( + "crush", + tmpfile.Name(), + editor.AtPosition( + m.textarea.Line()+1, + m.textarea.Column()+1, + ), + ) + if err != nil { + return util.ReportError(err) + } + return tea.ExecProcess(cmd, func(err error) tea.Msg { if err != nil { return util.ReportError(err) } From 23c6e43b2a4f5b3d86cc8c4fffef41ce81d6baf4 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:28:30 -0300 Subject: [PATCH 07/58] chore(legal): @aleksclark 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 e7e6a072edd662f7a22223846d31347ed10f405e..a18c4ccb7d31b709a1f19ababb62843a5a924349 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1023,6 +1023,14 @@ "created_at": "2026-01-08T21:44:05Z", "repoId": 987670088, "pullRequestNo": 1801 + }, + { + "name": "aleksclark", + "id": 607132, + "comment_id": 3729687747, + "created_at": "2026-01-09T16:28:21Z", + "repoId": 987670088, + "pullRequestNo": 1811 } ] } \ No newline at end of file From 617f9e28f634089fed552cc28a987851f23c48e0 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 9 Jan 2026 14:12:05 -0300 Subject: [PATCH 08/58] feat: allow to send the prompt if its empty but has text attachments (#1806) Signed-off-by: Carlos Alexandro Becker Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/agent/agent.go | 2 +- internal/message/attachment.go | 12 +++- internal/tui/components/chat/editor/editor.go | 72 ++++++++++--------- .../tui/components/chat/messages/messages.go | 8 ++- 4 files changed, 56 insertions(+), 38 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 759a9274f2f4cc8c306ac0cc042de89cd1a25097..7c7ac4c6c1f3d320fe3e3dd865f8e7b56c73010d 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -134,7 +134,7 @@ func NewSessionAgent( } func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) { - if call.Prompt == "" { + if call.Prompt == "" && !message.ContainsTextAttachment(call.Attachments) { return nil, ErrEmptyPrompt } if call.SessionID == "" { diff --git a/internal/message/attachment.go b/internal/message/attachment.go index 0e3b70a8766c74d37399c1ba8c38fe19e74f871d..b04863f39cc5b266662395344d5227cfa12f4188 100644 --- a/internal/message/attachment.go +++ b/internal/message/attachment.go @@ -1,6 +1,9 @@ package message -import "strings" +import ( + "slices" + "strings" +) type Attachment struct { FilePath string @@ -11,3 +14,10 @@ type Attachment struct { func (a Attachment) IsText() bool { return strings.HasPrefix(a.MimeType, "text/") } func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") } + +// ContainsTextAttachment returns true if any of the attachments is a text attachments. +func ContainsTextAttachment(attachments []Attachment) bool { + return slices.ContainsFunc(attachments, func(a Attachment) bool { + return a.IsText() + }) +} diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 8f9b326b9f941bb99cfbaad992830c3173ea41c4..01badb98d37eb848ccf5962e01793ecaa3fc0f59 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -1,13 +1,14 @@ package editor import ( - "errors" "fmt" "math/rand" "net/http" "os" "path/filepath" + "regexp" "slices" + "strconv" "strings" "unicode" @@ -146,7 +147,7 @@ func (m *editorCmp) send() tea.Cmd { attachments := m.attachments - if value == "" { + if value == "" && !message.ContainsTextAttachment(attachments) { return nil } @@ -233,13 +234,31 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { m.textarea.SetValue(msg.Text) m.textarea.MoveToEnd() case tea.PasteMsg: - content, path, err := pasteToFile(msg) - if errors.Is(err, errNotAFile) { - m.textarea, cmd = m.textarea.Update(msg) - return m, cmd + // If pasted text has more than 2 newlines, treat it as a file attachment. + if strings.Count(msg.Content, "\n") > 2 { + content := []byte(msg.Content) + if len(content) > maxAttachmentSize { + return m, util.ReportWarn("Paste is too big (>5mb)") + } + name := fmt.Sprintf("paste_%d.txt", m.pasteIdx()) + mimeType := mimeOf(content) + attachment := message.Attachment{ + FileName: name, + FilePath: name, + MimeType: mimeType, + Content: content, + } + return m, util.CmdHandler(filepicker.FilePickedMsg{ + Attachment: attachment, + }) } + + // Try to parse as a file path. + content, path, err := filepathToFile(msg.Content) if err != nil { - return m, util.ReportError(err) + // Not a file path, just update the textarea normally. + m.textarea, cmd = m.textarea.Update(msg) + return m, cmd } if len(content) > maxAttachmentSize { @@ -256,7 +275,6 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { if !attachment.IsText() && !attachment.IsImage() { return m, util.ReportWarn("Invalid file content type: " + mimeType) } - m.textarea.InsertString(attachment.FileName) return m, util.CmdHandler(filepicker.FilePickedMsg{ Attachment: attachment, }) @@ -627,33 +645,21 @@ func New(app *app.App) Editor { var maxAttachmentSize = 5 * 1024 * 1024 // 5MB -var errNotAFile = errors.New("not a file") - -func pasteToFile(msg tea.PasteMsg) ([]byte, string, error) { - content, path, err := filepathToFile(msg.Content) - if err == nil { - return content, path, err - } - - if strings.Count(msg.Content, "\n") > 2 { - return contentToFile([]byte(msg.Content)) - } - - return nil, "", errNotAFile -} +var pasteRE = regexp.MustCompile(`paste_(\d+).txt`) -func contentToFile(content []byte) ([]byte, string, error) { - f, err := os.CreateTemp("", "paste_*.txt") - if err != nil { - return nil, "", err - } - if _, err := f.Write(content); err != nil { - return nil, "", err - } - if err := f.Close(); err != nil { - return nil, "", err +func (m *editorCmp) pasteIdx() int { + result := 0 + for _, at := range m.attachments { + found := pasteRE.FindStringSubmatch(at.FileName) + if len(found) == 0 { + continue + } + idx, err := strconv.Atoi(found[1]) + if err == nil { + result = max(result, idx) + } } - return content, f.Name(), nil + return result + 1 } func filepathToFile(name string) ([]byte, string, error) { diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 1359823edb7a783cd23b600e1ddae3870f2a2107..b4db149946fe0a1f67c957eeb04da2966e1f5f28 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -223,8 +223,10 @@ func (m *messageCmp) renderAssistantMessage() string { // message content and any attached files with appropriate icons. func (m *messageCmp) renderUserMessage() string { t := styles.CurrentTheme() - parts := []string{ - m.toMarkdown(m.message.Content().String()), + var parts []string + + if s := m.message.Content().String(); s != "" { + parts = append(parts, m.toMarkdown(s)) } attachmentStyle := t.S().Base. @@ -256,7 +258,7 @@ func (m *messageCmp) renderUserMessage() string { } if len(attachments) > 0 { - parts = append(parts, "", strings.Join(attachments, "")) + parts = append(parts, strings.Join(attachments, "")) } joined := lipgloss.JoinVertical(lipgloss.Left, parts...) From d1382bb55d03936047df0fc81b3ab9da3cafe4b5 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 9 Jan 2026 14:21:42 -0300 Subject: [PATCH 09/58] perf: reduce memory usage (#1812) Signed-off-by: Carlos Alexandro Becker --- Taskfile.yaml | 3 +- go.mod | 8 ++- go.sum | 32 +++++++++--- internal/agent/common_test.go | 4 ++ internal/agent/tools/fetch_helpers.go | 5 +- internal/agent/tools/mcp/init.go | 3 +- internal/app/lsp_events.go | 3 +- internal/csync/maps.go | 12 +++-- internal/csync/versionedmap.go | 5 ++ internal/db/connect.go | 27 ++-------- internal/db/connect_modernc.go | 30 +++++++++++ internal/db/connect_ncruces.go | 37 ++++++++++++++ internal/lsp/client.go | 50 ++++++++++++++++++- internal/oauth/copilot/client.go | 3 +- internal/tui/components/chat/header/header.go | 9 +--- internal/tui/components/lsp/lsp.go | 42 ++++++---------- 16 files changed, 193 insertions(+), 80 deletions(-) create mode 100644 internal/db/connect_modernc.go create mode 100644 internal/db/connect_ncruces.go diff --git a/Taskfile.yaml b/Taskfile.yaml index 2f5574f7ab1f07a03f47e8534d477afd293d9248..68c805c599314cadde5c86fc37a0e3d1a6184f4e 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -44,7 +44,8 @@ tasks: run: desc: Run build cmds: - - go run . {{.CLI_ARGS}} + - go build -o crush . + - ./crush {{.CLI_ARGS}} test: desc: Run tests diff --git a/go.mod b/go.mod index a5fe985d15007fcf5176ca4e3c9fadb0095a0905..ffe05622459585903e108903bd30f4f1ccedb917 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/charmbracelet/x/ansi v0.11.3 github.com/charmbracelet/x/editor v0.2.0 github.com/charmbracelet/x/etag v0.2.0 - github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 + github.com/charmbracelet/x/exp/charmtone v0.0.0-20260109001716-2fbdffcb221f github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f github.com/charmbracelet/x/exp/ordered v0.1.0 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff @@ -63,6 +63,7 @@ require ( golang.org/x/text v0.32.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.43.0 mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5 mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5 ) @@ -141,9 +142,11 @@ require ( github.com/muesli/mango-cobra v1.2.0 // indirect github.com/muesli/mango-pflag v0.1.0 // indirect github.com/muesli/roff v0.1.0 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 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 @@ -180,4 +183,7 @@ require ( google.golang.org/protobuf v1.36.10 // indirect gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 3131993a7dd1473522938031ebb2be23b906e061..71b6a2b0b921c031d8a5925bb09c40847a3530d7 100644 --- a/go.sum +++ b/go.sum @@ -108,8 +108,8 @@ github.com/charmbracelet/x/editor v0.2.0 h1:7XLUKtaRaB8jN7bWU2p2UChiySyaAuIfYiIR github.com/charmbracelet/x/editor v0.2.0/go.mod h1:p3oQ28TSL3YPd+GKJ1fHWcp+7bVGpedHpXmo0D6t1dY= github.com/charmbracelet/x/etag v0.2.0 h1:Euj1VkheoHfTYA9y+TCwkeXF/hN8Fb9l4LqZl79pt04= github.com/charmbracelet/x/etag v0.2.0/go.mod h1:C1B7/bsgvzzxpfu0Rabbd+rTHJa5TmC/qgTseCf6DF0= -github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1xwHZg6eMZ9Wv5TE1UGub6ARubyOd1Lo5kPUI/6VL50= -github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20260109001716-2fbdffcb221f h1:OKFNbG2sSmgpQW9EC3gYNG+QrcQ4+wWYjzfmJvWkkDo= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20260109001716-2fbdffcb221f/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= @@ -184,6 +184,8 @@ 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/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -501,14 +503,32 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= -modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= -modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.43.0 h1:8YqiFx3G1VhHTXO2Q00bl1Wz9KhS9Q5okwfp9Y97VnA= +modernc.org/sqlite v1.43.0/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5 h1:mO2lyKtGwu4mGQ+Qqjx0+fd5UU5BXhX/rslFmxd5aco= mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5/go.mod h1:Of9PCedbLDYT8b3EyiYG64rNnx5nOp27OLCVdDrjJyo= mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5 h1:e7Z/Lgw/zMijvQBVrfh/vUDZ+9FpuSLrJDVGBuoJtuo= diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go index bfe987ffb9a3bf73556b502724a115f41fcc6caf..bdf7990cf8a8aff509ed39d1167213b45ff92615 100644 --- a/internal/agent/common_test.go +++ b/internal/agent/common_test.go @@ -182,6 +182,10 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel // would be included in prompt and break VCR cassette matching. cfg.Options.SkillsPaths = []string{} + // Clear LSP config to ensure test reproducibility - user's LSP config + // would be included in prompt and break VCR cassette matching. + cfg.LSP = nil + systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), *cfg) if err != nil { return nil, err diff --git a/internal/agent/tools/fetch_helpers.go b/internal/agent/tools/fetch_helpers.go index 34eb3b2fcd4424997338307560661172ed5f6662..dfcf31c882431ab468f8847fb4bedf609ffeb756 100644 --- a/internal/agent/tools/fetch_helpers.go +++ b/internal/agent/tools/fetch_helpers.go @@ -19,6 +19,8 @@ import ( // BrowserUserAgent is a realistic browser User-Agent for better compatibility. const BrowserUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +var multipleNewlinesRe = regexp.MustCompile(`\n{3,}`) + // FetchURLAndConvert fetches a URL and converts HTML content to markdown. func FetchURLAndConvert(ctx context.Context, client *http.Client, url string) (string, error) { req, err := http.NewRequestWithContext(ctx, "GET", url, nil) @@ -128,8 +130,7 @@ func removeNoisyElements(htmlContent string) string { // cleanupMarkdown removes excessive whitespace and blank lines from markdown. func cleanupMarkdown(content string) string { // Collapse multiple blank lines into at most two. - multipleNewlines := regexp.MustCompile(`\n{3,}`) - content = multipleNewlines.ReplaceAllString(content, "\n\n") + content = multipleNewlinesRe.ReplaceAllString(content, "\n\n") // Remove trailing whitespace from each line. lines := strings.Split(content, "\n") diff --git a/internal/agent/tools/mcp/init.go b/internal/agent/tools/mcp/init.go index be27ce3f8ae5b9b7f425e496a1726bc23eaf3aae..c28da6c1722413d276bbb47b3dbf3e9f66826263 100644 --- a/internal/agent/tools/mcp/init.go +++ b/internal/agent/tools/mcp/init.go @@ -9,7 +9,6 @@ import ( "fmt" "io" "log/slog" - "maps" "net/http" "os" "os/exec" @@ -98,7 +97,7 @@ func SubscribeEvents(ctx context.Context) <-chan pubsub.Event[Event] { // GetStates returns the current state of all MCP clients func GetStates() map[string]ClientInfo { - return maps.Collect(states.Seq2()) + return states.Copy() } // GetState returns the state of a specific MCP client diff --git a/internal/app/lsp_events.go b/internal/app/lsp_events.go index 08e54582b95d8db725bffc7ff8bd43d4a37528b1..5292983d46cf867b9380ad45f7831007da54f0d7 100644 --- a/internal/app/lsp_events.go +++ b/internal/app/lsp_events.go @@ -2,7 +2,6 @@ package app import ( "context" - "maps" "time" "github.com/charmbracelet/crush/internal/csync" @@ -49,7 +48,7 @@ func SubscribeLSPEvents(ctx context.Context) <-chan pubsub.Event[LSPEvent] { // GetLSPStates returns the current state of all LSP clients func GetLSPStates() map[string]LSPClientInfo { - return maps.Collect(lspStates.Seq2()) + return lspStates.Copy() } // GetLSPState returns the state of a specific LSP client diff --git a/internal/csync/maps.go b/internal/csync/maps.go index 1fd2005790014b2ce4bd5a78dbb7931d54cbe66c..97cb580f7a012559aafbc7bbef8386211b72ee90 100644 --- a/internal/csync/maps.go +++ b/internal/csync/maps.go @@ -96,12 +96,16 @@ func (m *Map[K, V]) Take(key K) (V, bool) { return v, ok } +// Copy returns a copy of the inner map. +func (m *Map[K, V]) Copy() map[K]V { + m.mu.RLock() + defer m.mu.RUnlock() + return maps.Clone(m.inner) +} + // Seq2 returns an iter.Seq2 that yields key-value pairs from the map. func (m *Map[K, V]) Seq2() iter.Seq2[K, V] { - dst := make(map[K]V) - m.mu.RLock() - maps.Copy(dst, m.inner) - m.mu.RUnlock() + dst := m.Copy() return func(yield func(K, V) bool) { for k, v := range dst { if !yield(k, v) { diff --git a/internal/csync/versionedmap.go b/internal/csync/versionedmap.go index f0f4e0249c3b0102976840bd82400e18c1703c47..6ed996b2ff8d1380aa7fd22cab57342bf71e4a8f 100644 --- a/internal/csync/versionedmap.go +++ b/internal/csync/versionedmap.go @@ -40,6 +40,11 @@ func (m *VersionedMap[K, V]) Seq2() iter.Seq2[K, V] { return m.m.Seq2() } +// Copy returns a copy of the inner map. +func (m *VersionedMap[K, V]) Copy() map[K]V { + return m.m.Copy() +} + // Len returns the number of items in the map. func (m *VersionedMap[K, V]) Len() int { return m.m.Len() diff --git a/internal/db/connect.go b/internal/db/connect.go index bfe768c7ae9a399afd61a9d0692841fbacbe164c..20f0c3f31b1506e32ed9d53327d839ac7616bbc9 100644 --- a/internal/db/connect.go +++ b/internal/db/connect.go @@ -7,42 +7,21 @@ import ( "log/slog" "path/filepath" - "github.com/ncruces/go-sqlite3" - "github.com/ncruces/go-sqlite3/driver" - _ "github.com/ncruces/go-sqlite3/embed" - "github.com/pressly/goose/v3" ) +// Connect opens a SQLite database connection and runs migrations. func Connect(ctx context.Context, dataDir string) (*sql.DB, error) { if dataDir == "" { return nil, fmt.Errorf("data.dir is not set") } dbPath := filepath.Join(dataDir, "crush.db") - // Set pragmas for better performance - pragmas := []string{ - "PRAGMA foreign_keys = ON;", - "PRAGMA journal_mode = WAL;", - "PRAGMA page_size = 4096;", - "PRAGMA cache_size = -8000;", - "PRAGMA synchronous = NORMAL;", - "PRAGMA secure_delete = ON;", - } - - db, err := driver.Open(dbPath, func(c *sqlite3.Conn) error { - for _, pragma := range pragmas { - if err := c.Exec(pragma); err != nil { - return fmt.Errorf("failed to set pragma `%s`: %w", pragma, err) - } - } - return nil - }) + db, err := openDB(dbPath) if err != nil { - return nil, fmt.Errorf("failed to open database: %w", err) + return nil, err } - // Verify connection if err = db.PingContext(ctx); err != nil { db.Close() return nil, fmt.Errorf("failed to connect to database: %w", err) diff --git a/internal/db/connect_modernc.go b/internal/db/connect_modernc.go new file mode 100644 index 0000000000000000000000000000000000000000..5a44a696d633ac56661fd2d25d841979a850b6e4 --- /dev/null +++ b/internal/db/connect_modernc.go @@ -0,0 +1,30 @@ +//go:build (darwin && (amd64 || arm64)) || (freebsd && (amd64 || arm64)) || (linux && (386 || amd64 || arm || arm64 || loong64 || ppc64le || riscv64 || s390x)) || (windows && (386 || amd64 || arm64)) + +package db + +import ( + "database/sql" + "fmt" + "net/url" + + _ "modernc.org/sqlite" +) + +func openDB(dbPath string) (*sql.DB, error) { + // Set pragmas for better performance via _pragma query params. + // Format: _pragma=name(value) + params := url.Values{} + params.Add("_pragma", "foreign_keys(on)") + params.Add("_pragma", "journal_mode(WAL)") + params.Add("_pragma", "page_size(4096)") + params.Add("_pragma", "cache_size(-8000)") + params.Add("_pragma", "synchronous(NORMAL)") + params.Add("_pragma", "secure_delete(on)") + + dsn := fmt.Sprintf("file:%s?%s", dbPath, params.Encode()) + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + return db, nil +} diff --git a/internal/db/connect_ncruces.go b/internal/db/connect_ncruces.go new file mode 100644 index 0000000000000000000000000000000000000000..45305e73a866717a94f2028b37bdc7681ed07c11 --- /dev/null +++ b/internal/db/connect_ncruces.go @@ -0,0 +1,37 @@ +//go:build !((darwin && (amd64 || arm64)) || (freebsd && (amd64 || arm64)) || (linux && (386 || amd64 || arm || arm64 || loong64 || ppc64le || riscv64 || s390x)) || (windows && (386 || amd64 || arm64))) + +package db + +import ( + "database/sql" + "fmt" + + "github.com/ncruces/go-sqlite3" + "github.com/ncruces/go-sqlite3/driver" + _ "github.com/ncruces/go-sqlite3/embed" +) + +func openDB(dbPath string) (*sql.DB, error) { + // Set pragmas for better performance. + pragmas := []string{ + "PRAGMA foreign_keys = ON;", + "PRAGMA journal_mode = WAL;", + "PRAGMA page_size = 4096;", + "PRAGMA cache_size = -8000;", + "PRAGMA synchronous = NORMAL;", + "PRAGMA secure_delete = ON;", + } + + db, err := driver.Open(dbPath, func(c *sqlite3.Conn) error { + for _, pragma := range pragmas { + if err := c.Exec(pragma); err != nil { + return fmt.Errorf("failed to set pragma %q: %w", pragma, err) + } + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + return db, nil +} diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 7d914d9a52ce75f621715273e8f6b9588aa912b7..7dba52fdf48a2205bc9fca8436390326be2a2d39 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "sync" "sync/atomic" "time" @@ -21,6 +22,14 @@ import ( "github.com/charmbracelet/x/powernap/pkg/transport" ) +// DiagnosticCounts holds the count of diagnostics by severity. +type DiagnosticCounts struct { + Error int + Warning int + Information int + Hint int +} + type Client struct { client *powernap.Client name string @@ -37,6 +46,11 @@ type Client struct { // Diagnostic cache diagnostics *csync.VersionedMap[protocol.DocumentURI, []protocol.Diagnostic] + // Cached diagnostic counts to avoid map copy on every UI render. + diagCountsCache DiagnosticCounts + diagCountsVersion uint64 + diagCountsMu sync.Mutex + // Files are currently opened by the LSP openFiles *csync.Map[string, *OpenFileInfo] @@ -350,7 +364,41 @@ func (c *Client) GetFileDiagnostics(uri protocol.DocumentURI) []protocol.Diagnos // GetDiagnostics returns all diagnostics for all files. func (c *Client) GetDiagnostics() map[protocol.DocumentURI][]protocol.Diagnostic { - return maps.Collect(c.diagnostics.Seq2()) + return c.diagnostics.Copy() +} + +// GetDiagnosticCounts returns cached diagnostic counts by severity. +// Uses the VersionedMap version to avoid recomputing on every call. +func (c *Client) GetDiagnosticCounts() DiagnosticCounts { + currentVersion := c.diagnostics.Version() + + c.diagCountsMu.Lock() + defer c.diagCountsMu.Unlock() + + if currentVersion == c.diagCountsVersion { + return c.diagCountsCache + } + + // Recompute counts. + counts := DiagnosticCounts{} + for _, diags := range c.diagnostics.Seq2() { + for _, diag := range diags { + switch diag.Severity { + case protocol.SeverityError: + counts.Error++ + case protocol.SeverityWarning: + counts.Warning++ + case protocol.SeverityInformation: + counts.Information++ + case protocol.SeverityHint: + counts.Hint++ + } + } + } + + c.diagCountsCache = counts + c.diagCountsVersion = currentVersion + return counts } // OpenFileOnDemand opens a file only if it's not already open. diff --git a/internal/oauth/copilot/client.go b/internal/oauth/copilot/client.go index f76f3bf640c4331968b4173cf0d48e0dbc69aed2..fd243f78b477465063c369dc4dc8f1ff38b72a8c 100644 --- a/internal/oauth/copilot/client.go +++ b/internal/oauth/copilot/client.go @@ -12,6 +12,8 @@ import ( "github.com/charmbracelet/crush/internal/log" ) +var assistantRolePattern = regexp.MustCompile(`"role"\s*:\s*"assistant"`) + // NewClient creates a new HTTP client with a custom transport that adds the // X-Initiator header based on message history in the request body. func NewClient(isSubAgent, debug bool) *http.Client { @@ -58,7 +60,6 @@ func (t *initiatorTransport) RoundTrip(req *http.Request) (*http.Response, error // Check for assistant messages using regex to handle whitespace // variations in the JSON while avoiding full unmarshalling overhead. initiator := userInitiator - assistantRolePattern := regexp.MustCompile(`"role"\s*:\s*"assistant"`) if assistantRolePattern.Match(bodyBytes) || t.isSubAgent { slog.Debug("Setting X-Initiator header to agent (found assistant messages in history)") initiator = agentInitiator diff --git a/internal/tui/components/chat/header/header.go b/internal/tui/components/chat/header/header.go index 59389815ac63ac127ac000abf872b000eb8f2347..c8848440b1193fda9a7b5df4b31e03edeaf744c4 100644 --- a/internal/tui/components/chat/header/header.go +++ b/internal/tui/components/chat/header/header.go @@ -15,7 +15,6 @@ import ( "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" ) type Header interface { @@ -106,13 +105,7 @@ func (h *header) details(availWidth int) string { errorCount := 0 for l := range h.lspClients.Seq() { - for _, diagnostics := range l.GetDiagnostics() { - for _, diagnostic := range diagnostics { - if diagnostic.Severity == protocol.SeverityError { - errorCount++ - } - } - } + errorCount += l.GetDiagnosticCounts().Error } if errorCount > 0 { diff --git a/internal/tui/components/lsp/lsp.go b/internal/tui/components/lsp/lsp.go index 18c3f74b71768b88d068093759245615d2f7a284..f9118143cbfd9a7bf19aa569bc85448746debecd 100644 --- a/internal/tui/components/lsp/lsp.go +++ b/internal/tui/components/lsp/lsp.go @@ -11,7 +11,6 @@ import ( "github.com/charmbracelet/crush/internal/lsp" "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" ) // RenderOptions contains options for rendering LSP lists. @@ -61,36 +60,23 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption // Calculate diagnostic counts if we have LSP clients var extraContent string if lspClients != nil { - lspErrs := map[protocol.DiagnosticSeverity]int{ - protocol.SeverityError: 0, - protocol.SeverityWarning: 0, - protocol.SeverityHint: 0, - protocol.SeverityInformation: 0, - } if client, ok := lspClients.Get(l.Name); ok { - for _, diagnostics := range client.GetDiagnostics() { - for _, diagnostic := range diagnostics { - if severity, ok := lspErrs[diagnostic.Severity]; ok { - lspErrs[diagnostic.Severity] = severity + 1 - } - } + counts := client.GetDiagnosticCounts() + errs := []string{} + if counts.Error > 0 { + errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, counts.Error))) } + if counts.Warning > 0 { + errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, counts.Warning))) + } + if counts.Hint > 0 { + errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, counts.Hint))) + } + if counts.Information > 0 { + errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, counts.Information))) + } + extraContent = strings.Join(errs, " ") } - - errs := []string{} - if lspErrs[protocol.SeverityError] > 0 { - errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError]))) - } - if lspErrs[protocol.SeverityWarning] > 0 { - errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning]))) - } - if lspErrs[protocol.SeverityHint] > 0 { - errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint]))) - } - if lspErrs[protocol.SeverityInformation] > 0 { - errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation]))) - } - extraContent = strings.Join(errs, " ") } lspList = append(lspList, From df6f51492ef104be308f77066442bb5eded86261 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 9 Jan 2026 15:29:31 -0300 Subject: [PATCH 10/58] fix(sqlite): busy timeout (#1815) Signed-off-by: Carlos Alexandro Becker --- internal/db/connect_modernc.go | 1 + internal/db/connect_ncruces.go | 1 + 2 files changed, 2 insertions(+) diff --git a/internal/db/connect_modernc.go b/internal/db/connect_modernc.go index 5a44a696d633ac56661fd2d25d841979a850b6e4..303c4e9a1108562d5060699381dcd9d8c9088d8a 100644 --- a/internal/db/connect_modernc.go +++ b/internal/db/connect_modernc.go @@ -20,6 +20,7 @@ func openDB(dbPath string) (*sql.DB, error) { params.Add("_pragma", "cache_size(-8000)") params.Add("_pragma", "synchronous(NORMAL)") params.Add("_pragma", "secure_delete(on)") + params.Add("_pragma", "busy_timeout(5000)") dsn := fmt.Sprintf("file:%s?%s", dbPath, params.Encode()) db, err := sql.Open("sqlite", dsn) diff --git a/internal/db/connect_ncruces.go b/internal/db/connect_ncruces.go index 45305e73a866717a94f2028b37bdc7681ed07c11..ceeb7233a45fff443c13ae7a8dccf740dbd5b782 100644 --- a/internal/db/connect_ncruces.go +++ b/internal/db/connect_ncruces.go @@ -20,6 +20,7 @@ func openDB(dbPath string) (*sql.DB, error) { "PRAGMA cache_size = -8000;", "PRAGMA synchronous = NORMAL;", "PRAGMA secure_delete = ON;", + "PRAGMA busy_timeout = 5000;", } db, err := driver.Open(dbPath, func(c *sqlite3.Conn) error { From e4400ad2413fdf57d85e5e07d641c61e357d3c2a Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 9 Jan 2026 17:58:37 -0300 Subject: [PATCH 11/58] perf(shell): reduce allocations in updateShellFromRunner (#1817) --- internal/shell/shell.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/shell/shell.go b/internal/shell/shell.go index f9f4656b82bbb6ee14b38469a20d493d98354b4a..e5a54f01c403ae1b8de681616c5d693bc842ac14 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -247,12 +247,14 @@ func (s *Shell) newInterp(stdout, stderr io.Writer) (*interp.Runner, error) { ) } -// updateShellFromRunner updates the shell from the interpreter after execution +// updateShellFromRunner updates the shell from the interpreter after execution. func (s *Shell) updateShellFromRunner(runner *interp.Runner) { s.cwd = runner.Dir - s.env = nil + s.env = s.env[:0] for name, vr := range runner.Vars { - s.env = append(s.env, fmt.Sprintf("%s=%s", name, vr.Str)) + if vr.Exported { + s.env = append(s.env, name+"="+vr.Str) + } } } From 0868681f6665c05552491ad1d9f71939fd47f68e Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 9 Jan 2026 17:58:51 -0300 Subject: [PATCH 12/58] perf: use strings.Builder for string concatenation in loops (#1819) --- internal/agent/agent.go | 25 +++++++++++------- internal/agent/agent_test.go | 36 +++++++++++++++++++++++++ internal/message/content.go | 16 +++++++----- internal/message/content_test.go | 45 ++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 15 deletions(-) create mode 100644 internal/message/content_test.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 7c7ac4c6c1f3d320fe3e3dd865f8e7b56c73010d..dae219a53f5387e45dcb084fa6ac5ae4a7165a4b 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -561,15 +561,7 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan return err } - summaryPromptText := "Provide a detailed summary of our conversation above." - if len(currentSession.Todos) > 0 { - summaryPromptText += "\n\n## Current Todo List\n\n" - for _, t := range currentSession.Todos { - summaryPromptText += fmt.Sprintf("- [%s] %s\n", t.Status, t.Content) - } - summaryPromptText += "\nInclude these tasks and their statuses in your summary. " - summaryPromptText += "Instruct the resuming assistant to use the `todos` tool to continue tracking progress on these tasks." - } + summaryPromptText := buildSummaryPrompt(currentSession.Todos) resp, err := agent.Stream(genCtx, fantasy.AgentStreamCall{ Prompt: summaryPromptText, @@ -1101,3 +1093,18 @@ func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Mes return convertedMessages } + +// buildSummaryPrompt constructs the prompt text for session summarization. +func buildSummaryPrompt(todos []session.Todo) string { + var sb strings.Builder + sb.WriteString("Provide a detailed summary of our conversation above.") + if len(todos) > 0 { + sb.WriteString("\n\n## Current Todo List\n\n") + for _, t := range todos { + fmt.Fprintf(&sb, "- [%s] %s\n", t.Status, t.Content) + } + sb.WriteString("\nInclude these tasks and their statuses in your summary. ") + sb.WriteString("Instruct the resuming assistant to use the `todos` tool to continue tracking progress on these tasks.") + } + return sb.String() +} diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index ca5bb10dca1bc1096429e99dc217389d30e90248..38a154d8c449b2cc148ef5038d7facf49450bbb3 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -1,6 +1,7 @@ package agent import ( + "fmt" "os" "path/filepath" "runtime" @@ -11,6 +12,7 @@ import ( "charm.land/x/vcr" "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/session" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -619,3 +621,37 @@ func TestCoderAgent(t *testing.T) { }) } } + +func makeTestTodos(n int) []session.Todo { + todos := make([]session.Todo, n) + for i := range n { + todos[i] = session.Todo{ + Status: session.TodoStatusPending, + Content: fmt.Sprintf("Task %d: Implement feature with some description that makes it realistic", i), + } + } + return todos +} + +func BenchmarkBuildSummaryPrompt(b *testing.B) { + cases := []struct { + name string + numTodos int + }{ + {"0todos", 0}, + {"5todos", 5}, + {"10todos", 10}, + {"50todos", 50}, + } + + for _, tc := range cases { + todos := makeTestTodos(tc.numTodos) + + b.Run(tc.name, func(b *testing.B) { + b.ReportAllocs() + for range b.N { + _ = buildSummaryPrompt(todos) + } + }) + } +} diff --git a/internal/message/content.go b/internal/message/content.go index 6c03d42aed05a7772f37d15dc782bf96c8b69685..3fed1f06019c855d30af9d5583e6a7b63fcbd508 100644 --- a/internal/message/content.go +++ b/internal/message/content.go @@ -437,23 +437,27 @@ func (m *Message) AddBinary(mimeType string, data []byte) { } func PromptWithTextAttachments(prompt string, attachments []Attachment) string { + var sb strings.Builder + sb.WriteString(prompt) addedAttachments := false for _, content := range attachments { if !content.IsText() { continue } if !addedAttachments { - prompt += "\nThe files below have been attached by the user, consider them in your response\n" + sb.WriteString("\nThe files below have been attached by the user, consider them in your response\n") addedAttachments = true } - tag := `\n` if content.FilePath != "" { - tag = fmt.Sprintf("\n", content.FilePath) + fmt.Fprintf(&sb, "\n", content.FilePath) + } else { + sb.WriteString("\n") } - prompt += tag - prompt += "\n" + string(content.Content) + "\n\n" + sb.WriteString("\n") + sb.Write(content.Content) + sb.WriteString("\n\n") } - return prompt + return sb.String() } func (m *Message) ToAIMessage() []fantasy.Message { diff --git a/internal/message/content_test.go b/internal/message/content_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7e9e273c57e4b6cee2df8cd6b74bf455797bce36 --- /dev/null +++ b/internal/message/content_test.go @@ -0,0 +1,45 @@ +package message + +import ( + "fmt" + "strings" + "testing" +) + +func makeTestAttachments(n int, contentSize int) []Attachment { + attachments := make([]Attachment, n) + content := []byte(strings.Repeat("x", contentSize)) + for i := range n { + attachments[i] = Attachment{ + FilePath: fmt.Sprintf("/path/to/file%d.txt", i), + MimeType: "text/plain", + Content: content, + } + } + return attachments +} + +func BenchmarkPromptWithTextAttachments(b *testing.B) { + cases := []struct { + name string + numFiles int + contentSize int + }{ + {"1file_100bytes", 1, 100}, + {"5files_1KB", 5, 1024}, + {"10files_10KB", 10, 10 * 1024}, + {"20files_50KB", 20, 50 * 1024}, + } + + for _, tc := range cases { + attachments := makeTestAttachments(tc.numFiles, tc.contentSize) + prompt := "Process these files" + + b.Run(tc.name, func(b *testing.B) { + b.ReportAllocs() + for range b.N { + _ = PromptWithTextAttachments(prompt, attachments) + } + }) + } +} From 542750a40e08f5caeb9f4afb412c7a081bb8ff5e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:11:15 +0000 Subject: [PATCH 13/58] chore(deps): bump the all group with 5 updates (#1832) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 10 +++++----- go.sum | 24 ++++++++++++------------ 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/go.mod b/go.mod index ffe05622459585903e108903bd30f4f1ccedb917..81df5359521def733c75bfe02465d17fcbdf4df8 100644 --- a/go.mod +++ b/go.mod @@ -13,12 +13,12 @@ require ( github.com/JohannesKaufmann/html-to-markdown v1.6.0 github.com/MakeNowJust/heredoc v1.0.0 github.com/PuerkitoBio/goquery v1.11.0 - github.com/alecthomas/chroma/v2 v2.21.1 + github.com/alecthomas/chroma/v2 v2.22.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/bmatcuk/doublestar/v4 v4.9.2 github.com/charlievieth/fastwalk v1.0.14 - github.com/charmbracelet/catwalk v0.12.2 + github.com/charmbracelet/catwalk v0.13.0 github.com/charmbracelet/colorprofile v0.4.1 github.com/charmbracelet/fang v0.4.4 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560 @@ -57,10 +57,10 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/zeebo/xxh3 v1.0.2 - golang.org/x/mod v0.31.0 + golang.org/x/mod v0.32.0 golang.org/x/net v0.48.0 golang.org/x/sync v0.19.0 - golang.org/x/text v0.32.0 + golang.org/x/text v0.33.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.43.0 diff --git a/go.sum b/go.sum index 71b6a2b0b921c031d8a5925bb09c40847a3530d7..1ca5a73d4a20738911fe93263cc7851b8ab0965e 100644 --- a/go.sum +++ b/go.sum @@ -39,8 +39,8 @@ github.com/RealAlexandreAI/json-repair v0.0.14 h1:4kTqotVonDVTio5n2yweRUELVcNe2x github.com/RealAlexandreAI/json-repair v0.0.14/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA= -github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= +github.com/alecthomas/chroma/v2 v2.22.0 h1:PqEhf+ezz5F5owoDeOUKFzW+W3ZJDShNCaHg4sZuItI= +github.com/alecthomas/chroma/v2 v2.22.0/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= @@ -86,16 +86,16 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= -github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= -github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.9.2 h1:b0mc6WyRSYLjzofB2v/0cuDUZ+MqoGyH3r0dVij35GI= +github.com/bmatcuk/doublestar/v4 v4.9.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/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-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/catwalk v0.12.2 h1:zq9b+7kiumof/Dzvqi/oHnwMBgSN/M2Yt82vlIAiKMU= -github.com/charmbracelet/catwalk v0.12.2/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ= +github.com/charmbracelet/catwalk v0.13.0 h1:L+chddP+PJvX3Vl+hqlWW5HAwBErlkL/friQXih1JQI= +github.com/charmbracelet/catwalk v0.13.0/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY= @@ -396,8 +396,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -462,8 +462,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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -472,8 +472,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.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= From b8847674247c0acb21b863c57f77ca44631185ca Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 12 Jan 2026 10:22:37 -0300 Subject: [PATCH 14/58] perf: fix possibly unclosed resp.body (#1818) Signed-off-by: Carlos Alexandro Becker --- internal/config/config.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 901562420e61fe3950886bebba7ff094eb8c91b6..31aeb4f06f7c8b9063579d9232310ac3a99befe0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -806,21 +806,21 @@ func (c *ProviderConfig) TestConnection(resolver VariableResolver) error { for k, v := range c.ExtraHeaders { req.Header.Set(k, v) } - b, err := client.Do(req) + resp, err := client.Do(req) if err != nil { return fmt.Errorf("failed to create request for provider %s: %w", c.ID, err) } + defer resp.Body.Close() if c.ID == string(catwalk.InferenceProviderZAI) { - if b.StatusCode == http.StatusUnauthorized { - // for z.ai just check if the http response is not 401 - return fmt.Errorf("failed to connect to provider %s: %s", c.ID, b.Status) + if resp.StatusCode == http.StatusUnauthorized { + // For z.ai just check if the http response is not 401. + return fmt.Errorf("failed to connect to provider %s: %s", c.ID, resp.Status) } } else { - if b.StatusCode != http.StatusOK { - return fmt.Errorf("failed to connect to provider %s: %s", c.ID, b.Status) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to connect to provider %s: %s", c.ID, resp.Status) } } - _ = b.Body.Close() return nil } From 2350f7ea30658e7fe0240b901818b22896d32a37 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 12 Jan 2026 10:22:57 -0300 Subject: [PATCH 15/58] fix: make sure to unlock in goroutine (#1820) Signed-off-by: Carlos Alexandro Becker --- internal/csync/maps.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/csync/maps.go b/internal/csync/maps.go index 97cb580f7a012559aafbc7bbef8386211b72ee90..d5856db463194f4aefc02794194992e7bb99a7ce 100644 --- a/internal/csync/maps.go +++ b/internal/csync/maps.go @@ -33,8 +33,8 @@ func NewLazyMap[K comparable, V any](load func() map[K]V) *Map[K, V] { m := &Map[K, V]{} m.mu.Lock() go func() { + defer m.mu.Unlock() m.inner = load() - m.mu.Unlock() }() return m } From a447f55c5c3091d0d1dd406ea9e96e97aaaa12ff Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 12 Jan 2026 10:24:58 -0300 Subject: [PATCH 16/58] perf(config): simplify loadFromConfigPaths (#1821) --- internal/config/attribution_migration_test.go | 4 +- internal/config/load.go | 45 +++----- internal/config/load_bench_test.go | 103 ++++++++++++++++++ internal/config/load_test.go | 11 +- internal/config/merge.go | 16 --- internal/config/merge_test.go | 27 ----- 6 files changed, 126 insertions(+), 80 deletions(-) create mode 100644 internal/config/load_bench_test.go delete mode 100644 internal/config/merge.go delete mode 100644 internal/config/merge_test.go diff --git a/internal/config/attribution_migration_test.go b/internal/config/attribution_migration_test.go index 6c891d92a9a29604d9d6c751e0d15df6edcf3598..cc8c9e2e278b89ee2f86996975774abe18413843 100644 --- a/internal/config/attribution_migration_test.go +++ b/internal/config/attribution_migration_test.go @@ -1,8 +1,6 @@ package config import ( - "io" - "strings" "testing" "github.com/stretchr/testify/require" @@ -83,7 +81,7 @@ func TestAttributionMigration(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - cfg, err := loadFromReaders([]io.Reader{strings.NewReader(tt.configJSON)}) + cfg, err := loadFromBytes([][]byte{[]byte(tt.configJSON)}) require.NoError(t, err) cfg.setDefaults(t.TempDir(), "") diff --git a/internal/config/load.go b/internal/config/load.go index 63904abc057e877c959991769c20f62a7ac8459a..8323c07ac9d4d7d6cd801d617cc6c40da2377bba 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "fmt" - "io" "log/slog" "maps" "os" @@ -25,25 +24,11 @@ import ( "github.com/charmbracelet/crush/internal/home" "github.com/charmbracelet/crush/internal/log" powernapConfig "github.com/charmbracelet/x/powernap/pkg/config" + "github.com/qjebbs/go-jsons" ) const defaultCatwalkURL = "https://catwalk.charm.sh" -// LoadReader config via io.Reader. -func LoadReader(fd io.Reader) (*Config, error) { - data, err := io.ReadAll(fd) - if err != nil { - return nil, err - } - - var config Config - err = json.Unmarshal(data, &config) - if err != nil { - return nil, err - } - return &config, err -} - // Load loads the configuration from the default paths. func Load(workingDir, dataDir string, debug bool) (*Config, error) { configPaths := lookupConfigs(workingDir) @@ -632,35 +617,39 @@ func lookupConfigs(cwd string) []string { } func loadFromConfigPaths(configPaths []string) (*Config, error) { - var configs []io.Reader + var configs [][]byte for _, path := range configPaths { - fd, err := os.Open(path) + data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { continue } return nil, fmt.Errorf("failed to open config file %s: %w", path, err) } - defer fd.Close() - - configs = append(configs, fd) + if len(data) == 0 { + continue + } + configs = append(configs, data) } - return loadFromReaders(configs) + return loadFromBytes(configs) } -func loadFromReaders(readers []io.Reader) (*Config, error) { - if len(readers) == 0 { +func loadFromBytes(configs [][]byte) (*Config, error) { + if len(configs) == 0 { return &Config{}, nil } - merged, err := Merge(readers) + data, err := jsons.Merge(configs) if err != nil { - return nil, fmt.Errorf("failed to merge configuration readers: %w", err) + return nil, err } - - return LoadReader(merged) + var config Config + if err := json.Unmarshal(data, &config); err != nil { + return nil, err + } + return &config, nil } func hasVertexCredentials(env env.Env) bool { diff --git a/internal/config/load_bench_test.go b/internal/config/load_bench_test.go new file mode 100644 index 0000000000000000000000000000000000000000..3df43946d438fd478b63a1dfd242ee4c35e71896 --- /dev/null +++ b/internal/config/load_bench_test.go @@ -0,0 +1,103 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func BenchmarkLoadFromConfigPaths(b *testing.B) { + // Create temp config files with realistic content. + tmpDir := b.TempDir() + + globalConfig := filepath.Join(tmpDir, "global.json") + localConfig := filepath.Join(tmpDir, "local.json") + + globalContent := []byte(`{ + "providers": { + "openai": { + "api_key": "$OPENAI_API_KEY", + "base_url": "https://api.openai.com/v1" + }, + "anthropic": { + "api_key": "$ANTHROPIC_API_KEY", + "base_url": "https://api.anthropic.com" + } + }, + "options": { + "tui": { + "theme": "dark" + } + } + }`) + + localContent := []byte(`{ + "providers": { + "openai": { + "api_key": "sk-override-key" + } + }, + "options": { + "context_paths": ["README.md", "AGENTS.md"] + } + }`) + + if err := os.WriteFile(globalConfig, globalContent, 0o644); err != nil { + b.Fatal(err) + } + if err := os.WriteFile(localConfig, localContent, 0o644); err != nil { + b.Fatal(err) + } + + configPaths := []string{globalConfig, localConfig} + + b.ReportAllocs() + for b.Loop() { + _, err := loadFromConfigPaths(configPaths) + if err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkLoadFromConfigPaths_MissingFiles(b *testing.B) { + // Test with mix of existing and non-existing paths. + tmpDir := b.TempDir() + + existingConfig := filepath.Join(tmpDir, "exists.json") + content := []byte(`{"options": {"tui": {"theme": "dark"}}}`) + if err := os.WriteFile(existingConfig, content, 0o644); err != nil { + b.Fatal(err) + } + + configPaths := []string{ + filepath.Join(tmpDir, "nonexistent1.json"), + existingConfig, + filepath.Join(tmpDir, "nonexistent2.json"), + } + + b.ReportAllocs() + for b.Loop() { + _, err := loadFromConfigPaths(configPaths) + if err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkLoadFromConfigPaths_Empty(b *testing.B) { + // Test with no config files. + tmpDir := b.TempDir() + configPaths := []string{ + filepath.Join(tmpDir, "nonexistent1.json"), + filepath.Join(tmpDir, "nonexistent2.json"), + } + + b.ReportAllocs() + for b.Loop() { + _, err := loadFromConfigPaths(configPaths) + if err != nil { + b.Fatal(err) + } + } +} diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 4819a195df5031a0e179903be013a89ca038791d..47cb0c5ec0ef8ae0266ed77b47fa60834d596399 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -5,7 +5,6 @@ import ( "log/slog" "os" "path/filepath" - "strings" "testing" "github.com/charmbracelet/catwalk/pkg/catwalk" @@ -22,12 +21,12 @@ func TestMain(m *testing.M) { os.Exit(exitVal) } -func TestConfig_LoadFromReaders(t *testing.T) { - data1 := strings.NewReader(`{"providers": {"openai": {"api_key": "key1", "base_url": "https://api.openai.com/v1"}}}`) - data2 := strings.NewReader(`{"providers": {"openai": {"api_key": "key2", "base_url": "https://api.openai.com/v2"}}}`) - data3 := strings.NewReader(`{"providers": {"openai": {}}}`) +func TestConfig_LoadFromBytes(t *testing.T) { + data1 := []byte(`{"providers": {"openai": {"api_key": "key1", "base_url": "https://api.openai.com/v1"}}}`) + data2 := []byte(`{"providers": {"openai": {"api_key": "key2", "base_url": "https://api.openai.com/v2"}}}`) + data3 := []byte(`{"providers": {"openai": {}}}`) - loadedConfig, err := loadFromReaders([]io.Reader{data1, data2, data3}) + loadedConfig, err := loadFromBytes([][]byte{data1, data2, data3}) require.NoError(t, err) require.NotNil(t, loadedConfig) diff --git a/internal/config/merge.go b/internal/config/merge.go deleted file mode 100644 index 3c9b7d6283a193166ad50730b28853a909f5158a..0000000000000000000000000000000000000000 --- a/internal/config/merge.go +++ /dev/null @@ -1,16 +0,0 @@ -package config - -import ( - "bytes" - "io" - - "github.com/qjebbs/go-jsons" -) - -func Merge(data []io.Reader) (io.Reader, error) { - got, err := jsons.Merge(data) - if err != nil { - return nil, err - } - return bytes.NewReader(got), nil -} diff --git a/internal/config/merge_test.go b/internal/config/merge_test.go deleted file mode 100644 index 1b721bf2e8e4b4596025c2c773bec0093778f430..0000000000000000000000000000000000000000 --- a/internal/config/merge_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package config - -import ( - "io" - "strings" - "testing" -) - -func TestMerge(t *testing.T) { - data1 := strings.NewReader(`{"foo": "bar"}`) - data2 := strings.NewReader(`{"baz": "qux"}`) - - merged, err := Merge([]io.Reader{data1, data2}) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - expected := `{"foo":"bar","baz":"qux"}` - got, err := io.ReadAll(merged) - if err != nil { - t.Fatalf("expected no error reading merged data, got %v", err) - } - - if string(got) != expected { - t.Errorf("expected %s, got %s", expected, string(got)) - } -} From 6f646c9151a63d2c609f349847ef670420dddc4e Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 12 Jan 2026 10:37:05 -0300 Subject: [PATCH 17/58] perf: improve startup and shutdown speed (#1829) Signed-off-by: Carlos Alexandro Becker --- internal/agent/agent.go | 5 +++++ internal/agent/coordinator.go | 17 +++++++++++------ internal/agent/tools/mcp/init.go | 29 ++++++++++++++--------------- internal/app/app.go | 11 +++++++---- internal/lsp/client.go | 4 ---- internal/shell/background.go | 26 +++++++++++++++++++------- internal/skills/skills.go | 2 +- 7 files changed, 57 insertions(+), 37 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index dae219a53f5387e45dcb084fa6ac5ae4a7165a4b..c64a4c7838b8545eed8bbaa3e32f33bab437f8d6 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -68,6 +68,7 @@ type SessionAgent interface { Run(context.Context, SessionAgentCall) (*fantasy.AgentResult, error) SetModels(large Model, small Model) SetTools(tools []fantasy.AgentTool) + SetSystemPrompt(systemPrompt string) Cancel(sessionID string) CancelAll() IsSessionBusy(sessionID string) bool @@ -964,6 +965,10 @@ func (a *sessionAgent) SetTools(tools []fantasy.AgentTool) { a.tools = tools } +func (a *sessionAgent) SetSystemPrompt(systemPrompt string) { + a.systemPrompt = systemPrompt +} + func (a *sessionAgent) Model() Model { return a.largeModel } diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index b13603bb131090c86eaff3f6ea9527cb1b9dacf6..d48c074ff5d9c02db1427aa44ee13aed4c9af7e2 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -322,17 +322,12 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age return nil, err } - systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg) - if err != nil { - return nil, err - } - largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider) result := NewSessionAgent(SessionAgentOptions{ large, small, largeProviderCfg.SystemPromptPrefix, - systemPrompt, + "", isSubAgent, c.cfg.Options.DisableAutoSummarize, c.permissions.SkipRequests(), @@ -340,6 +335,16 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age c.messages, nil, }) + + c.readyWg.Go(func() error { + systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg) + if err != nil { + return err + } + result.SetSystemPrompt(systemPrompt) + return nil + }) + c.readyWg.Go(func() error { tools, err := c.buildTools(ctx, agent) if err != nil { diff --git a/internal/agent/tools/mcp/init.go b/internal/agent/tools/mcp/init.go index c28da6c1722413d276bbb47b3dbf3e9f66826263..bb43f7f157dc1cf2d094354a4e709e0beb1f52b6 100644 --- a/internal/agent/tools/mcp/init.go +++ b/internal/agent/tools/mcp/init.go @@ -107,29 +107,28 @@ func GetState(name string) (ClientInfo, bool) { // Close closes all MCP clients. This should be called during application shutdown. func Close() error { - var errs []error var wg sync.WaitGroup - for name, session := range sessions.Seq2() { - wg.Go(func() { - done := make(chan bool, 1) - go func() { + done := make(chan struct{}, 1) + go func() { + for name, session := range sessions.Seq2() { + wg.Go(func() { if err := session.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)) + slog.Warn("Failed to shutdown MCP client", "name", name, "error", err) } - done <- true - }() - select { - case <-done: - case <-time.After(time.Millisecond * 250): - } - }) + }) + } + wg.Wait() + done <- struct{}{} + }() + select { + case <-done: + case <-time.After(5 * time.Second): } - wg.Wait() broker.Shutdown() - return errors.Join(errs...) + return nil } // Initialize initializes MCP clients based on the provided configuration. diff --git a/internal/app/app.go b/internal/app/app.go index 436579d0b9593a1f9fd36606ae1e1b81fd89e737..24f1500cc707a16964f00a6eac1a62c0ac094850 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -405,12 +405,15 @@ func (app *App) Shutdown() { }) // Shutdown all LSP clients. + shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second) + defer cancel() for name, client := range app.LSPClients.Seq2() { wg.Go(func() { - shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second) - defer cancel() - if err := client.Close(shutdownCtx); err != nil { - slog.Error("Failed to shutdown LSP client", "name", name, "error", err) + if err := client.Close(shutdownCtx); err != nil && + !errors.Is(err, io.EOF) && + !errors.Is(err, context.Canceled) && + err.Error() != "signal: killed" { + slog.Warn("Failed to shutdown LSP client", "name", name, "error", err) } }) } diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 7dba52fdf48a2205bc9fca8436390326be2a2d39..79220cc1f315fec30a1bee2aa0dcd106bc311a02 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -153,10 +153,6 @@ func (c *Client) Initialize(ctx context.Context, workspaceDir string) (*protocol // Close closes the LSP client. func (c *Client) Close(ctx context.Context) error { - // Try to close all open files first - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - c.CloseAllFiles(ctx) // Shutdown and exit the client diff --git a/internal/shell/background.go b/internal/shell/background.go index 37dd5d909adb0ef2230a4a84ab394851dd8167a4..bc81369ec877586c92fa9bc701d8b78b669f23d5 100644 --- a/internal/shell/background.go +++ b/internal/shell/background.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "slices" "sync" "sync/atomic" "time" @@ -163,15 +164,26 @@ func (m *BackgroundShellManager) Cleanup() int { // KillAll terminates all background shells. func (m *BackgroundShellManager) KillAll() { - shells := make([]*BackgroundShell, 0, m.shells.Len()) - for shell := range m.shells.Seq() { - shells = append(shells, shell) - } + shells := slices.Collect(m.shells.Seq()) m.shells.Reset(map[string]*BackgroundShell{}) + done := make(chan struct{}, 1) + go func() { + var wg sync.WaitGroup + for _, shell := range shells { + wg.Go(func() { + shell.cancel() + <-shell.done + }) + } + wg.Wait() + done <- struct{}{} + }() - for _, shell := range shells { - shell.cancel() - <-shell.done + select { + case <-done: + return + case <-time.After(time.Second * 5): + return } } diff --git a/internal/skills/skills.go b/internal/skills/skills.go index cba0b994d188e184fdcb55cf98bd080764e34327..e0488cff4a8c42ceee68a6a89608bd90acafdb2e 100644 --- a/internal/skills/skills.go +++ b/internal/skills/skills.go @@ -147,7 +147,7 @@ func Discover(paths []string) []*Skill { slog.Warn("Skill validation failed", "path", path, "error", err) return nil } - slog.Info("Successfully loaded skill", "name", skill.Name, "path", path) + slog.Debug("Successfully loaded skill", "name", skill.Name, "path", path) mu.Lock() skills = append(skills, skill) mu.Unlock() From 163abdc1c4903bebbe19643e73f0335740ec7c6d Mon Sep 17 00:00:00 2001 From: Jon Gjengset Date: Mon, 12 Jan 2026 15:10:11 +0100 Subject: [PATCH 18/58] feat: add `disable_default_providers` option (#1675) --- internal/config/config.go | 1 + internal/config/load.go | 12 ++ internal/config/load_test.go | 211 +++++++++++++++++++++++++++++++++++ 3 files changed, 224 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index 31aeb4f06f7c8b9063579d9232310ac3a99befe0..2c414e3e9e35d6f232e00762f50aca1066aca321 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -248,6 +248,7 @@ type Options struct { DataDirectory string `json:"data_directory,omitempty" jsonschema:"description=Directory for storing application data (relative to working directory),default=.crush,example=.crush"` // Relative to the cwd DisabledTools []string `json:"disabled_tools,omitempty" jsonschema:"description=List of built-in tools to disable and hide from the agent,example=bash,example=sourcegraph"` DisableProviderAutoUpdate bool `json:"disable_provider_auto_update,omitempty" jsonschema:"description=Disable providers auto-update,default=false"` + DisableDefaultProviders bool `json:"disable_default_providers,omitempty" jsonschema:"description=Ignore all default/embedded providers. When enabled, providers must be fully specified in the config file with base_url, models, and api_key - no merging with defaults occurs,default=false"` Attribution *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"` DisableMetrics bool `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"` InitializeAs string `json:"initialize_as,omitempty" jsonschema:"description=Name of the context file to create/update during project initialization,default=AGENTS.md,example=AGENTS.md,example=CRUSH.md,example=CLAUDE.md,example=docs/LLMs.md"` diff --git a/internal/config/load.go b/internal/config/load.go index 8323c07ac9d4d7d6cd801d617cc6c40da2377bba..4d7ea133b3034c46cc4e9b4097e372b7f18aa7a9 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -122,6 +122,14 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know restore := PushPopCrushEnv() defer restore() + // When disable_default_providers is enabled, skip all default/embedded + // providers entirely. Users must fully specify any providers they want. + // We skip to the custom provider validation loop which handles all + // user-configured providers uniformly. + if c.Options.DisableDefaultProviders { + knownProviders = nil + } + for _, p := range knownProviders { knownProviderNames[string(p.ID)] = true config, configExists := c.Providers.Get(string(p.ID)) @@ -362,6 +370,10 @@ func (c *Config) setDefaults(workingDir, dataDir string) { c.Options.DisableProviderAutoUpdate, _ = strconv.ParseBool(str) } + if str, ok := os.LookupEnv("CRUSH_DISABLE_DEFAULT_PROVIDERS"); ok { + c.Options.DisableDefaultProviders, _ = strconv.ParseBool(str) + } + if c.Options.Attribution == nil { c.Options.Attribution = &Attribution{ TrailerStyle: TrailerStyleAssistedBy, diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 47cb0c5ec0ef8ae0266ed77b47fa60834d596399..8924475ef9c652ea1962e4f032a0e62e560bce7a 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -1094,6 +1094,217 @@ func TestConfig_defaultModelSelection(t *testing.T) { }) } +func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) { + t.Run("when enabled, ignores all default providers and requires full specification", func(t *testing.T) { + knownProviders := []catwalk.Provider{ + { + ID: "openai", + APIKey: "$OPENAI_API_KEY", + APIEndpoint: "https://api.openai.com/v1", + Models: []catwalk.Model{{ + ID: "gpt-4", + }}, + }, + } + + // User references openai but doesn't fully specify it (no base_url, no + // models). This should be rejected because disable_default_providers + // treats all providers as custom. + cfg := &Config{ + Options: &Options{ + DisableDefaultProviders: true, + }, + Providers: csync.NewMapFrom(map[string]ProviderConfig{ + "openai": { + APIKey: "$OPENAI_API_KEY", + }, + }), + } + cfg.setDefaults("/tmp", "") + + env := env.NewFromMap(map[string]string{ + "OPENAI_API_KEY": "test-key", + }) + resolver := NewEnvironmentVariableResolver(env) + err := cfg.configureProviders(env, resolver, knownProviders) + require.NoError(t, err) + + // openai should NOT be present because it lacks base_url and models. + require.Equal(t, 0, cfg.Providers.Len()) + _, exists := cfg.Providers.Get("openai") + require.False(t, exists, "openai should not be present without full specification") + }) + + t.Run("when enabled, fully specified providers work", func(t *testing.T) { + knownProviders := []catwalk.Provider{ + { + ID: "openai", + APIKey: "$OPENAI_API_KEY", + APIEndpoint: "https://api.openai.com/v1", + Models: []catwalk.Model{{ + ID: "gpt-4", + }}, + }, + } + + // User fully specifies their provider. + cfg := &Config{ + Options: &Options{ + DisableDefaultProviders: true, + }, + Providers: csync.NewMapFrom(map[string]ProviderConfig{ + "my-llm": { + APIKey: "$MY_API_KEY", + BaseURL: "https://my-llm.example.com/v1", + Models: []catwalk.Model{{ + ID: "my-model", + }}, + }, + }), + } + cfg.setDefaults("/tmp", "") + + env := env.NewFromMap(map[string]string{ + "MY_API_KEY": "test-key", + "OPENAI_API_KEY": "test-key", + }) + resolver := NewEnvironmentVariableResolver(env) + err := cfg.configureProviders(env, resolver, knownProviders) + require.NoError(t, err) + + // Only fully specified provider should be present. + require.Equal(t, 1, cfg.Providers.Len()) + provider, exists := cfg.Providers.Get("my-llm") + require.True(t, exists, "my-llm should be present") + require.Equal(t, "https://my-llm.example.com/v1", provider.BaseURL) + require.Len(t, provider.Models, 1) + + // Default openai should NOT be present. + _, exists = cfg.Providers.Get("openai") + require.False(t, exists, "openai should not be present") + }) + + t.Run("when disabled, includes all known providers with valid credentials", func(t *testing.T) { + knownProviders := []catwalk.Provider{ + { + ID: "openai", + APIKey: "$OPENAI_API_KEY", + APIEndpoint: "https://api.openai.com/v1", + Models: []catwalk.Model{{ + ID: "gpt-4", + }}, + }, + { + ID: "anthropic", + APIKey: "$ANTHROPIC_API_KEY", + APIEndpoint: "https://api.anthropic.com/v1", + Models: []catwalk.Model{{ + ID: "claude-3", + }}, + }, + } + + // User only configures openai, both API keys are available, but option + // is disabled. + cfg := &Config{ + Options: &Options{ + DisableDefaultProviders: false, + }, + Providers: csync.NewMapFrom(map[string]ProviderConfig{ + "openai": { + APIKey: "$OPENAI_API_KEY", + }, + }), + } + cfg.setDefaults("/tmp", "") + + env := env.NewFromMap(map[string]string{ + "OPENAI_API_KEY": "test-key", + "ANTHROPIC_API_KEY": "test-key", + }) + resolver := NewEnvironmentVariableResolver(env) + err := cfg.configureProviders(env, resolver, knownProviders) + require.NoError(t, err) + + // Both providers should be present. + require.Equal(t, 2, cfg.Providers.Len()) + _, exists := cfg.Providers.Get("openai") + require.True(t, exists, "openai should be present") + _, exists = cfg.Providers.Get("anthropic") + require.True(t, exists, "anthropic should be present") + }) + + t.Run("when enabled, provider missing models is rejected", func(t *testing.T) { + cfg := &Config{ + Options: &Options{ + DisableDefaultProviders: true, + }, + Providers: csync.NewMapFrom(map[string]ProviderConfig{ + "my-llm": { + APIKey: "test-key", + BaseURL: "https://my-llm.example.com/v1", + Models: []catwalk.Model{}, // No models. + }, + }), + } + cfg.setDefaults("/tmp", "") + + env := env.NewFromMap(map[string]string{}) + resolver := NewEnvironmentVariableResolver(env) + err := cfg.configureProviders(env, resolver, []catwalk.Provider{}) + require.NoError(t, err) + + // Provider should be rejected for missing models. + require.Equal(t, 0, cfg.Providers.Len()) + }) + + t.Run("when enabled, provider missing base_url is rejected", func(t *testing.T) { + cfg := &Config{ + Options: &Options{ + DisableDefaultProviders: true, + }, + Providers: csync.NewMapFrom(map[string]ProviderConfig{ + "my-llm": { + APIKey: "test-key", + Models: []catwalk.Model{{ID: "model"}}, + // No BaseURL. + }, + }), + } + cfg.setDefaults("/tmp", "") + + env := env.NewFromMap(map[string]string{}) + resolver := NewEnvironmentVariableResolver(env) + err := cfg.configureProviders(env, resolver, []catwalk.Provider{}) + require.NoError(t, err) + + // Provider should be rejected for missing base_url. + require.Equal(t, 0, cfg.Providers.Len()) + }) +} + +func TestConfig_setDefaultsDisableDefaultProvidersEnvVar(t *testing.T) { + t.Run("sets option from environment variable", func(t *testing.T) { + t.Setenv("CRUSH_DISABLE_DEFAULT_PROVIDERS", "true") + + cfg := &Config{} + cfg.setDefaults("/tmp", "") + + require.True(t, cfg.Options.DisableDefaultProviders) + }) + + t.Run("does not override when env var is not set", func(t *testing.T) { + cfg := &Config{ + Options: &Options{ + DisableDefaultProviders: true, + }, + } + cfg.setDefaults("/tmp", "") + + require.True(t, cfg.Options.DisableDefaultProviders) + }) +} + func TestConfig_configureSelectedModels(t *testing.T) { t.Run("should override defaults", func(t *testing.T) { knownProviders := []catwalk.Provider{ From b2252f8f98ff5f60e8757b1fc47defeff2f4ae3a Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Mon, 12 Jan 2026 14:10:55 +0000 Subject: [PATCH 19/58] chore: auto-update files --- schema.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/schema.json b/schema.json index a2d88bfd5f4be210534209694a9f0c0eb5c993c0..d92c398cc84704e69975f501c5f96e26ebfa7d1e 100644 --- a/schema.json +++ b/schema.json @@ -421,6 +421,11 @@ "description": "Disable providers auto-update", "default": false }, + "disable_default_providers": { + "type": "boolean", + "description": "Ignore all default/embedded providers. When enabled", + "default": false + }, "attribution": { "$ref": "#/$defs/Attribution", "description": "Attribution settings for generated content" From 6f0f5197e36c494d69ace2ee3d011696d5b656fc Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 12 Jan 2026 15:11:51 +0100 Subject: [PATCH 20/58] Fix shutdown (#1833) --- internal/agent/agent.go | 11 +++++++---- internal/agent/agentic_fetch_tool.go | 6 ++++-- internal/agent/tools/bash.go | 5 ++++- internal/agent/tools/download.go | 6 ++++-- internal/agent/tools/edit.go | 15 ++++++++++++--- internal/agent/tools/fetch.go | 6 ++++-- internal/agent/tools/ls.go | 6 ++++-- internal/agent/tools/mcp-tools.go | 5 ++++- internal/agent/tools/multiedit.go | 12 +++++++++--- internal/agent/tools/multiedit_test.go | 4 ++-- internal/agent/tools/view.go | 6 ++++-- internal/agent/tools/write.go | 5 ++++- internal/app/app.go | 11 +++++++---- internal/permission/permission.go | 21 +++++++++++++-------- internal/permission/permission_test.go | 22 ++++++++++++++-------- 15 files changed, 96 insertions(+), 45 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index c64a4c7838b8545eed8bbaa3e32f33bab437f8d6..8d2fa40fd427143bf988587ef7faa3a89c3e23b1 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -445,7 +445,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy Content: content, IsError: true, } - _, createErr = a.messages.Create(context.Background(), currentAssistant.SessionID, message.CreateMessageParams{ + _, createErr = a.messages.Create(ctx, currentAssistant.SessionID, message.CreateMessageParams{ Role: message.Tool, Parts: []message.ContentPart{ toolResult, @@ -876,14 +876,17 @@ func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, } func (a *sessionAgent) Cancel(sessionID string) { - // Cancel regular requests. - if cancel, ok := a.activeRequests.Take(sessionID); ok && cancel != nil { + // Cancel regular requests. Don't use Take() here - we need the entry to + // remain in activeRequests so IsBusy() returns true until the goroutine + // fully completes (including error handling that may access the DB). + // The defer in processRequest will clean up the entry. + if cancel, ok := a.activeRequests.Get(sessionID); ok && cancel != nil { slog.Info("Request cancellation initiated", "session_id", sessionID) cancel() } // Also check for summarize requests. - if cancel, ok := a.activeRequests.Take(sessionID + "-summarize"); ok && cancel != nil { + if cancel, ok := a.activeRequests.Get(sessionID + "-summarize"); ok && cancel != nil { slog.Info("Summarize cancellation initiated", "session_id", sessionID) cancel() } diff --git a/internal/agent/agentic_fetch_tool.go b/internal/agent/agentic_fetch_tool.go index 333ec7926f80735c3798c524378964a8e41fe3e4..89d3535720f8452111f12f4df4eb691e39253bed 100644 --- a/internal/agent/agentic_fetch_tool.go +++ b/internal/agent/agentic_fetch_tool.go @@ -79,7 +79,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) ( description = "Search the web and analyze results" } - p := c.permissions.Request( + p, err := c.permissions.Request(ctx, permission.CreatePermissionRequest{ SessionID: validationResult.SessionID, Path: c.cfg.WorkingDir(), @@ -90,7 +90,9 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) ( Params: tools.AgenticFetchPermissionsParams(params), }, ) - + if err != nil { + return fantasy.ToolResponse{}, err + } if !p { return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } diff --git a/internal/agent/tools/bash.go b/internal/agent/tools/bash.go index c3f0bc8cd24a6c4ff7c6f775e357c90b3dc99802..ca3612e091f23235688a2a40006469e39093d6a5 100644 --- a/internal/agent/tools/bash.go +++ b/internal/agent/tools/bash.go @@ -215,7 +215,7 @@ func NewBashTool(permissions permission.Service, workingDir string, attribution return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for executing shell command") } if !isSafeReadOnly { - p := permissions.Request( + p, err := permissions.Request(ctx, permission.CreatePermissionRequest{ SessionID: sessionID, Path: execWorkingDir, @@ -226,6 +226,9 @@ func NewBashTool(permissions permission.Service, workingDir string, attribution Params: BashPermissionsParams(params), }, ) + if err != nil { + return fantasy.ToolResponse{}, err + } if !p { return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } diff --git a/internal/agent/tools/download.go b/internal/agent/tools/download.go index 353b312a29d410c6485f76fc8dd42a4b9dcdefb1..8f3f224b9e5647911d3c7e1cc5a668eea18b1785 100644 --- a/internal/agent/tools/download.go +++ b/internal/agent/tools/download.go @@ -70,7 +70,7 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client * return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for downloading files") } - p := permissions.Request( + p, err := permissions.Request(ctx, permission.CreatePermissionRequest{ SessionID: sessionID, Path: filePath, @@ -80,7 +80,9 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client * Params: DownloadPermissionsParams(params), }, ) - + if err != nil { + return fantasy.ToolResponse{}, err + } if !p { return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } diff --git a/internal/agent/tools/edit.go b/internal/agent/tools/edit.go index e4503e8127a750647c659353a018d36ee42643a1..a3680d009c6d76f8bcb3e39f1c1ddd2041aa1e52 100644 --- a/internal/agent/tools/edit.go +++ b/internal/agent/tools/edit.go @@ -122,7 +122,7 @@ func createNewFile(edit editContext, filePath, content string, call fantasy.Tool content, strings.TrimPrefix(filePath, edit.workingDir), ) - p := edit.permissions.Request( + p, err := edit.permissions.Request(edit.ctx, permission.CreatePermissionRequest{ SessionID: sessionID, Path: fsext.PathOrPrefix(filePath, edit.workingDir), @@ -137,6 +137,9 @@ func createNewFile(edit editContext, filePath, content string, call fantasy.Tool }, }, ) + if err != nil { + return fantasy.ToolResponse{}, err + } if !p { return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } @@ -243,7 +246,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool strings.TrimPrefix(filePath, edit.workingDir), ) - p := edit.permissions.Request( + p, err := edit.permissions.Request(edit.ctx, permission.CreatePermissionRequest{ SessionID: sessionID, Path: fsext.PathOrPrefix(filePath, edit.workingDir), @@ -258,6 +261,9 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool }, }, ) + if err != nil { + return fantasy.ToolResponse{}, err + } if !p { return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } @@ -378,7 +384,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep strings.TrimPrefix(filePath, edit.workingDir), ) - p := edit.permissions.Request( + p, err := edit.permissions.Request(edit.ctx, permission.CreatePermissionRequest{ SessionID: sessionID, Path: fsext.PathOrPrefix(filePath, edit.workingDir), @@ -393,6 +399,9 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep }, }, ) + if err != nil { + return fantasy.ToolResponse{}, err + } if !p { return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } diff --git a/internal/agent/tools/fetch.go b/internal/agent/tools/fetch.go index b23da7099be7ad0b5e3cc7076426c7494e8a3202..29fa6f15b5a90fe2dd8d34ef383990b892b742c3 100644 --- a/internal/agent/tools/fetch.go +++ b/internal/agent/tools/fetch.go @@ -55,7 +55,7 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file") } - p := permissions.Request( + p, err := permissions.Request(ctx, permission.CreatePermissionRequest{ SessionID: sessionID, Path: workingDir, @@ -66,7 +66,9 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt Params: FetchPermissionsParams(params), }, ) - + if err != nil { + return fantasy.ToolResponse{}, err + } if !p { return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } diff --git a/internal/agent/tools/ls.go b/internal/agent/tools/ls.go index 2a6627741256339a319ec734c4ff766b041e5670..eff7bac0757b5956f669a752c378cab548affb85 100644 --- a/internal/agent/tools/ls.go +++ b/internal/agent/tools/ls.go @@ -79,7 +79,7 @@ func NewLsTool(permissions permission.Service, workingDir string, lsConfig confi return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing directories outside working directory") } - granted := permissions.Request( + granted, err := permissions.Request(ctx, permission.CreatePermissionRequest{ SessionID: sessionID, Path: absSearchPath, @@ -90,7 +90,9 @@ func NewLsTool(permissions permission.Service, workingDir string, lsConfig confi Params: LSPermissionsParams(params), }, ) - + if err != nil { + return fantasy.ToolResponse{}, err + } if !granted { return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } diff --git a/internal/agent/tools/mcp-tools.go b/internal/agent/tools/mcp-tools.go index 5b4302cc5e16adedea18bdc767d2312f8d920f82..fa55f03728639a09e6bd2f150338238d30120883 100644 --- a/internal/agent/tools/mcp-tools.go +++ b/internal/agent/tools/mcp-tools.go @@ -89,7 +89,7 @@ func (m *Tool) Run(ctx context.Context, params fantasy.ToolCall) (fantasy.ToolRe return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file") } permissionDescription := fmt.Sprintf("execute %s with the following parameters:", m.Info().Name) - p := m.permissions.Request( + p, err := m.permissions.Request(ctx, permission.CreatePermissionRequest{ SessionID: sessionID, ToolCallID: params.ID, @@ -100,6 +100,9 @@ func (m *Tool) Run(ctx context.Context, params fantasy.ToolCall) (fantasy.ToolRe Params: params.Input, }, ) + if err != nil { + return fantasy.ToolResponse{}, err + } if !p { return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } diff --git a/internal/agent/tools/multiedit.go b/internal/agent/tools/multiedit.go index 9136c37fadb914cb1c560e3fa5f2b6208fc3ead5..0640228d23230e6a49d8e1405f371c099031fbf7 100644 --- a/internal/agent/tools/multiedit.go +++ b/internal/agent/tools/multiedit.go @@ -173,7 +173,7 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call } else { description = fmt.Sprintf("Create file %s with %d edits", params.FilePath, editsApplied) } - p := edit.permissions.Request(permission.CreatePermissionRequest{ + p, err := edit.permissions.Request(edit.ctx, permission.CreatePermissionRequest{ SessionID: sessionID, Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir), ToolCallID: call.ID, @@ -186,12 +186,15 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call NewContent: currentContent, }, }) + if err != nil { + return fantasy.ToolResponse{}, err + } if !p { return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } // Write the file - err := os.WriteFile(params.FilePath, []byte(currentContent), 0o644) + err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644) if err != nil { return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err) } @@ -314,7 +317,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call } else { description = fmt.Sprintf("Apply %d edits to file %s", editsApplied, params.FilePath) } - p := edit.permissions.Request(permission.CreatePermissionRequest{ + p, err := edit.permissions.Request(edit.ctx, permission.CreatePermissionRequest{ SessionID: sessionID, Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir), ToolCallID: call.ID, @@ -327,6 +330,9 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call NewContent: currentContent, }, }) + if err != nil { + return fantasy.ToolResponse{}, err + } if !p { return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } diff --git a/internal/agent/tools/multiedit_test.go b/internal/agent/tools/multiedit_test.go index 36d0a0d469f67aa11cf36cd0bce3efffb4bab683..b6d575435e63dcd62a4dc9a7efb76cf13c14ad05 100644 --- a/internal/agent/tools/multiedit_test.go +++ b/internal/agent/tools/multiedit_test.go @@ -19,8 +19,8 @@ type mockPermissionService struct { *pubsub.Broker[permission.PermissionRequest] } -func (m *mockPermissionService) Request(req permission.CreatePermissionRequest) bool { - return true +func (m *mockPermissionService) Request(ctx context.Context, req permission.CreatePermissionRequest) (bool, error) { + return true, nil } func (m *mockPermissionService) Grant(req permission.PermissionRequest) {} diff --git a/internal/agent/tools/view.go b/internal/agent/tools/view.go index 7129a91b4b526bfdd27c97987b84aeae38d33068..96150669c292d457f7e8f1bb514f2a209bb73b6e 100644 --- a/internal/agent/tools/view.go +++ b/internal/agent/tools/view.go @@ -88,7 +88,7 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory") } - granted := permissions.Request( + granted, err := permissions.Request(ctx, permission.CreatePermissionRequest{ SessionID: sessionID, Path: absFilePath, @@ -99,7 +99,9 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss Params: ViewPermissionsParams(params), }, ) - + if err != nil { + return fantasy.ToolResponse{}, err + } if !granted { return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } diff --git a/internal/agent/tools/write.go b/internal/agent/tools/write.go index 4ffd44a0553d1a1646d20dac557ab4e1bc47f45a..bbd5e50cf863d4d13503f6cee926b57df80f69bc 100644 --- a/internal/agent/tools/write.go +++ b/internal/agent/tools/write.go @@ -111,7 +111,7 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis strings.TrimPrefix(filePath, workingDir), ) - p := permissions.Request( + p, err := permissions.Request(ctx, permission.CreatePermissionRequest{ SessionID: sessionID, Path: fsext.PathOrPrefix(filePath, workingDir), @@ -126,6 +126,9 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis }, }, ) + if err != nil { + return fantasy.ToolResponse{}, err + } if !p { return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } diff --git a/internal/app/app.go b/internal/app/app.go index 24f1500cc707a16964f00a6eac1a62c0ac094850..08762f863a7d9cf77751d0c2c4095591f002eab0 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -392,13 +392,16 @@ func (app *App) Subscribe(program *tea.Program) { func (app *App) Shutdown() { start := time.Now() defer func() { slog.Info("Shutdown took " + time.Since(start).String()) }() - var wg sync.WaitGroup + + // First, cancel all agents and wait for them to finish. This must complete + // before closing the DB so agents can finish writing their state. if app.AgentCoordinator != nil { - wg.Go(func() { - app.AgentCoordinator.CancelAll() - }) + app.AgentCoordinator.CancelAll() } + // Now run remaining cleanup tasks in parallel. + var wg sync.WaitGroup + // Kill all background shells. wg.Go(func() { shell.GetBackgroundShellManager().KillAll() diff --git a/internal/permission/permission.go b/internal/permission/permission.go index 829dd2ed90abf4d45b63481eacebb492cadabdfd..9dc85e976238fdbe1ff2d3689b2a2c4160608760 100644 --- a/internal/permission/permission.go +++ b/internal/permission/permission.go @@ -47,7 +47,7 @@ type Service interface { GrantPersistent(permission PermissionRequest) Grant(permission PermissionRequest) Deny(permission PermissionRequest) - Request(opts CreatePermissionRequest) bool + Request(ctx context.Context, opts CreatePermissionRequest) (bool, error) AutoApproveSession(sessionID string) SetSkipRequests(skip bool) SkipRequests() bool @@ -122,9 +122,9 @@ func (s *permissionService) Deny(permission PermissionRequest) { } } -func (s *permissionService) Request(opts CreatePermissionRequest) bool { +func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRequest) (bool, error) { if s.skip { - return true + return true, nil } // tell the UI that a permission was requested @@ -137,7 +137,7 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool { // Check if the tool/action combination is in the allowlist commandKey := opts.ToolName + ":" + opts.Action if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) { - return true + return true, nil } s.autoApproveSessionsMu.RLock() @@ -145,7 +145,7 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool { s.autoApproveSessionsMu.RUnlock() if autoApprove { - return true + return true, nil } fileInfo, err := os.Stat(opts.Path) @@ -176,7 +176,7 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool { for _, p := range s.sessionPermissions { if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path { s.sessionPermissionsMu.RUnlock() - return true + return true, nil } } s.sessionPermissionsMu.RUnlock() @@ -185,7 +185,7 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool { for _, p := range s.sessionPermissions { if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path { s.sessionPermissionsMu.RUnlock() - return true + return true, nil } } s.sessionPermissionsMu.RUnlock() @@ -199,7 +199,12 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool { // Publish the request s.Publish(pubsub.CreatedEvent, permission) - return <-respCh + select { + case <-ctx.Done(): + return false, ctx.Err() + case granted := <-respCh: + return granted, nil + } } func (s *permissionService) AutoApproveSession(sessionID string) { diff --git a/internal/permission/permission_test.go b/internal/permission/permission_test.go index d1ccd286836768f1bc1119966568941f7494affd..89e06916024cd1669f5e0d0a263d4a71548c8a97 100644 --- a/internal/permission/permission_test.go +++ b/internal/permission/permission_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestPermissionService_AllowedCommands(t *testing.T) { @@ -81,14 +82,16 @@ func TestPermissionService_AllowedCommands(t *testing.T) { func TestPermissionService_SkipMode(t *testing.T) { service := NewPermissionService("/tmp", true, []string{}) - result := service.Request(CreatePermissionRequest{ + result, err := service.Request(t.Context(), CreatePermissionRequest{ SessionID: "test-session", ToolName: "bash", Action: "execute", Description: "test command", Path: "/tmp", }) - + if err != nil { + t.Errorf("unexpected error: %v", err) + } if !result { t.Error("expected permission to be granted in skip mode") } @@ -115,7 +118,7 @@ func TestPermissionService_SequentialProperties(t *testing.T) { go func() { defer wg.Done() - result1 = service.Request(req1) + result1, _ = service.Request(t.Context(), req1) }() var permissionReq PermissionRequest @@ -136,7 +139,8 @@ func TestPermissionService_SequentialProperties(t *testing.T) { Params: map[string]string{"file": "test.txt"}, Path: "/tmp/test.txt", } - result2 := service.Request(req2) + result2, err := service.Request(t.Context(), req2) + require.NoError(t, err) assert.True(t, result2, "Second request should be auto-approved") }) t.Run("Sequential requests with temporary grants", func(t *testing.T) { @@ -156,7 +160,7 @@ func TestPermissionService_SequentialProperties(t *testing.T) { var wg sync.WaitGroup wg.Go(func() { - result1 = service.Request(req) + result1, _ = service.Request(t.Context(), req) }) var permissionReq PermissionRequest @@ -170,7 +174,7 @@ func TestPermissionService_SequentialProperties(t *testing.T) { var result2 bool wg.Go(func() { - result2 = service.Request(req) + result2, _ = service.Request(t.Context(), req) }) event = <-events @@ -215,7 +219,8 @@ func TestPermissionService_SequentialProperties(t *testing.T) { wg.Add(1) go func(index int, request CreatePermissionRequest) { defer wg.Done() - results = append(results, service.Request(request)) + result, _ := service.Request(t.Context(), request) + results = append(results, result) }(i, req) } @@ -241,7 +246,8 @@ func TestPermissionService_SequentialProperties(t *testing.T) { assert.Equal(t, 2, grantedCount, "Should have 2 granted and 1 denied") secondReq := requests[1] secondReq.Description = "Repeat of second request" - result := service.Request(secondReq) + result, err := service.Request(t.Context(), secondReq) + require.NoError(t, err) assert.True(t, result, "Repeated request should be auto-approved due to persistent permission") }) } From 33a41c5d2df11958e7631ae68036f252023f1b36 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 18:30:40 +0000 Subject: [PATCH 21/58] chore(deps): bump golang.org/x/net from 0.48.0 to 0.49.0 in the all group (#1837) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 81df5359521def733c75bfe02465d17fcbdf4df8..8d0b8efaa315b3c38f32690fa4076e384bf947de 100644 --- a/go.mod +++ b/go.mod @@ -58,7 +58,7 @@ require ( github.com/tidwall/sjson v1.2.5 github.com/zeebo/xxh3 v1.0.2 golang.org/x/mod v0.32.0 - golang.org/x/net v0.48.0 + golang.org/x/net v0.49.0 golang.org/x/sync v0.19.0 golang.org/x/text v0.33.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 @@ -169,12 +169,12 @@ require ( go.opentelemetry.io/otel/trace v1.37.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect - golang.org/x/crypto v0.46.0 // indirect + golang.org/x/crypto v0.47.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/image v0.27.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/term v0.38.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.39.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/api v0.239.0 // indirect google.golang.org/genai v1.40.0 // indirect diff --git a/go.sum b/go.sum index 1ca5a73d4a20738911fe93263cc7851b8ab0965e..5c0ddbe41a1e09b9103315e7da9fbdf1511e30e8 100644 --- a/go.sum +++ b/go.sum @@ -385,8 +385,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.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= 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.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= @@ -409,8 +409,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -438,8 +438,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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.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= @@ -451,8 +451,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.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= 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= From 9d9cfa1bf20db357350e4a053d8f554d158941b4 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 12 Jan 2026 17:30:48 -0300 Subject: [PATCH 23/58] fix: update fantasy with panic fix for google gemini (#1840) https://github.com/charmbracelet/fantasy/pull/116 --- go.mod | 28 ++++++++++++++-------------- go.sum | 56 ++++++++++++++++++++++++++++---------------------------- 2 files changed, 42 insertions(+), 42 deletions(-) diff --git a/go.mod b/go.mod index 8d0b8efaa315b3c38f32690fa4076e384bf947de..8959596f7feca0d6df1ce50e88712e8cc1058fa9 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.5 require ( charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66 charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e - charm.land/fantasy v0.6.0 + charm.land/fantasy v0.6.1 charm.land/glamour/v2 v2.0.0-20251110203732-69649f93d3b1 charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971 charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da @@ -77,20 +77,20 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect github.com/RealAlexandreAI/json-repair v0.0.14 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect - github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect - github.com/aws/aws-sdk-go-v2/config v1.32.6 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.6 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.7 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.7 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect github.com/aws/smithy-go v1.24.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect @@ -126,7 +126,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kaptinlin/go-i18n v0.2.2 // indirect github.com/kaptinlin/jsonpointer v0.4.8 // indirect - github.com/kaptinlin/jsonschema v0.6.5 // indirect + github.com/kaptinlin/jsonschema v0.6.6 // indirect github.com/kaptinlin/messageformat-go v0.4.7 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.0.9 // indirect @@ -177,7 +177,7 @@ require ( golang.org/x/term v0.39.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/api v0.239.0 // indirect - google.golang.org/genai v1.40.0 // indirect + google.golang.org/genai v1.41.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect google.golang.org/grpc v1.76.0 // indirect google.golang.org/protobuf v1.36.10 // indirect diff --git a/go.sum b/go.sum index 5c0ddbe41a1e09b9103315e7da9fbdf1511e30e8..70582b7c92f86af89a03d9f9a43382e27235d2ca 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66 h1:2BdJynsAW+8rv charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4= charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e h1:tXwTmgGpwZT7ParKF5xbEQBVjM2e1uKhKi/GpfU3mYQ= charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e/go.mod h1:pDM18flq3Z4njKZPA3zCvyVSSIJbMcoqlE82BdGUtL8= -charm.land/fantasy v0.6.0 h1:0PZfZ/w6c70UdlumGGFW6s9zTV6f4xAV/bXo6vGuZsc= -charm.land/fantasy v0.6.0/go.mod h1:hUyklhBbCtnVeMAWGXHbMD4A+5B8dHbYHGZDfOYpzzw= +charm.land/fantasy v0.6.1 h1:v3pavSHpZ5xTw98TpNYoj6DRq4ksCBWwJiZeiG/mVIc= +charm.land/fantasy v0.6.1/go.mod h1:Ifj41bNnIXJ1aF6sLKcS9y3MzWbDnObmcHrCaaHfpZ0= charm.land/glamour/v2 v2.0.0-20251110203732-69649f93d3b1 h1:9q4+yyU7105T3OrOx0csMyKnw89yMSijJ+rVld/Z2ek= charm.land/glamour/v2 v2.0.0-20251110203732-69649f93d3b1/go.mod h1:J3kVhY6oHXZq5f+8vC3hmDO95fEvbqj3z7xDwxrfzU8= charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971 h1:xZFcNsJMiIDbFtWRyDmkKNk1sjojfaom4Zoe0cyH/8c= @@ -48,34 +48,34 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= 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.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= -github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= +github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM= -github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8= -github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI= -github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE= -github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= +github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY= +github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -214,8 +214,8 @@ github.com/kaptinlin/go-i18n v0.2.2 h1:kebVCZme/BrCTqonh/J+VYCl1+Of5C18bvyn3DRPl github.com/kaptinlin/go-i18n v0.2.2/go.mod h1:MiwkeHryBopAhC/M3zEwIM/2IN8TvTqJQswPw6kceqM= github.com/kaptinlin/jsonpointer v0.4.8 h1:HocHcXrOBfP/nUJw0YYjed/TlQvuCAY6uRs3Qok7F6g= github.com/kaptinlin/jsonpointer v0.4.8/go.mod h1:9y0LgXavlmVE5FSHShY5LRlURJJVhbyVJSRWkilrTqA= -github.com/kaptinlin/jsonschema v0.6.5 h1:hC7upwWlvamWqeTVQ3ab20F4w0XKNKR1drY9apoqGOU= -github.com/kaptinlin/jsonschema v0.6.5/go.mod h1:EbhSbdxZ4QjzIORdMWOrRXJeCHrLTJqXDA8JzNaeFc8= +github.com/kaptinlin/jsonschema v0.6.6 h1:UmIF1amA5ijCGSk4tl4ViNlgYL4jzHHvY+Nd5cnkfDI= +github.com/kaptinlin/jsonschema v0.6.6/go.mod h1:EbhSbdxZ4QjzIORdMWOrRXJeCHrLTJqXDA8JzNaeFc8= github.com/kaptinlin/messageformat-go v0.4.7 h1:HQ/OvFUSU7+fAHWkZnP2ug9y+A/ZyTE8j33jfWr8O3Q= github.com/kaptinlin/messageformat-go v0.4.7/go.mod h1:DusKpv8CIybczGvwIVn3j13hbR3psr5mOwhFudkiq1c= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= @@ -479,8 +479,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.239.0 h1:2hZKUnFZEy81eugPs4e2XzIJ5SOwQg0G82bpXD65Puo= google.golang.org/api v0.239.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= -google.golang.org/genai v1.40.0 h1:kYxyQSH+vsib8dvsgyLJzsVEIv5k3ZmHJyVqdvGncmc= -google.golang.org/genai v1.40.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= +google.golang.org/genai v1.41.0 h1:ayXl75LjTmqTu0y94yr96d17gIb4zF8gWVzX2TgioEY= +google.golang.org/genai v1.41.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8= google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= From d6028d627e97ff0289fb0e91321702e78214e63a Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:35:30 -0300 Subject: [PATCH 24/58] chore(legal): @jeis4wpi 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 a18c4ccb7d31b709a1f19ababb62843a5a924349..7509ab47944078677d0f92a929c7d168070e1b9d 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1031,6 +1031,14 @@ "created_at": "2026-01-09T16:28:21Z", "repoId": 987670088, "pullRequestNo": 1811 + }, + { + "name": "jeis4wpi", + "id": 42679190, + "comment_id": 3735501265, + "created_at": "2026-01-11T19:19:03Z", + "repoId": 987670088, + "pullRequestNo": 1827 } ] } \ No newline at end of file From 71b54b223108f1b887175f64b59c0e66f03fe54b Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:35:41 -0300 Subject: [PATCH 25/58] chore(legal): @uppet 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 7509ab47944078677d0f92a929c7d168070e1b9d..c8fb31dbc93313867bdae72bba8d6de27f26667d 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1039,6 +1039,14 @@ "created_at": "2026-01-11T19:19:03Z", "repoId": 987670088, "pullRequestNo": 1827 + }, + { + "name": "uppet", + "id": 110209, + "comment_id": 3738688581, + "created_at": "2026-01-12T13:58:05Z", + "repoId": 987670088, + "pullRequestNo": 1830 } ] } \ No newline at end of file From dd0d6cf73d20a378961482aae74bbfbfca8d0a95 Mon Sep 17 00:00:00 2001 From: John Eismeier <42679190+jeis4wpi@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:40:18 -0500 Subject: [PATCH 26/58] chore: fix some typos --- README.md | 2 +- internal/cmd/login.go | 2 +- internal/tui/components/dialogs/copilot/device_flow.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4135ddea8c6209f05989e64ddc356a2244efbb03..929b77425f1b42452a4e38d8cfa540773dd54a79 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ scoop install crush
Nix (NUR) -Crush is available via the offical Charm [NUR](https://github.com/nix-community/NUR) in `nur.repos.charmbracelet.crush`, which is the most up-to-date way to get Crush in Nix. +Crush is available via the official Charm [NUR](https://github.com/nix-community/NUR) in `nur.repos.charmbracelet.crush`, which is the most up-to-date way to get Crush in Nix. You can also try out Crush via the NUR with `nix-shell`: diff --git a/internal/cmd/login.go b/internal/cmd/login.go index 07cc90d320ebd4817474a1a14553558caca5e950..b38eaeed00ad1def862d83145f256bc219c27fda 100644 --- a/internal/cmd/login.go +++ b/internal/cmd/login.go @@ -168,7 +168,7 @@ func loginCopilot() error { fmt.Println() fmt.Println(lipgloss.NewStyle().Hyperlink(copilot.SignupURL, "id=copilot-signup").Render(copilot.SignupURL)) fmt.Println() - fmt.Println("You may be able to request free access if elegible. For more information, see:") + fmt.Println("You may be able to request free access if eligible. For more information, see:") fmt.Println() fmt.Println(lipgloss.NewStyle().Hyperlink(copilot.FreeURL, "id=copilot-free").Render(copilot.FreeURL)) } diff --git a/internal/tui/components/dialogs/copilot/device_flow.go b/internal/tui/components/dialogs/copilot/device_flow.go index d3f792291e4bce77dc5ceacb1aa1200a111981dc..d8a2850c3ea151021958a07b350df879d1db4554 100644 --- a/internal/tui/components/dialogs/copilot/device_flow.go +++ b/internal/tui/components/dialogs/copilot/device_flow.go @@ -174,7 +174,7 @@ func (d *DeviceFlow) View() string { freeMessage := lipgloss.NewStyle(). Margin(0, 1). Width(d.width - 2). - Render("You may be able to request free access if elegible. For more information, see:") + Render("You may be able to request free access if eligible. For more information, see:") return lipgloss.JoinVertical( lipgloss.Left, message, From 5f1d92a19362fffe77a5e3a5dc199b2df09e6c71 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Mon, 12 Jan 2026 19:45:21 -0300 Subject: [PATCH 27/58] chore(legal): @andreasdotorg 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 c8fb31dbc93313867bdae72bba8d6de27f26667d..cf21b7c02c3ecb20d01ac8250cee76e2727b81b2 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1047,6 +1047,14 @@ "created_at": "2026-01-12T13:58:05Z", "repoId": 987670088, "pullRequestNo": 1830 + }, + { + "name": "andreasdotorg", + "id": 153248, + "comment_id": 3740767910, + "created_at": "2026-01-12T22:16:05Z", + "repoId": 987670088, + "pullRequestNo": 1841 } ] } \ No newline at end of file From 87df08c4088afb265a3886380db96ca6387c518d Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 13 Jan 2026 11:29:21 -0500 Subject: [PATCH 28/58] fix: race condition where title might not be generated (#1844) --- internal/agent/agent.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 8d2fa40fd427143bf988587ef7faa3a89c3e23b1..198159d53adbcbba8f8598bf24a8eef55825acfc 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -183,6 +183,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy a.generateTitle(titleCtx, call.SessionID, call.Prompt) }) } + defer wg.Wait() // Add the user message to the session. _, err = a.createUserMessage(ctx, call) @@ -491,7 +492,6 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy } return nil, err } - wg.Wait() if shouldSummarize { a.activeRequests.Del(call.SessionID) From 3fd9d970149e706149c47e86f3d007512472ff12 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 13 Jan 2026 14:34:19 -0300 Subject: [PATCH 29/58] ci(sec): add more security jobs, improve build, enable race detector (#1849) Signed-off-by: Carlos Alexandro Becker Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/build.yml | 28 ++++++-- .github/workflows/security.yml | 88 ++++++++++++++++++++++++++ Taskfile.yaml | 10 +-- go.mod | 2 +- go.sum | 4 +- internal/csync/maps_test.go | 7 +- internal/permission/permission.go | 13 +++- internal/permission/permission_test.go | 4 +- internal/shell/background.go | 43 +++++++++++-- internal/shell/background_test.go | 16 ++--- internal/tui/styles/theme.go | 32 ++++++---- 11 files changed, 199 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/security.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3511f7fe0c4f487eb3fc9009795361ada8e2eff7..39b5923298e2f7fa8d5452327a6e8b2a08f0df97 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,11 +1,27 @@ name: build on: [push, pull_request] +permissions: + contents: read + +concurrency: + group: build-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: build: - uses: charmbracelet/meta/.github/workflows/build.yml@main - with: - go-version: "" - go-version-file: ./go.mod - secrets: - gh_pat: "${{ secrets.PERSONAL_ACCESS_TOKEN }}" + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 + with: + go-version-file: go.mod + - run: go mod tidy + - run: git diff --exit-code + - run: go build -race ./... + - run: go test -race -failfast ./... diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000000000000000000000000000000000000..8fc56fa39e7b47d1fe5ba84c0f0e7cb65733a264 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,88 @@ +name: "security" + +on: + pull_request: + push: + branches: [main] + schedule: + - cron: "0 2 * * *" + +permissions: + contents: read + +concurrency: + group: security-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + codeql: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: ["go", "actions"] + permissions: + actions: read + contents: read + pull-requests: read + security-events: write + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + - uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 + with: + languages: ${{ matrix.language }} + - uses: github/codeql-action/autobuild@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 + - uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 + + grype: + runs-on: ubuntu-latest + permissions: + security-events: write + actions: read + contents: read + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + - uses: anchore/scan-action@40a61b52209e9d50e87917c5b901783d546b12d0 # v7.2.1 + id: scan + with: + path: "." + fail-build: true + severity-cutoff: critical + - uses: github/codeql-action/upload-sarif@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 + with: + sarif_file: ${{ steps.scan.outputs.sarif }} + + govulncheck: + runs-on: ubuntu-latest + permissions: + security-events: write + contents: read + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + - uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4 + with: + output-format: sarif + output-file: results.sarif + - uses: github/codeql-action/upload-sarif@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 + with: + sarif_file: results.sarif + + dependency-review: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + permissions: + contents: read + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + - uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2 + with: + fail-on-severity: critical + allow-licenses: BSD-2-Clause, BSD-3-Clause, MIT, Apache-2.0, MPL-2.0, ISC diff --git a/Taskfile.yaml b/Taskfile.yaml index 68c805c599314cadde5c86fc37a0e3d1a6184f4e..0043f4f033e455a5800da2431848e620c37a0f5a 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -5,6 +5,8 @@ version: "3" vars: VERSION: sh: git describe --long 2>/dev/null || echo "" + RACE: + sh: test -f race.log && echo "1" || echo "" env: CGO_ENABLED: 0 @@ -37,20 +39,20 @@ tasks: vars: LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}' cmds: - - go build {{.LDFLAGS}} . + - "go build {{if .RACE}}-race{{end}} {{.LDFLAGS}} ." generates: - crush run: desc: Run build cmds: - - go build -o crush . - - ./crush {{.CLI_ARGS}} + - task: build + - "./crush {{.CLI_ARGS}} {{if .RACE}}2>race.log{{end}}" test: desc: Run tests cmds: - - go test ./... {{.CLI_ARGS}} + - go test -race -failfast ./... {{.CLI_ARGS}} test:record: desc: Run tests and record all VCR cassettes again diff --git a/go.mod b/go.mod index 8959596f7feca0d6df1ce50e88712e8cc1058fa9..fe3497de825754c0e60835a079f9d6014d9c603a 100644 --- a/go.mod +++ b/go.mod @@ -29,7 +29,7 @@ require ( github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f github.com/charmbracelet/x/exp/ordered v0.1.0 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff - github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 + github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b github.com/charmbracelet/x/term v0.2.2 github.com/denisbrodbeck/machineid v1.0.1 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec diff --git a/go.sum b/go.sum index 70582b7c92f86af89a03d9f9a43382e27235d2ca..d3d7696e9729d1a20dc45c7122a847e384bb72df 100644 --- a/go.sum +++ b/go.sum @@ -118,8 +118,8 @@ github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff h1:Uwr+/ github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ= github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM= -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/powernap v0.0.0-20260113142046-c1fa3de7983b h1:5ye9hzBKH623bMVz5auIuY6K21loCdxpRmFle2O9R/8= +github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= diff --git a/internal/csync/maps_test.go b/internal/csync/maps_test.go index 4c590f008dad91e8dcbc40d1b90d87ef1b3e5750..31e6fa0c3aef18a04c61ea3d4d36b5187228c3ff 100644 --- a/internal/csync/maps_test.go +++ b/internal/csync/maps_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "maps" "sync" + "sync/atomic" "testing" "testing/synctest" "time" @@ -46,12 +47,12 @@ func TestNewLazyMap(t *testing.T) { waiter := sync.Mutex{} waiter.Lock() - loadCalled := false + var loadCalled atomic.Bool loadFunc := func() map[string]int { waiter.Lock() defer waiter.Unlock() - loadCalled = true + loadCalled.Store(true) return map[string]int{ "key1": 1, "key2": 2, @@ -63,7 +64,7 @@ func TestNewLazyMap(t *testing.T) { waiter.Unlock() // Allow the load function to proceed time.Sleep(100 * time.Millisecond) - require.True(t, loadCalled) + require.True(t, loadCalled.Load()) require.Equal(t, 2, m.Len()) value, ok := m.Get("key1") diff --git a/internal/permission/permission.go b/internal/permission/permission.go index 9dc85e976238fdbe1ff2d3689b2a2c4160608760..e1bf1bae14b8473989b1c0890c58188591123d71 100644 --- a/internal/permission/permission.go +++ b/internal/permission/permission.go @@ -68,8 +68,9 @@ type permissionService struct { allowedTools []string // used to make sure we only process one request at a time - requestMu sync.Mutex - activeRequest *PermissionRequest + requestMu sync.Mutex + activeRequest *PermissionRequest + activeRequestMu sync.Mutex } func (s *permissionService) GrantPersistent(permission PermissionRequest) { @@ -86,9 +87,11 @@ func (s *permissionService) GrantPersistent(permission PermissionRequest) { s.sessionPermissions = append(s.sessionPermissions, permission) s.sessionPermissionsMu.Unlock() + s.activeRequestMu.Lock() if s.activeRequest != nil && s.activeRequest.ID == permission.ID { s.activeRequest = nil } + s.activeRequestMu.Unlock() } func (s *permissionService) Grant(permission PermissionRequest) { @@ -101,9 +104,11 @@ func (s *permissionService) Grant(permission PermissionRequest) { respCh <- true } + s.activeRequestMu.Lock() if s.activeRequest != nil && s.activeRequest.ID == permission.ID { s.activeRequest = nil } + s.activeRequestMu.Unlock() } func (s *permissionService) Deny(permission PermissionRequest) { @@ -117,9 +122,11 @@ func (s *permissionService) Deny(permission PermissionRequest) { respCh <- false } + s.activeRequestMu.Lock() if s.activeRequest != nil && s.activeRequest.ID == permission.ID { s.activeRequest = nil } + s.activeRequestMu.Unlock() } func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRequest) (bool, error) { @@ -190,7 +197,9 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe } s.sessionPermissionsMu.RUnlock() + s.activeRequestMu.Lock() s.activeRequest = &permission + s.activeRequestMu.Unlock() respCh := make(chan bool, 1) s.pendingRequests.Set(permission.ID, respCh) diff --git a/internal/permission/permission_test.go b/internal/permission/permission_test.go index 89e06916024cd1669f5e0d0a263d4a71548c8a97..79930f3ae1e2ef15257f09724fef64d3ea28dada 100644 --- a/internal/permission/permission_test.go +++ b/internal/permission/permission_test.go @@ -189,7 +189,7 @@ func TestPermissionService_SequentialProperties(t *testing.T) { events := service.Subscribe(t.Context()) var wg sync.WaitGroup - results := make([]bool, 0) + results := make([]bool, 3) requests := []CreatePermissionRequest{ { @@ -220,7 +220,7 @@ func TestPermissionService_SequentialProperties(t *testing.T) { go func(index int, request CreatePermissionRequest) { defer wg.Done() result, _ := service.Request(t.Context(), request) - results = append(results, result) + results[index] = result }(i, req) } diff --git a/internal/shell/background.go b/internal/shell/background.go index bc81369ec877586c92fa9bc701d8b78b669f23d5..cb1855836f64bdd56a90802c2bbb939a5a514100 100644 --- a/internal/shell/background.go +++ b/internal/shell/background.go @@ -19,6 +19,30 @@ const ( CompletedJobRetentionMinutes = 8 * 60 ) +// syncBuffer is a thread-safe wrapper around bytes.Buffer. +type syncBuffer struct { + buf bytes.Buffer + mu sync.RWMutex +} + +func (sb *syncBuffer) Write(p []byte) (n int, err error) { + sb.mu.Lock() + defer sb.mu.Unlock() + return sb.buf.Write(p) +} + +func (sb *syncBuffer) WriteString(s string) (n int, err error) { + sb.mu.Lock() + defer sb.mu.Unlock() + return sb.buf.WriteString(s) +} + +func (sb *syncBuffer) String() string { + sb.mu.RLock() + defer sb.mu.RUnlock() + return sb.buf.String() +} + // BackgroundShell represents a shell running in the background. type BackgroundShell struct { ID string @@ -28,8 +52,8 @@ type BackgroundShell struct { WorkingDir string ctx context.Context cancel context.CancelFunc - stdout *bytes.Buffer - stderr *bytes.Buffer + stdout *syncBuffer + stderr *syncBuffer done chan struct{} exitErr error completedAt int64 // Unix timestamp when job completed (0 if still running) @@ -46,12 +70,17 @@ var ( idCounter atomic.Uint64 ) +// newBackgroundShellManager creates a new BackgroundShellManager instance. +func newBackgroundShellManager() *BackgroundShellManager { + return &BackgroundShellManager{ + shells: csync.NewMap[string, *BackgroundShell](), + } +} + // GetBackgroundShellManager returns the singleton background shell manager. func GetBackgroundShellManager() *BackgroundShellManager { backgroundManagerOnce.Do(func() { - backgroundManager = &BackgroundShellManager{ - shells: csync.NewMap[string, *BackgroundShell](), - } + backgroundManager = newBackgroundShellManager() }) return backgroundManager } @@ -80,8 +109,8 @@ func (m *BackgroundShellManager) Start(ctx context.Context, workingDir string, b Shell: shell, ctx: shellCtx, cancel: cancel, - stdout: &bytes.Buffer{}, - stderr: &bytes.Buffer{}, + stdout: &syncBuffer{}, + stderr: &syncBuffer{}, done: make(chan struct{}), } diff --git a/internal/shell/background_test.go b/internal/shell/background_test.go index 5149861d94e457e8a78650c48d9c6765a57d369e..7c521bc1477b07775cffb69f310fa83d710d4634 100644 --- a/internal/shell/background_test.go +++ b/internal/shell/background_test.go @@ -14,7 +14,7 @@ func TestBackgroundShellManager_Start(t *testing.T) { ctx := context.Background() workingDir := t.TempDir() - manager := GetBackgroundShellManager() + manager := newBackgroundShellManager() bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'hello world'", "") if err != nil { @@ -51,7 +51,7 @@ func TestBackgroundShellManager_Get(t *testing.T) { ctx := context.Background() workingDir := t.TempDir() - manager := GetBackgroundShellManager() + manager := newBackgroundShellManager() bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'test'", "") if err != nil { @@ -77,7 +77,7 @@ func TestBackgroundShellManager_Kill(t *testing.T) { ctx := context.Background() workingDir := t.TempDir() - manager := GetBackgroundShellManager() + manager := newBackgroundShellManager() // Start a long-running command bgShell, err := manager.Start(ctx, workingDir, nil, "sleep 10", "") @@ -106,7 +106,7 @@ func TestBackgroundShellManager_Kill(t *testing.T) { func TestBackgroundShellManager_KillNonExistent(t *testing.T) { t.Parallel() - manager := GetBackgroundShellManager() + manager := newBackgroundShellManager() err := manager.Kill("non-existent-id") if err == nil { @@ -119,7 +119,7 @@ func TestBackgroundShell_IsDone(t *testing.T) { ctx := context.Background() workingDir := t.TempDir() - manager := GetBackgroundShellManager() + manager := newBackgroundShellManager() bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'quick'", "") if err != nil { @@ -142,7 +142,7 @@ func TestBackgroundShell_WithBlockFuncs(t *testing.T) { ctx := context.Background() workingDir := t.TempDir() - manager := GetBackgroundShellManager() + manager := newBackgroundShellManager() blockFuncs := []BlockFunc{ CommandsBlocker([]string{"curl", "wget"}), @@ -180,7 +180,7 @@ func TestBackgroundShellManager_List(t *testing.T) { ctx := context.Background() workingDir := t.TempDir() - manager := GetBackgroundShellManager() + manager := newBackgroundShellManager() // Start two shells bgShell1, err := manager.Start(ctx, workingDir, nil, "sleep 1", "") @@ -224,7 +224,7 @@ func TestBackgroundShellManager_KillAll(t *testing.T) { ctx := context.Background() workingDir := t.TempDir() - manager := GetBackgroundShellManager() + manager := newBackgroundShellManager() // Start multiple long-running shells shell1, err := manager.Start(ctx, workingDir, nil, "sleep 10", "") diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go index f87ffd9de8b324cec4dcfd8b7cee61f71e0390eb..b03603c57439f5f950f9860d3287b0f9d13742e5 100644 --- a/internal/tui/styles/theme.go +++ b/internal/tui/styles/theme.go @@ -4,6 +4,7 @@ import ( "fmt" "image/color" "strings" + "sync" "charm.land/bubbles/v2/filepicker" "charm.land/bubbles/v2/help" @@ -97,7 +98,8 @@ type Theme struct { AuthBorderUnselected lipgloss.Style AuthTextUnselected lipgloss.Style - styles *Styles + styles *Styles + stylesOnce sync.Once } type Styles struct { @@ -134,9 +136,9 @@ type Styles struct { } func (t *Theme) S() *Styles { - if t.styles == nil { + t.stylesOnce.Do(func() { t.styles = t.buildStyles() - } + }) return t.styles } @@ -500,27 +502,31 @@ type Manager struct { current *Theme } -var defaultManager *Manager +var ( + defaultManager *Manager + defaultManagerOnce sync.Once +) + +func initDefaultManager() *Manager { + defaultManagerOnce.Do(func() { + defaultManager = newManager() + }) + return defaultManager +} func SetDefaultManager(m *Manager) { defaultManager = m } func DefaultManager() *Manager { - if defaultManager == nil { - defaultManager = NewManager() - } - return defaultManager + return initDefaultManager() } func CurrentTheme() *Theme { - if defaultManager == nil { - defaultManager = NewManager() - } - return defaultManager.Current() + return initDefaultManager().Current() } -func NewManager() *Manager { +func newManager() *Manager { m := &Manager{ themes: make(map[string]*Theme), } From c57cbc653124b23be8ba9ebede33c124611709d8 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 13 Jan 2026 16:45:49 -0300 Subject: [PATCH 30/58] ci: fix govulncheck Signed-off-by: Carlos Alexandro Becker --- .github/workflows/security.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 8fc56fa39e7b47d1fe5ba84c0f0e7cb65733a264..9c70b17828afe559a096b5a55da1eeed762b9ee9 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -69,6 +69,7 @@ jobs: with: output-format: sarif output-file: results.sarif + go-version-input: 1.26.0-rc.1 # change to "stable" once Go 1.26 is released - uses: github/codeql-action/upload-sarif@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 with: sarif_file: results.sarif From 151e063dd2502d25f763b8e14bfeba8c870b21bf Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 13 Jan 2026 17:22:22 -0500 Subject: [PATCH 32/58] fix(ci): security: allow Google Patent License for Go modules --- .github/workflows/security.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 9c70b17828afe559a096b5a55da1eeed762b9ee9..857184e7cca015984d36b0e08c6762d3570c12f2 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -86,4 +86,4 @@ jobs: - uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2 with: fail-on-severity: critical - allow-licenses: BSD-2-Clause, BSD-3-Clause, MIT, Apache-2.0, MPL-2.0, ISC + allow-licenses: BSD-2-Clause, BSD-3-Clause, MIT, Apache-2.0, MPL-2.0, ISC, LicenseRef-scancode-google-patent-license-golang From 340defd5675f4fec003e5c4578a79a29fca4ea73 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 13 Jan 2026 17:34:57 -0500 Subject: [PATCH 33/58] fix(ci): update security workflow to use setup-go and install govulncheck --- .github/workflows/security.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 857184e7cca015984d36b0e08c6762d3570c12f2..3a90ea316c3d86f5b2f93224fd2b35eaa572e704 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -65,11 +65,14 @@ jobs: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false - - uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4 + - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: - output-format: sarif - output-file: results.sarif - go-version-input: 1.26.0-rc.1 # change to "stable" once Go 1.26 is released + go-version: 1.26.0-rc.1 # change to "stable" once Go 1.26 is released + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + - name: Run govulncheck + run: | + govulncheck -C . -format sarif ./... > results.sarif - uses: github/codeql-action/upload-sarif@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 with: sarif_file: results.sarif From 67437151e91296db7c25908ef0e69031339de56a Mon Sep 17 00:00:00 2001 From: kslamph <15257433+kslamph@users.noreply.github.com> Date: Wed, 14 Jan 2026 06:46:07 +0800 Subject: [PATCH 34/58] feat: add clipboard image paste functionality to chat editor (#181) (#1151) Co-authored-by: Ayman Bagabas --- go.mod | 6 +- go.sum | 12 ++- internal/tui/components/chat/editor/editor.go | 79 +++++++++++++++++++ internal/tui/components/chat/editor/keys.go | 6 ++ 4 files changed, 97 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index fe3497de825754c0e60835a079f9d6014d9c603a..72f53ccfbd743a333730b34ac28cc0edffe5aa50 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/PuerkitoBio/goquery v1.11.0 github.com/alecthomas/chroma/v2 v2.22.0 github.com/atotto/clipboard v0.1.4 + github.com/aymanbagabas/go-nativeclipboard v0.1.2 github.com/aymanbagabas/go-udiff v0.3.1 github.com/bmatcuk/doublestar/v4 v4.9.2 github.com/charlievieth/fastwalk v1.0.14 @@ -100,13 +101,14 @@ require ( github.com/charmbracelet/x/json v0.2.0 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.6.1 // indirect + github.com/clipperhouse/displaywidth v0.6.2 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/disintegration/gift v1.1.2 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect @@ -171,7 +173,7 @@ require ( go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect golang.org/x/crypto v0.47.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/image v0.27.0 // indirect + golang.org/x/image v0.34.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/term v0.39.0 // indirect diff --git a/go.sum b/go.sum index d3d7696e9729d1a20dc45c7122a847e384bb72df..8973fcdc1b227d0b30aad691220103319afa93ca 100644 --- a/go.sum +++ b/go.sum @@ -78,6 +78,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aymanbagabas/go-nativeclipboard v0.1.2 h1:Z2iVRWQ4IynMLWM6a+lWH2Nk5gPyEtPRMuBIyZ2dECM= +github.com/aymanbagabas/go-nativeclipboard v0.1.2/go.mod h1:BVJhN7hs5DieCzUB2Atf4Yk9Y9kFe62E95+gOjpJq6Q= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= @@ -126,8 +128,8 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= -github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY= -github.com/clipperhouse/displaywidth v0.6.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= +github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo= +github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= @@ -150,6 +152,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff h1:vAcU1VsCRstZ9ty11yD/L0WDyT73S/gVfmuWvcWX5DA= +github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= @@ -389,8 +393,8 @@ golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= 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.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= -golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= +golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= +golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 01badb98d37eb848ccf5962e01793ecaa3fc0f59..b5cadb8cde8a1ced8543d01eb7abd28d906f1597 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -16,6 +16,7 @@ import ( "charm.land/bubbles/v2/textarea" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + nativeclipboard "github.com/aymanbagabas/go-nativeclipboard" "github.com/charmbracelet/crush/internal/app" "github.com/charmbracelet/crush/internal/filetracker" "github.com/charmbracelet/crush/internal/fsext" @@ -338,6 +339,84 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { m.textarea.InsertRune('\n') cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{})) } + // Handle image paste from clipboard + if key.Matches(msg, m.keyMap.PasteImage) { + imageData, err := nativeclipboard.Image.Read() + + if err != nil || len(imageData) == 0 { + // If no image data found, try to get text data (could be file path) + var textData []byte + textData, err = nativeclipboard.Text.Read() + if err != nil || len(textData) == 0 { + // If clipboard is empty, show a warning + return m, util.ReportWarn("No data found in clipboard. Note: Some terminals may not support reading image data from clipboard directly.") + } + + // Check if the text data is a file path + textStr := string(textData) + // First, try to interpret as a file path (existing functionality) + path := strings.ReplaceAll(textStr, "\\ ", " ") + path, err = filepath.Abs(strings.TrimSpace(path)) + if err == nil { + isAllowedType := false + for _, ext := range filepicker.AllowedTypes { + if strings.HasSuffix(path, ext) { + isAllowedType = true + break + } + } + if isAllowedType { + tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize) + if !tooBig { + content, err := os.ReadFile(path) + if err == nil { + mimeBufferSize := min(512, len(content)) + mimeType := http.DetectContentType(content[:mimeBufferSize]) + fileName := filepath.Base(path) + attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content} + return m, util.CmdHandler(filepicker.FilePickedMsg{ + Attachment: attachment, + }) + } + } + } + } + + // If not a valid file path, show a warning + return m, util.ReportWarn("No image found in clipboard") + } else { + // We have image data from the clipboard + // Create a temporary file to store the clipboard image data + tempFile, err := os.CreateTemp("", "clipboard_image_crush_*") + if err != nil { + return m, util.ReportError(err) + } + defer tempFile.Close() + + // Write clipboard content to the temporary file + _, err = tempFile.Write(imageData) + if err != nil { + return m, util.ReportError(err) + } + + // Determine the file extension based on the image data + mimeBufferSize := min(512, len(imageData)) + mimeType := http.DetectContentType(imageData[:mimeBufferSize]) + + // Create an attachment from the temporary file + fileName := filepath.Base(tempFile.Name()) + attachment := message.Attachment{ + FilePath: tempFile.Name(), + FileName: fileName, + MimeType: mimeType, + Content: imageData, + } + + return m, util.CmdHandler(filepicker.FilePickedMsg{ + Attachment: attachment, + }) + } + } // Handle Enter key if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) { value := m.textarea.Value() diff --git a/internal/tui/components/chat/editor/keys.go b/internal/tui/components/chat/editor/keys.go index 0ba4571888e547b1c4a85e7ee9dd73ff07ce13d2..c20df5cc1c071deab83754430543b9be2381127c 100644 --- a/internal/tui/components/chat/editor/keys.go +++ b/internal/tui/components/chat/editor/keys.go @@ -9,6 +9,7 @@ type EditorKeyMap struct { SendMessage key.Binding OpenEditor key.Binding Newline key.Binding + PasteImage key.Binding } func DefaultEditorKeyMap() EditorKeyMap { @@ -32,6 +33,10 @@ func DefaultEditorKeyMap() EditorKeyMap { // to reflect that. key.WithHelp("ctrl+j", "newline"), ), + PasteImage: key.NewBinding( + key.WithKeys("ctrl+v"), + key.WithHelp("ctrl+v", "paste image from clipboard"), + ), } } @@ -42,6 +47,7 @@ func (k EditorKeyMap) KeyBindings() []key.Binding { k.SendMessage, k.OpenEditor, k.Newline, + k.PasteImage, AttachmentsKeyMaps.AttachmentDeleteMode, AttachmentsKeyMaps.DeleteAllAttachments, AttachmentsKeyMaps.Escape, From a13019640e124872c3671139aa2d06cbf96c3b5f Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 13 Jan 2026 21:25:33 -0500 Subject: [PATCH 35/58] chore(README): update crush art (#1861) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 929b77425f1b42452a4e38d8cfa540773dd54a79..cfcd765ee150d181c00cf649a5ba15055b6bdbae 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Crush

- Charm Crush Logo
+ Charm Crush Logo
Latest Release Build Status

From b8d88ddb8e4590b5d0ed434b7f2cdb69dcc8ad5b Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Wed, 14 Jan 2026 01:29:05 -0300 Subject: [PATCH 36/58] chore(legal): @kuxoapp 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 cf21b7c02c3ecb20d01ac8250cee76e2727b81b2..29ea40e9d11b164aacbbcc4539c541bdf2ba1214 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1055,6 +1055,14 @@ "created_at": "2026-01-12T22:16:05Z", "repoId": 987670088, "pullRequestNo": 1841 + }, + { + "name": "kuxoapp", + "id": 254052994, + "comment_id": 3747622477, + "created_at": "2026-01-14T04:18:44Z", + "repoId": 987670088, + "pullRequestNo": 1864 } ] } \ No newline at end of file From 8f2ae5ceea85c17e11eeda685db90c64f24632c5 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Wed, 14 Jan 2026 11:02:16 -0300 Subject: [PATCH 37/58] chore(legal): @mhpenta 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 29ea40e9d11b164aacbbcc4539c541bdf2ba1214..5929987f916594da1109eee2082c154620edf660 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1063,6 +1063,14 @@ "created_at": "2026-01-14T04:18:44Z", "repoId": 987670088, "pullRequestNo": 1864 + }, + { + "name": "mhpenta", + "id": 183146177, + "comment_id": 3749703014, + "created_at": "2026-01-14T14:02:04Z", + "repoId": 987670088, + "pullRequestNo": 1870 } ] } \ No newline at end of file From f66762b917c3ac133ca8f1fc430c5cbd524412f0 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 14 Jan 2026 12:16:31 -0300 Subject: [PATCH 38/58] fix: race in agent.go (#1853) Signed-off-by: Carlos Alexandro Becker --- internal/agent/agent.go | 107 +++++++++++++++++++--------------- internal/agent/event.go | 12 ++-- internal/csync/slices.go | 44 ++++---------- internal/csync/slices_test.go | 57 ------------------ internal/csync/value.go | 44 ++++++++++++++ internal/csync/value_test.go | 99 +++++++++++++++++++++++++++++++ 6 files changed, 218 insertions(+), 145 deletions(-) create mode 100644 internal/csync/value.go create mode 100644 internal/csync/value_test.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 198159d53adbcbba8f8598bf24a8eef55825acfc..c0b9080bb640085c6fd0fdbde8db0fbfe7f476dd 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -87,12 +87,13 @@ type Model struct { } type sessionAgent struct { - largeModel Model - smallModel Model - systemPromptPrefix string - systemPrompt string + largeModel *csync.Value[Model] + smallModel *csync.Value[Model] + systemPromptPrefix *csync.Value[string] + systemPrompt *csync.Value[string] + tools *csync.Slice[fantasy.AgentTool] + isSubAgent bool - tools []fantasy.AgentTool sessions session.Service messages message.Service disableAutoSummarize bool @@ -119,15 +120,15 @@ func NewSessionAgent( opts SessionAgentOptions, ) SessionAgent { return &sessionAgent{ - largeModel: opts.LargeModel, - smallModel: opts.SmallModel, - systemPromptPrefix: opts.SystemPromptPrefix, - systemPrompt: opts.SystemPrompt, + largeModel: csync.NewValue(opts.LargeModel), + smallModel: csync.NewValue(opts.SmallModel), + systemPromptPrefix: csync.NewValue(opts.SystemPromptPrefix), + systemPrompt: csync.NewValue(opts.SystemPrompt), isSubAgent: opts.IsSubAgent, sessions: opts.Sessions, messages: opts.Messages, disableAutoSummarize: opts.DisableAutoSummarize, - tools: opts.Tools, + tools: csync.NewSliceFrom(opts.Tools), isYolo: opts.IsYolo, messageQueue: csync.NewMap[string, []SessionAgentCall](), activeRequests: csync.NewMap[string, context.CancelFunc](), @@ -153,15 +154,21 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy return nil, nil } - if len(a.tools) > 0 { + // Copy mutable fields under lock to avoid races with SetTools/SetModels. + agentTools := a.tools.Copy() + largeModel := a.largeModel.Get() + systemPrompt := a.systemPrompt.Get() + promptPrefix := a.systemPromptPrefix.Get() + + if len(agentTools) > 0 { // Add Anthropic caching to the last tool. - a.tools[len(a.tools)-1].SetProviderOptions(a.getCacheControlOptions()) + agentTools[len(agentTools)-1].SetProviderOptions(a.getCacheControlOptions()) } agent := fantasy.NewAgent( - a.largeModel.Model, - fantasy.WithSystemPrompt(a.systemPrompt), - fantasy.WithTools(a.tools...), + largeModel.Model, + fantasy.WithSystemPrompt(systemPrompt), + fantasy.WithTools(agentTools...), ) sessionLock := sync.Mutex{} @@ -234,7 +241,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy prepared.Messages = append(prepared.Messages, userMessage.ToAIMessage()...) } - prepared.Messages = a.workaroundProviderMediaLimitations(prepared.Messages) + prepared.Messages = a.workaroundProviderMediaLimitations(prepared.Messages, largeModel) lastSystemRoleInx := 0 systemMessageUpdated := false @@ -252,7 +259,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy } } - if promptPrefix := a.promptPrefix(); promptPrefix != "" { + if promptPrefix != "" { prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(promptPrefix)}, prepared.Messages...) } @@ -260,15 +267,15 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy assistantMsg, err = a.messages.Create(callContext, call.SessionID, message.CreateMessageParams{ Role: message.Assistant, Parts: []message.ContentPart{}, - Model: a.largeModel.ModelCfg.Model, - Provider: a.largeModel.ModelCfg.Provider, + Model: largeModel.ModelCfg.Model, + Provider: largeModel.ModelCfg.Provider, }) if err != nil { return callContext, prepared, err } callContext = context.WithValue(callContext, tools.MessageIDContextKey, assistantMsg.ID) - callContext = context.WithValue(callContext, tools.SupportsImagesContextKey, a.largeModel.CatwalkCfg.SupportsImages) - callContext = context.WithValue(callContext, tools.ModelNameContextKey, a.largeModel.CatwalkCfg.Name) + callContext = context.WithValue(callContext, tools.SupportsImagesContextKey, largeModel.CatwalkCfg.SupportsImages) + callContext = context.WithValue(callContext, tools.ModelNameContextKey, largeModel.CatwalkCfg.Name) currentAssistant = &assistantMsg return callContext, prepared, err }, @@ -362,7 +369,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy sessionLock.Unlock() return getSessionErr } - a.updateSessionUsage(a.largeModel, &updatedSession, stepResult.Usage, a.openrouterCost(stepResult.ProviderMetadata)) + a.updateSessionUsage(largeModel, &updatedSession, stepResult.Usage, a.openrouterCost(stepResult.ProviderMetadata)) _, sessionErr := a.sessions.Save(genCtx, updatedSession) sessionLock.Unlock() if sessionErr != nil { @@ -372,7 +379,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy }, StopWhen: []fantasy.StopCondition{ func(_ []fantasy.StepResult) bool { - cw := int64(a.largeModel.CatwalkCfg.ContextWindow) + cw := int64(largeModel.CatwalkCfg.ContextWindow) tokens := currentSession.CompletionTokens + currentSession.PromptTokens remaining := cw - tokens var threshold int64 @@ -474,7 +481,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy currentAssistant.AddFinish( message.FinishReasonError, "Copilot model not enabled", - fmt.Sprintf("%q is not enabled in Copilot. Go to the following page to enable it. Then, wait 5 minutes before trying again. %s", a.largeModel.CatwalkCfg.Name, link), + fmt.Sprintf("%q is not enabled in Copilot. Go to the following page to enable it. Then, wait 5 minutes before trying again. %s", largeModel.CatwalkCfg.Name, link), ) } else { currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(providerErr.Title), defaultTitle), providerErr.Message) @@ -529,6 +536,10 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan return ErrSessionBusy } + // Copy mutable fields under lock to avoid races with SetModels. + largeModel := a.largeModel.Get() + systemPromptPrefix := a.systemPromptPrefix.Get() + currentSession, err := a.sessions.Get(ctx, sessionID) if err != nil { return fmt.Errorf("failed to get session: %w", err) @@ -549,13 +560,13 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan defer a.activeRequests.Del(sessionID) defer cancel() - agent := fantasy.NewAgent(a.largeModel.Model, + agent := fantasy.NewAgent(largeModel.Model, fantasy.WithSystemPrompt(string(summaryPrompt)), ) summaryMessage, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{ Role: message.Assistant, - Model: a.largeModel.Model.Model(), - Provider: a.largeModel.Model.Provider(), + Model: largeModel.Model.Model(), + Provider: largeModel.Model.Provider(), IsSummaryMessage: true, }) if err != nil { @@ -570,8 +581,8 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan ProviderOptions: opts, PrepareStep: func(callContext context.Context, options fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) { prepared.Messages = options.Messages - if a.systemPromptPrefix != "" { - prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(a.systemPromptPrefix)}, prepared.Messages...) + if systemPromptPrefix != "" { + prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(systemPromptPrefix)}, prepared.Messages...) } return callContext, prepared, nil }, @@ -622,7 +633,7 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan } } - a.updateSessionUsage(a.largeModel, ¤tSession, resp.TotalUsage, openrouterCost) + a.updateSessionUsage(largeModel, ¤tSession, resp.TotalUsage, openrouterCost) // Just in case, get just the last usage info. usage := resp.Response.Usage @@ -730,9 +741,13 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user return } + smallModel := a.smallModel.Get() + largeModel := a.largeModel.Get() + systemPromptPrefix := a.systemPromptPrefix.Get() + var maxOutputTokens int64 = 40 - if a.smallModel.CatwalkCfg.CanReason { - maxOutputTokens = a.smallModel.CatwalkCfg.DefaultMaxTokens + if smallModel.CatwalkCfg.CanReason { + maxOutputTokens = smallModel.CatwalkCfg.DefaultMaxTokens } newAgent := func(m fantasy.LanguageModel, p []byte, tok int64) fantasy.Agent { @@ -746,9 +761,9 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user Prompt: fmt.Sprintf("Generate a concise title for the following content:\n\n%s\n \n\n", userPrompt), PrepareStep: func(callCtx context.Context, opts fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) { prepared.Messages = opts.Messages - if a.systemPromptPrefix != "" { + if systemPromptPrefix != "" { prepared.Messages = append([]fantasy.Message{ - fantasy.NewSystemMessage(a.systemPromptPrefix), + fantasy.NewSystemMessage(systemPromptPrefix), }, prepared.Messages...) } return callCtx, prepared, nil @@ -756,7 +771,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user } // Use the small model to generate the title. - model := &a.smallModel + model := smallModel agent := newAgent(model.Model, titlePrompt, maxOutputTokens) resp, err := agent.Stream(ctx, streamCall) if err == nil { @@ -765,7 +780,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user } else { // It didn't work. Let's try with the big model. slog.Error("error generating title with small model; trying big model", "err", err) - model = &a.largeModel + model = largeModel agent = newAgent(model.Model, titlePrompt, maxOutputTokens) resp, err = agent.Stream(ctx, streamCall) if err == nil { @@ -960,24 +975,20 @@ func (a *sessionAgent) QueuedPromptsList(sessionID string) []string { } func (a *sessionAgent) SetModels(large Model, small Model) { - a.largeModel = large - a.smallModel = small + a.largeModel.Set(large) + a.smallModel.Set(small) } func (a *sessionAgent) SetTools(tools []fantasy.AgentTool) { - a.tools = tools + a.tools.SetSlice(tools) } func (a *sessionAgent) SetSystemPrompt(systemPrompt string) { - a.systemPrompt = systemPrompt + a.systemPrompt.Set(systemPrompt) } func (a *sessionAgent) Model() Model { - return a.largeModel -} - -func (a *sessionAgent) promptPrefix() string { - return a.systemPromptPrefix + return a.largeModel.Get() } // convertToToolResult converts a fantasy tool result to a message tool result. @@ -1034,9 +1045,9 @@ func (a *sessionAgent) convertToToolResult(result fantasy.ToolResultContent) mes // // BEFORE: [tool result: image data] // AFTER: [tool result: "Image loaded - see attached"], [user: image attachment] -func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Message) []fantasy.Message { - providerSupportsMedia := a.largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderAnthropic) || - a.largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderBedrock) +func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Message, largeModel Model) []fantasy.Message { + providerSupportsMedia := largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderAnthropic) || + largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderBedrock) if providerSupportsMedia { return messages diff --git a/internal/agent/event.go b/internal/agent/event.go index bf36ec84bf4270bd2e63ae0efae0440474288565..3f6c640f6a983c515034e0698676632d0cb57824 100644 --- a/internal/agent/event.go +++ b/internal/agent/event.go @@ -7,23 +7,23 @@ import ( "github.com/charmbracelet/crush/internal/event" ) -func (a sessionAgent) eventPromptSent(sessionID string) { +func (a *sessionAgent) eventPromptSent(sessionID string) { event.PromptSent( - a.eventCommon(sessionID, a.largeModel)..., + a.eventCommon(sessionID, a.largeModel.Get())..., ) } -func (a sessionAgent) eventPromptResponded(sessionID string, duration time.Duration) { +func (a *sessionAgent) eventPromptResponded(sessionID string, duration time.Duration) { event.PromptResponded( append( - a.eventCommon(sessionID, a.largeModel), + a.eventCommon(sessionID, a.largeModel.Get()), "prompt duration pretty", duration.String(), "prompt duration in seconds", int64(duration.Seconds()), )..., ) } -func (a sessionAgent) eventTokensUsed(sessionID string, model Model, usage fantasy.Usage, cost float64) { +func (a *sessionAgent) eventTokensUsed(sessionID string, model Model, usage fantasy.Usage, cost float64) { event.TokensUsed( append( a.eventCommon(sessionID, model), @@ -37,7 +37,7 @@ func (a sessionAgent) eventTokensUsed(sessionID string, model Model, usage fanta ) } -func (a sessionAgent) eventCommon(sessionID string, model Model) []any { +func (a *sessionAgent) eventCommon(sessionID string, model Model) []any { m := model.ModelCfg return []any{ diff --git a/internal/csync/slices.go b/internal/csync/slices.go index c5c635683e70046694f1cdf647aac8cb425abd24..fcce9881b6e27021adcc9462b123f49d469dcd9f 100644 --- a/internal/csync/slices.go +++ b/internal/csync/slices.go @@ -2,7 +2,6 @@ package csync import ( "iter" - "slices" "sync" ) @@ -63,24 +62,6 @@ func (s *Slice[T]) Append(items ...T) { s.inner = append(s.inner, items...) } -// Prepend adds an element to the beginning of the slice. -func (s *Slice[T]) Prepend(item T) { - s.mu.Lock() - defer s.mu.Unlock() - s.inner = append([]T{item}, s.inner...) -} - -// Delete removes the element at the specified index. -func (s *Slice[T]) Delete(index int) bool { - s.mu.Lock() - defer s.mu.Unlock() - if index < 0 || index >= len(s.inner) { - return false - } - s.inner = slices.Delete(s.inner, index, index+1) - return true -} - // Get returns the element at the specified index. func (s *Slice[T]) Get(index int) (T, bool) { s.mu.RLock() @@ -92,17 +73,6 @@ func (s *Slice[T]) Get(index int) (T, bool) { return s.inner[index], true } -// Set updates the element at the specified index. -func (s *Slice[T]) Set(index int, item T) bool { - s.mu.Lock() - defer s.mu.Unlock() - if index < 0 || index >= len(s.inner) { - return false - } - s.inner[index] = item - return true -} - // Len returns the number of elements in the slice. func (s *Slice[T]) Len() int { s.mu.RLock() @@ -131,10 +101,7 @@ func (s *Slice[T]) Seq() iter.Seq[T] { // Seq2 returns an iterator that yields index-value pairs from the slice. func (s *Slice[T]) Seq2() iter.Seq2[int, T] { - s.mu.RLock() - items := make([]T, len(s.inner)) - copy(items, s.inner) - s.mu.RUnlock() + items := s.Copy() return func(yield func(int, T) bool) { for i, v := range items { if !yield(i, v) { @@ -143,3 +110,12 @@ func (s *Slice[T]) Seq2() iter.Seq2[int, T] { } } } + +// Copy returns a copy of the inner slice. +func (s *Slice[T]) Copy() []T { + s.mu.RLock() + defer s.mu.RUnlock() + items := make([]T, len(s.inner)) + copy(items, s.inner) + return items +} diff --git a/internal/csync/slices_test.go b/internal/csync/slices_test.go index 85aedbaba40103ff9a8979e5c70299223f74591f..c7946ac6f1a84614def05b7b6e7e9b0ed11b3a73 100644 --- a/internal/csync/slices_test.go +++ b/internal/csync/slices_test.go @@ -109,44 +109,6 @@ func TestSlice(t *testing.T) { require.Equal(t, "world", val) }) - t.Run("Prepend", func(t *testing.T) { - s := NewSlice[string]() - s.Append("world") - s.Prepend("hello") - - require.Equal(t, 2, s.Len()) - val, ok := s.Get(0) - require.True(t, ok) - require.Equal(t, "hello", val) - - val, ok = s.Get(1) - require.True(t, ok) - require.Equal(t, "world", val) - }) - - t.Run("Delete", func(t *testing.T) { - s := NewSliceFrom([]int{1, 2, 3, 4, 5}) - - // Delete middle element - ok := s.Delete(2) - require.True(t, ok) - require.Equal(t, 4, s.Len()) - - expected := []int{1, 2, 4, 5} - actual := slices.Collect(s.Seq()) - require.Equal(t, expected, actual) - - // Delete out of bounds - ok = s.Delete(10) - require.False(t, ok) - require.Equal(t, 4, s.Len()) - - // Delete negative index - ok = s.Delete(-1) - require.False(t, ok) - require.Equal(t, 4, s.Len()) - }) - t.Run("Get", func(t *testing.T) { s := NewSliceFrom([]string{"a", "b", "c"}) @@ -163,25 +125,6 @@ func TestSlice(t *testing.T) { require.False(t, ok) }) - t.Run("Set", func(t *testing.T) { - s := NewSliceFrom([]string{"a", "b", "c"}) - - ok := s.Set(1, "modified") - require.True(t, ok) - - val, ok := s.Get(1) - require.True(t, ok) - require.Equal(t, "modified", val) - - // Out of bounds - ok = s.Set(10, "invalid") - require.False(t, ok) - - // Negative index - ok = s.Set(-1, "invalid") - require.False(t, ok) - }) - t.Run("SetSlice", func(t *testing.T) { s := NewSlice[int]() s.Append(1) diff --git a/internal/csync/value.go b/internal/csync/value.go new file mode 100644 index 0000000000000000000000000000000000000000..17528a281e0d34d49b206a7c3901b892370c18ba --- /dev/null +++ b/internal/csync/value.go @@ -0,0 +1,44 @@ +package csync + +import ( + "reflect" + "sync" +) + +// Value is a generic thread-safe wrapper for any value type. +// +// For slices, use [Slice]. For maps, use [Map]. Pointers are not supported. +type Value[T any] struct { + v T + mu sync.RWMutex +} + +// NewValue creates a new Value with the given initial value. +// +// Panics if t is a pointer, slice, or map. Use the dedicated types for those. +func NewValue[T any](t T) *Value[T] { + v := reflect.ValueOf(t) + switch v.Kind() { + case reflect.Pointer: + panic("csync.Value does not support pointer types") + case reflect.Slice: + panic("csync.Value does not support slice types; use csync.Slice") + case reflect.Map: + panic("csync.Value does not support map types; use csync.Map") + } + return &Value[T]{v: t} +} + +// Get returns the current value. +func (v *Value[T]) Get() T { + v.mu.RLock() + defer v.mu.RUnlock() + return v.v +} + +// Set updates the value. +func (v *Value[T]) Set(t T) { + v.mu.Lock() + defer v.mu.Unlock() + v.v = t +} diff --git a/internal/csync/value_test.go b/internal/csync/value_test.go new file mode 100644 index 0000000000000000000000000000000000000000..3fa41d85144ea9373c7d440238c0321f52286330 --- /dev/null +++ b/internal/csync/value_test.go @@ -0,0 +1,99 @@ +package csync + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValue_GetSet(t *testing.T) { + t.Parallel() + + v := NewValue(42) + require.Equal(t, 42, v.Get()) + + v.Set(100) + require.Equal(t, 100, v.Get()) +} + +func TestValue_ZeroValue(t *testing.T) { + t.Parallel() + + v := NewValue("") + require.Equal(t, "", v.Get()) + + v.Set("hello") + require.Equal(t, "hello", v.Get()) +} + +func TestValue_Struct(t *testing.T) { + t.Parallel() + + type config struct { + Name string + Count int + } + + v := NewValue(config{Name: "test", Count: 1}) + require.Equal(t, config{Name: "test", Count: 1}, v.Get()) + + v.Set(config{Name: "updated", Count: 2}) + require.Equal(t, config{Name: "updated", Count: 2}, v.Get()) +} + +func TestValue_PointerPanics(t *testing.T) { + t.Parallel() + + require.Panics(t, func() { + NewValue(&struct{}{}) + }) +} + +func TestValue_SlicePanics(t *testing.T) { + t.Parallel() + + require.Panics(t, func() { + NewValue([]string{"a", "b"}) + }) +} + +func TestValue_MapPanics(t *testing.T) { + t.Parallel() + + require.Panics(t, func() { + NewValue(map[string]int{"a": 1}) + }) +} + +func TestValue_ConcurrentAccess(t *testing.T) { + t.Parallel() + + v := NewValue(0) + var wg sync.WaitGroup + + // Concurrent writers. + for i := range 100 { + wg.Add(1) + go func(val int) { + defer wg.Done() + v.Set(val) + }(i) + } + + // Concurrent readers. + for range 100 { + wg.Add(1) + go func() { + defer wg.Done() + _ = v.Get() + }() + } + + wg.Wait() + + // Value should be one of the set values (0-99). + got := v.Get() + require.GreaterOrEqual(t, got, 0) + require.Less(t, got, 100) +} From f7de0d5d9ec1721bd371e77e20b3b9281eb829d5 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 14 Jan 2026 16:48:04 -0300 Subject: [PATCH 39/58] chore(deps): update catwalk to being openai gpt 5.2 codex --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 72f53ccfbd743a333730b34ac28cc0edffe5aa50..7d96d6bca5a5c659814a1911c11800f7b2e71c61 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/aymanbagabas/go-udiff v0.3.1 github.com/bmatcuk/doublestar/v4 v4.9.2 github.com/charlievieth/fastwalk v1.0.14 - github.com/charmbracelet/catwalk v0.13.0 + github.com/charmbracelet/catwalk v0.14.1 github.com/charmbracelet/colorprofile v0.4.1 github.com/charmbracelet/fang v0.4.4 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560 diff --git a/go.sum b/go.sum index 8973fcdc1b227d0b30aad691220103319afa93ca..5a2b20e02e02085bf7f8559d946bced27a20cc27 100644 --- a/go.sum +++ b/go.sum @@ -96,8 +96,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/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/catwalk v0.13.0 h1:L+chddP+PJvX3Vl+hqlWW5HAwBErlkL/friQXih1JQI= -github.com/charmbracelet/catwalk v0.13.0/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ= +github.com/charmbracelet/catwalk v0.14.1 h1:n16H880MHW8PPgQeh0dorP77AJMxw5JcOUPuC3FFhaQ= +github.com/charmbracelet/catwalk v0.14.1/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY= From 632ba621bfc07721baf9207e49ebb9324b5122b4 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 14 Jan 2026 15:28:01 -0500 Subject: [PATCH 40/58] chore: tidy agent package (#1857) --- internal/agent/agent.go | 23 +++++++++++++++-------- internal/agent/coordinator.go | 2 +- internal/agent/tools/diagnostics.go | 2 +- internal/agent/tools/edit.go | 19 ++++++++++++------- internal/agent/tools/fetch.go | 22 +++++++++++++--------- internal/agent/tools/ls.go | 17 ++++++++++++----- internal/agent/tools/view.go | 7 ------- internal/agent/tools/write.go | 9 +-------- 8 files changed, 55 insertions(+), 46 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index c0b9080bb640085c6fd0fdbde8db0fbfe7f476dd..4377ab56cc9e5666100378d6166321051774b26a 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -40,7 +40,14 @@ import ( "github.com/charmbracelet/crush/internal/stringext" ) -const defaultSessionName = "Untitled Session" +const ( + defaultSessionName = "Untitled Session" + + // Constants for auto-summarization thresholds + largeContextWindowThreshold = 200_000 + largeContextWindowBuffer = 20_000 + smallContextWindowRatio = 0.2 +) //go:embed templates/title.md var titlePrompt []byte @@ -383,10 +390,10 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy tokens := currentSession.CompletionTokens + currentSession.PromptTokens remaining := cw - tokens var threshold int64 - if cw > 200_000 { - threshold = 20_000 + if cw > largeContextWindowThreshold { + threshold = largeContextWindowBuffer } else { - threshold = int64(float64(cw) * 0.2) + threshold = int64(float64(cw) * smallContextWindowRatio) } if (remaining <= threshold) && !a.disableAutoSummarize { shouldSummarize = true @@ -720,15 +727,15 @@ func (a *sessionAgent) getSessionMessages(ctx context.Context, session session.S } if session.SummaryMessageID != "" { - summaryMsgInex := -1 + summaryMsgIndex := -1 for i, msg := range msgs { if msg.ID == session.SummaryMessageID { - summaryMsgInex = i + summaryMsgIndex = i break } } - if summaryMsgInex != -1 { - msgs = msgs[summaryMsgInex:] + if summaryMsgIndex != -1 { + msgs = msgs[summaryMsgIndex:] msgs[0].Role = message.User } } diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index d48c074ff5d9c02db1427aa44ee13aed4c9af7e2..943c3efc41b33ea9f261b4ffc7256b6f544beff9 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -489,7 +489,7 @@ func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Mo } if smallCatwalkModel == nil { - return Model{}, Model{}, errors.New("snall model not found in provider config") + return Model{}, Model{}, errors.New("small model not found in provider config") } largeModelID := largeModelCfg.Model diff --git a/internal/agent/tools/diagnostics.go b/internal/agent/tools/diagnostics.go index de1fc9a13c95296bb8e637f56b4d0abc4f25c34b..85e8b8d0f7d997f8db83f0d6176ce30c644b86f0 100644 --- a/internal/agent/tools/diagnostics.go +++ b/internal/agent/tools/diagnostics.go @@ -16,7 +16,7 @@ import ( ) type DiagnosticsParams struct { - FilePath string `json:"file_path,omitempty" description:"The path to the file to get diagnostics for (leave w empty for project diagnostics)"` + FilePath string `json:"file_path,omitempty" description:"The path to the file to get diagnostics for (leave empty for project diagnostics)"` } const DiagnosticsToolName = "lsp_diagnostics" diff --git a/internal/agent/tools/edit.go b/internal/agent/tools/edit.go index a3680d009c6d76f8bcb3e39f1c1ddd2041aa1e52..8d8bb87be59dbfb0e038ba40e2b09a3c14d7624b 100644 --- a/internal/agent/tools/edit.go +++ b/internal/agent/tools/edit.go @@ -44,6 +44,11 @@ type EditResponseMetadata struct { const EditToolName = "edit" +var ( + oldStringNotFoundErr = fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks.") + oldStringMultipleMatchesErr = fantasy.NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true") +) + //go:embed edit.md var editDescription []byte @@ -217,12 +222,12 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool newContent = strings.ReplaceAll(oldContent, oldString, "") deletionCount = strings.Count(oldContent, oldString) if deletionCount == 0 { - return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil + return oldStringNotFoundErr, nil } } else { index := strings.Index(oldContent, oldString) if index == -1 { - return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil + return oldStringNotFoundErr, nil } lastIndex := strings.LastIndex(oldContent, oldString) @@ -287,7 +292,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool } } if file.Content != oldContent { - // User Manually changed the content store an intermediate version + // User manually changed the content; store an intermediate version _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent) if err != nil { slog.Error("Error creating file history version", "error", err) @@ -353,17 +358,17 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep newContent = strings.ReplaceAll(oldContent, oldString, newString) replacementCount = strings.Count(oldContent, oldString) if replacementCount == 0 { - return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil + return oldStringNotFoundErr, nil } } else { index := strings.Index(oldContent, oldString) if index == -1 { - return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil + return oldStringNotFoundErr, nil } lastIndex := strings.LastIndex(oldContent, oldString) if index != lastIndex { - return fantasy.NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil + return oldStringMultipleMatchesErr, nil } newContent = oldContent[:index] + newString + oldContent[index+len(oldString):] @@ -425,7 +430,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep } } if file.Content != oldContent { - // User Manually changed the content store an intermediate version + // User manually changed the content; store an intermediate version _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent) if err != nil { slog.Debug("Error creating file history version", "error", err) diff --git a/internal/agent/tools/fetch.go b/internal/agent/tools/fetch.go index 29fa6f15b5a90fe2dd8d34ef383990b892b742c3..fdb63f057958e5e5a67affe0783a452c27febf41 100644 --- a/internal/agent/tools/fetch.go +++ b/internal/agent/tools/fetch.go @@ -73,12 +73,14 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } + // maxFetchTimeoutSeconds is the maximum allowed timeout for fetch requests (2 minutes) + const maxFetchTimeoutSeconds = 120 + // Handle timeout with context requestCtx := ctx if params.Timeout > 0 { - maxTimeout := 120 // 2 minutes - if params.Timeout > maxTimeout { - params.Timeout = maxTimeout + if params.Timeout > maxFetchTimeoutSeconds { + params.Timeout = maxFetchTimeoutSeconds } var cancel context.CancelFunc requestCtx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Second) @@ -102,7 +104,10 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt return fantasy.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil } - maxSize := int64(5 * 1024 * 1024) // 5MB + // maxFetchResponseSizeBytes is the maximum size of response body to read (5MB) + const maxFetchResponseSizeBytes = int64(5 * 1024 * 1024) + + maxSize := maxFetchResponseSizeBytes body, err := io.ReadAll(io.LimitReader(resp.Body, maxSize)) if err != nil { return fantasy.NewTextErrorResponse("Failed to read response body: " + err.Error()), nil @@ -110,8 +115,8 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt content := string(body) - isValidUt8 := utf8.ValidString(content) - if !isValidUt8 { + validUTF8 := utf8.ValidString(content) + if !validUTF8 { return fantasy.NewTextErrorResponse("Response content is not valid UTF-8"), nil } contentType := resp.Header.Get("Content-Type") @@ -154,9 +159,8 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt content = "\n\n" + body + "\n\n" } } - // calculate byte size of content - contentSize := int64(len(content)) - if contentSize > MaxReadSize { + // truncate content if it exceeds max read size + if int64(len(content)) > MaxReadSize { content = content[:MaxReadSize] content += fmt.Sprintf("\n\n[Content truncated to %d bytes]", MaxReadSize) } diff --git a/internal/agent/tools/ls.go b/internal/agent/tools/ls.go index eff7bac0757b5956f669a752c378cab548affb85..20bb1bad4c2d92bb02e564045d4553206ffd12a1 100644 --- a/internal/agent/tools/ls.go +++ b/internal/agent/tools/ls.go @@ -28,10 +28,17 @@ type LSPermissionsParams struct { Depth int `json:"depth"` } +type NodeType string + +const ( + NodeTypeFile NodeType = "file" + NodeTypeDirectory NodeType = "directory" +) + type TreeNode struct { Name string `json:"name"` Path string `json:"path"` - Type string `json:"type"` // "file" or "directory" + Type NodeType `json:"type"` Children []*TreeNode `json:"children,omitempty"` } @@ -179,9 +186,9 @@ func createFileTree(sortedPaths []string, rootPath string) []*TreeNode { isLastPart := i == len(parts)-1 isDir := !isLastPart || strings.HasSuffix(relativePath, string(filepath.Separator)) - nodeType := "file" + nodeType := NodeTypeFile if isDir { - nodeType = "directory" + nodeType = NodeTypeDirectory } newNode := &TreeNode{ Name: part, @@ -228,13 +235,13 @@ func printNode(builder *strings.Builder, node *TreeNode, level int) { indent := strings.Repeat(" ", level) nodeName := node.Name - if node.Type == "directory" { + if node.Type == NodeTypeDirectory { nodeName = nodeName + "/" } fmt.Fprintf(builder, "%s- %s\n", indent, nodeName) - if node.Type == "directory" && len(node.Children) > 0 { + if node.Type == NodeTypeDirectory && len(node.Children) > 0 { for _, child := range node.Children { printNode(builder, child, level+1) } diff --git a/internal/agent/tools/view.go b/internal/agent/tools/view.go index 96150669c292d457f7e8f1bb514f2a209bb73b6e..35865cf43f7c587d60764b3ed177374940bbe2dc 100644 --- a/internal/agent/tools/view.go +++ b/internal/agent/tools/view.go @@ -35,13 +35,6 @@ type ViewPermissionsParams struct { Limit int `json:"limit"` } -type viewTool struct { - lspClients *csync.Map[string, *lsp.Client] - workingDir string - permissions permission.Service - skillsPaths []string -} - type ViewResponseMetadata struct { FilePath string `json:"file_path"` Content string `json:"content"` diff --git a/internal/agent/tools/write.go b/internal/agent/tools/write.go index bbd5e50cf863d4d13503f6cee926b57df80f69bc..8becaea3c08157897dcece7b3d5d4de5cb2ee929 100644 --- a/internal/agent/tools/write.go +++ b/internal/agent/tools/write.go @@ -36,13 +36,6 @@ type WritePermissionsParams struct { NewContent string `json:"new_content,omitempty"` } -type writeTool struct { - lspClients *csync.Map[string, *lsp.Client] - permissions permission.Service - files history.Service - workingDir string -} - type WriteResponseMetadata struct { Diff string `json:"diff"` Additions int `json:"additions"` @@ -148,7 +141,7 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis } } if file.Content != oldContent { - // User Manually changed the content store an intermediate version + // User manually changed the content; store an intermediate version _, err = files.CreateVersion(ctx, sessionID, filePath, oldContent) if err != nil { slog.Error("Error creating file history version", "error", err) From 75209207bcd58dfeb159fe9df72c9bf67fe8cd4c Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 14 Jan 2026 15:28:26 -0500 Subject: [PATCH 41/58] chore: fix more typos (#1863) --- internal/agent/agent_test.go | 2 +- internal/agent/hyper/provider.go | 2 +- internal/app/lsp.go | 2 +- internal/update/update.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 38a154d8c449b2cc148ef5038d7facf49450bbb3..d61395a6080c6d9052545b1f82024f324766632b 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -456,7 +456,7 @@ func TestCoderAgent(t *testing.T) { }) t.Run("sourcegraph tool", func(t *testing.T) { if runtime.GOOS == "darwin" { - t.Skip("skipping flacky test on macos for now") + t.Skip("skipping flakey test on macos for now") } agent, env := setupAgent(t, pair) diff --git a/internal/agent/hyper/provider.go b/internal/agent/hyper/provider.go index ea2f4a18eaeec017f5f3f02576504a424f50bbf1..03278ae99f87608c65263b0ffef7fb473cd58e31 100644 --- a/internal/agent/hyper/provider.go +++ b/internal/agent/hyper/provider.go @@ -326,5 +326,5 @@ func retryAfter(resp *http.Response) string { d := time.Duration(after) * time.Second return "Try again in " + d.String() } - return "Try again in later" + return "Try again later" } diff --git a/internal/app/lsp.go b/internal/app/lsp.go index dfebfe565d96ed2798e1e89cb7e82aaa7b78c13f..23a5447af92872223f91d3283cf6663aae0d1d07 100644 --- a/internal/app/lsp.go +++ b/internal/app/lsp.go @@ -67,7 +67,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config lspClient.SetServerState(lsp.StateError) updateLSPState(name, lsp.StateError, err, lspClient, 0) } else { - // Server reached a ready state scuccessfully. + // Server reached a ready state successfully. slog.Debug("LSP server is ready", "name", name) lspClient.SetServerState(lsp.StateReady) updateLSPState(name, lsp.StateReady, nil, lspClient, 0) diff --git a/internal/update/update.go b/internal/update/update.go index a813fe3516dc28233e3df01c77d4d62d4d97db18..dd733da542259e60cc166c3ef48e645a527a548f 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -106,7 +106,7 @@ func (c *github) Latest(ctx context.Context) (*Release, error) { if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("github api returned status %d: %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body)) } var release Release From 3a1da539eae24c6d1474add2aa31e7956128e73c Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 14 Jan 2026 21:54:08 +0100 Subject: [PATCH 42/58] fix: resolve extra headers for providers (#1764) --- internal/config/load.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/config/load.go b/internal/config/load.go index 4d7ea133b3034c46cc4e9b4097e372b7f18aa7a9..25139cb5f4b2ba8013525bfde025f04cb267d1b8 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -177,6 +177,14 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know if len(config.ExtraHeaders) > 0 { maps.Copy(headers, config.ExtraHeaders) } + for k, v := range headers { + resolved, err := resolver.ResolveValue(v) + if err != nil { + slog.Error("Could not resolve provider header", "err", err.Error()) + continue + } + headers[k] = resolved + } prepared := ProviderConfig{ ID: string(p.ID), Name: p.Name, @@ -307,6 +315,15 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know continue } + for k, v := range providerConfig.ExtraHeaders { + resolved, err := resolver.ResolveValue(v) + if err != nil { + slog.Error("Could not resolve provider header", "err", err.Error()) + continue + } + providerConfig.ExtraHeaders[k] = resolved + } + c.Providers.Set(id, providerConfig) } return nil From 2842d4df64e1e87ebe54f0f0af0f60e7cf2592ed Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Thu, 15 Jan 2026 08:56:55 -0500 Subject: [PATCH 43/58] docs(readme): update features section --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cfcd765ee150d181c00cf649a5ba15055b6bdbae..0003c9223283cdfd59ea40b804d3856dd7923db9 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ - **Session-Based:** maintain multiple work sessions and contexts per project - **LSP-Enhanced:** Crush uses LSPs for additional context, just like you do - **Extensible:** add capabilities via MCPs (`http`, `stdio`, and `sse`) -- **Works Everywhere:** first-class support in every terminal on macOS, Linux, Windows (PowerShell and WSL), FreeBSD, OpenBSD, and NetBSD +- **Works Everywhere:** first-class support in every terminal on macOS, Linux, Windows (PowerShell and WSL), Android, FreeBSD, OpenBSD, and NetBSD +- **Industrial Grade:** built on the Charm ecosystem, powering 25k+ applications, from leading open source projects, to business-critical infrastructure ## Installation From 7c96457bf40252126517b377dcdb3391040f540b Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 15 Jan 2026 11:45:48 -0300 Subject: [PATCH 44/58] fix: make hyper and copilot link styled on ui (#1872) --- internal/agent/agent.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 4377ab56cc9e5666100378d6166321051774b26a..3fe13094666b1b8ff249459ef8674e61e03c9f2a 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -38,6 +38,7 @@ import ( "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/stringext" + "github.com/charmbracelet/x/exp/charmtone" ) const ( @@ -473,18 +474,19 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy var fantasyErr *fantasy.Error var providerErr *fantasy.ProviderError const defaultTitle = "Provider Error" + linkStyle := lipgloss.NewStyle().Foreground(charmtone.Guac).Underline(true) if isCancelErr { currentAssistant.AddFinish(message.FinishReasonCanceled, "User canceled request", "") } else if isPermissionErr { currentAssistant.AddFinish(message.FinishReasonPermissionDenied, "User denied permission", "") } else if errors.Is(err, hyper.ErrNoCredits) { url := hyper.BaseURL() - link := lipgloss.NewStyle().Hyperlink(url, "id=hyper").Render(url) + link := linkStyle.Hyperlink(url, "id=hyper").Render(url) currentAssistant.AddFinish(message.FinishReasonError, "No credits", "You're out of credits. Add more at "+link) } else if errors.As(err, &providerErr) { if providerErr.Message == "The requested model is not supported." { url := "https://github.com/settings/copilot/features" - link := lipgloss.NewStyle().Hyperlink(url, "id=hyper").Render(url) + link := linkStyle.Hyperlink(url, "id=copilot").Render(url) currentAssistant.AddFinish( message.FinishReasonError, "Copilot model not enabled", From b92a466afb7af5b9b6cd8162381108168e60470d Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Thu, 15 Jan 2026 14:10:32 -0500 Subject: [PATCH 45/58] docs(readme): remove extra comma --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0003c9223283cdfd59ea40b804d3856dd7923db9..6a57c7934d0714cd4e0ae3f30fab108d03196b98 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ - **LSP-Enhanced:** Crush uses LSPs for additional context, just like you do - **Extensible:** add capabilities via MCPs (`http`, `stdio`, and `sse`) - **Works Everywhere:** first-class support in every terminal on macOS, Linux, Windows (PowerShell and WSL), Android, FreeBSD, OpenBSD, and NetBSD -- **Industrial Grade:** built on the Charm ecosystem, powering 25k+ applications, from leading open source projects, to business-critical infrastructure +- **Industrial Grade:** built on the Charm ecosystem, powering 25k+ applications, from leading open source projects to business-critical infrastructure ## Installation From ab6d97113665941296269a48c4ea47f886829e41 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 15 Jan 2026 21:13:01 +0100 Subject: [PATCH 46/58] fix: try to make the search tool more reliable (#1779) --- internal/agent/tools/search.go | 172 +++++++++++++++++------------ internal/agent/tools/web_search.go | 3 + 2 files changed, 104 insertions(+), 71 deletions(-) diff --git a/internal/agent/tools/search.go b/internal/agent/tools/search.go index 64c3219f169b1c8ce8284b86203e84bfb19d0e59..9df7be8764ab952a23f25d624f72748696a86aac 100644 --- a/internal/agent/tools/search.go +++ b/internal/agent/tools/search.go @@ -4,10 +4,13 @@ import ( "context" "fmt" "io" + "math/rand/v2" "net/http" "net/url" "slices" "strings" + "sync" + "time" "golang.org/x/net/html" ) @@ -20,28 +23,41 @@ type SearchResult struct { Position int } -// searchDuckDuckGo performs a web search using DuckDuckGo's HTML endpoint. +var userAgents = []string{ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", +} + +var acceptLanguages = []string{ + "en-US,en;q=0.9", + "en-US,en;q=0.9,es;q=0.8", + "en-GB,en;q=0.9,en-US;q=0.8", + "en-US,en;q=0.5", + "en-CA,en;q=0.9,en-US;q=0.8", +} + func searchDuckDuckGo(ctx context.Context, client *http.Client, query string, maxResults int) ([]SearchResult, error) { if maxResults <= 0 { maxResults = 10 } - formData := url.Values{} - formData.Set("q", query) - formData.Set("b", "") - formData.Set("kl", "") + searchURL := "https://lite.duckduckgo.com/lite/?q=" + url.QueryEscape(query) - req, err := http.NewRequestWithContext(ctx, "POST", "https://html.duckduckgo.com/html", strings.NewReader(formData.Encode())) + req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("User-Agent", BrowserUserAgent) - req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") - req.Header.Set("Accept-Language", "en-US,en;q=0.5") - req.Header.Set("Accept-Encoding", "gzip, deflate") - req.Header.Set("Referer", "https://duckduckgo.com/") + setRandomizedHeaders(req) resp, err := client.Do(req) if err != nil { @@ -49,10 +65,8 @@ func searchDuckDuckGo(ctx context.Context, client *http.Client, query string, ma } defer resp.Body.Close() - // Accept both 200 (OK) and 202 (Accepted). - // DuckDuckGo may still return 202 for rate limiting or bot detection. if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { - return nil, fmt.Errorf("search failed with status code: %d (DuckDuckGo may be rate limiting requests)", resp.StatusCode) + return nil, fmt.Errorf("search failed with status code: %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) @@ -60,85 +74,90 @@ func searchDuckDuckGo(ctx context.Context, client *http.Client, query string, ma return nil, fmt.Errorf("failed to read response: %w", err) } - return parseSearchResults(string(body), maxResults) + return parseLiteSearchResults(string(body), maxResults) } -// parseSearchResults extracts search results from DuckDuckGo HTML response. -func parseSearchResults(htmlContent string, maxResults int) ([]SearchResult, error) { +func setRandomizedHeaders(req *http.Request) { + req.Header.Set("User-Agent", userAgents[rand.IntN(len(userAgents))]) + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + req.Header.Set("Accept-Language", acceptLanguages[rand.IntN(len(acceptLanguages))]) + req.Header.Set("Accept-Encoding", "identity") + req.Header.Set("Connection", "keep-alive") + req.Header.Set("Upgrade-Insecure-Requests", "1") + req.Header.Set("Sec-Fetch-Dest", "document") + req.Header.Set("Sec-Fetch-Mode", "navigate") + req.Header.Set("Sec-Fetch-Site", "none") + req.Header.Set("Sec-Fetch-User", "?1") + req.Header.Set("Cache-Control", "max-age=0") + if rand.IntN(2) == 0 { + req.Header.Set("DNT", "1") + } +} + +func parseLiteSearchResults(htmlContent string, maxResults int) ([]SearchResult, error) { doc, err := html.Parse(strings.NewReader(htmlContent)) if err != nil { return nil, fmt.Errorf("failed to parse HTML: %w", err) } var results []SearchResult - var traverse func(*html.Node) + var currentResult *SearchResult + var traverse func(*html.Node) traverse = func(n *html.Node) { - if n.Type == html.ElementNode && n.Data == "div" && hasClass(n, "result") { - result := extractResult(n) - if result != nil && result.Link != "" && !strings.Contains(result.Link, "y.js") { - result.Position = len(results) + 1 - results = append(results, *result) - if len(results) >= maxResults { - return + if n.Type == html.ElementNode { + if n.Data == "a" && hasClass(n, "result-link") { + if currentResult != nil && currentResult.Link != "" { + currentResult.Position = len(results) + 1 + results = append(results, *currentResult) + if len(results) >= maxResults { + return + } + } + currentResult = &SearchResult{Title: getTextContent(n)} + for _, attr := range n.Attr { + if attr.Key == "href" { + currentResult.Link = cleanDuckDuckGoURL(attr.Val) + break + } } } + if n.Data == "td" && hasClass(n, "result-snippet") && currentResult != nil { + currentResult.Snippet = getTextContent(n) + } } - for c := n.FirstChild; c != nil && len(results) < maxResults; c = c.NextSibling { + for c := n.FirstChild; c != nil; c = c.NextSibling { + if len(results) >= maxResults { + return + } traverse(c) } } traverse(doc) + + if currentResult != nil && currentResult.Link != "" && len(results) < maxResults { + currentResult.Position = len(results) + 1 + results = append(results, *currentResult) + } + return results, nil } -// hasClass checks if an HTML node has a specific class. func hasClass(n *html.Node, class string) bool { for _, attr := range n.Attr { if attr.Key == "class" { - return slices.Contains(strings.Fields(attr.Val), class) - } - } - return false -} - -// extractResult extracts a search result from a result div node. -func extractResult(n *html.Node) *SearchResult { - result := &SearchResult{} - - var traverse func(*html.Node) - traverse = func(node *html.Node) { - if node.Type == html.ElementNode { - // Look for title link. - if node.Data == "a" && hasClass(node, "result__a") { - result.Title = getTextContent(node) - for _, attr := range node.Attr { - if attr.Key == "href" { - result.Link = cleanDuckDuckGoURL(attr.Val) - break - } - } - } - // Look for snippet. - if node.Data == "a" && hasClass(node, "result__snippet") { - result.Snippet = getTextContent(node) + if slices.Contains(strings.Fields(attr.Val), class) { + return true } } - for c := node.FirstChild; c != nil; c = c.NextSibling { - traverse(c) - } } - - traverse(n) - return result + return false } -// getTextContent extracts all text content from a node and its children. func getTextContent(n *html.Node) string { var text strings.Builder var traverse func(*html.Node) - traverse = func(node *html.Node) { if node.Type == html.TextNode { text.WriteString(node.Data) @@ -147,22 +166,18 @@ func getTextContent(n *html.Node) string { traverse(c) } } - traverse(n) return strings.TrimSpace(text.String()) } -// cleanDuckDuckGoURL extracts the actual URL from DuckDuckGo's redirect URL. func cleanDuckDuckGoURL(rawURL string) string { if strings.HasPrefix(rawURL, "//duckduckgo.com/l/?uddg=") { - // Extract the actual URL from the redirect. if idx := strings.Index(rawURL, "uddg="); idx != -1 { encoded := rawURL[idx+5:] if ampIdx := strings.Index(encoded, "&"); ampIdx != -1 { encoded = encoded[:ampIdx] } - decoded, err := url.QueryUnescape(encoded) - if err == nil { + if decoded, err := url.QueryUnescape(encoded); err == nil { return decoded } } @@ -170,20 +185,35 @@ func cleanDuckDuckGoURL(rawURL string) string { return rawURL } -// formatSearchResults formats search results for LLM consumption. func formatSearchResults(results []SearchResult) string { if len(results) == 0 { - return "No results were found for your search query. This could be due to DuckDuckGo's bot detection or the query returned no matches. Please try rephrasing your search or try again in a few minutes." + return "No results found. Try rephrasing your search." } var sb strings.Builder sb.WriteString(fmt.Sprintf("Found %d search results:\n\n", len(results))) - for _, result := range results { sb.WriteString(fmt.Sprintf("%d. %s\n", result.Position, result.Title)) sb.WriteString(fmt.Sprintf(" URL: %s\n", result.Link)) sb.WriteString(fmt.Sprintf(" Summary: %s\n\n", result.Snippet)) } - return sb.String() } + +var ( + lastSearchMu sync.Mutex + lastSearchTime time.Time +) + +// maybeDelaySearch adds a random delay if the last search was recent. +func maybeDelaySearch() { + lastSearchMu.Lock() + defer lastSearchMu.Unlock() + + minGap := time.Duration(500+rand.IntN(1500)) * time.Millisecond + elapsed := time.Since(lastSearchTime) + if elapsed < minGap { + time.Sleep(minGap - elapsed) + } + lastSearchTime = time.Now() +} diff --git a/internal/agent/tools/web_search.go b/internal/agent/tools/web_search.go index b604c9051b4f5b0039431c01bea0b150a318740e..5ce9280c013cdd100f6d7734c969723b21e7e3bf 100644 --- a/internal/agent/tools/web_search.go +++ b/internal/agent/tools/web_search.go @@ -3,6 +3,7 @@ package tools import ( "context" _ "embed" + "log/slog" "net/http" "time" @@ -41,7 +42,9 @@ func NewWebSearchTool(client *http.Client) fantasy.AgentTool { maxResults = 20 } + maybeDelaySearch() results, err := searchDuckDuckGo(ctx, client, params.Query, maxResults) + slog.Debug("Web search completed", "query", params.Query, "results", len(results), "err", err) if err != nil { return fantasy.NewTextErrorResponse("Failed to search: " + err.Error()), nil } From 0de07884cfe5e011d7c93118daff826e0baf36df Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 16 Jan 2026 10:48:18 -0300 Subject: [PATCH 47/58] chore: remove duplicated log Signed-off-by: Carlos Alexandro Becker --- internal/agent/agent.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 3fe13094666b1b8ff249459ef8674e61e03c9f2a..c916cfd886372ab86f6d1fbb0e8b7bde2c87dabb 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -820,7 +820,6 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user // Clean up title. var title string title = strings.ReplaceAll(resp.Response.Content.Text(), "\n", " ") - slog.Info("generated title", "title", title) // Remove thinking tags if present. title = thinkTagRegex.ReplaceAllString(title, "") From da3e44cc085963253af4425cef849e84bb16d088 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 16 Jan 2026 15:06:32 +0100 Subject: [PATCH 48/58] fix: mcps loading in non interactive mode (#1894) --- internal/agent/coordinator.go | 6 ++++++ internal/agent/tools/mcp/init.go | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 943c3efc41b33ea9f261b4ffc7256b6f544beff9..985445e1dc0e0263b6faba388888bf30ec3bfb92 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -20,6 +20,7 @@ import ( "github.com/charmbracelet/crush/internal/agent/hyper" "github.com/charmbracelet/crush/internal/agent/prompt" "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/agent/tools/mcp" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/history" @@ -411,6 +412,11 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan } } + // Wait for MCP initialization to complete before reading MCP tools. + if err := mcp.WaitForInit(ctx); err != nil { + return nil, fmt.Errorf("failed to wait for MCP initialization: %w", err) + } + for _, tool := range tools.GetMCPTools(c.permissions, c.cfg.WorkingDir()) { if agent.AllowedMCP == nil { // No MCP restrictions diff --git a/internal/agent/tools/mcp/init.go b/internal/agent/tools/mcp/init.go index bb43f7f157dc1cf2d094354a4e709e0beb1f52b6..e1e7d609efc86d0dcb510fa5963552f7d487a134 100644 --- a/internal/agent/tools/mcp/init.go +++ b/internal/agent/tools/mcp/init.go @@ -29,6 +29,8 @@ var ( sessions = csync.NewMap[string, *mcp.ClientSession]() states = csync.NewMap[string, ClientInfo]() broker = pubsub.NewBroker[Event]() + initOnce sync.Once + initDone = make(chan struct{}) ) // State represents the current state of an MCP client @@ -197,6 +199,18 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config }(name, m) } wg.Wait() + initOnce.Do(func() { close(initDone) }) +} + +// WaitForInit blocks until MCP initialization is complete. +// If Initialize was never called, this returns immediately. +func WaitForInit(ctx context.Context) error { + select { + case <-initDone: + return nil + case <-ctx.Done(): + return ctx.Err() + } } func getOrRenewClient(ctx context.Context, name string) (*mcp.ClientSession, error) { From e5e45ad5a094b430964ca1bd52ab156942f24aaf Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 16 Jan 2026 14:50:03 -0300 Subject: [PATCH 49/58] chore: update `posthog-go` to latest version --- go.mod | 3 ++- go.sum | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 7d96d6bca5a5c659814a1911c11800f7b2e71c61..df7a9053cf007703712b2c1acd947db843649ef0 100644 --- a/go.mod +++ b/go.mod @@ -45,7 +45,7 @@ require ( github.com/nxadm/tail v1.4.11 github.com/openai/openai-go/v2 v2.7.1 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c - github.com/posthog/posthog-go v1.8.2 + github.com/posthog/posthog-go v1.9.0 github.com/pressly/goose/v3 v3.26.0 github.com/qjebbs/go-jsons v1.0.0-alpha.4 github.com/rivo/uniseg v0.4.7 @@ -116,6 +116,7 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/jsonschema-go v0.3.0 // indirect diff --git a/go.sum b/go.sum index 5a2b20e02e02085bf7f8559d946bced27a20cc27..db43880aed6a487365203f17a331d6d267aa9a6e 100644 --- a/go.sum +++ b/go.sum @@ -177,6 +177,8 @@ github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7 github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE= github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= @@ -288,8 +290,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgm github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= 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.8.2 h1:v/ajsM8lq+2Z3OlQbTVWqiHI+hyh9Cd4uiQt1wFlehE= -github.com/posthog/posthog-go v1.8.2/go.mod h1:ueZiJCmHezyDHI/swIR1RmOfktLehnahJnFxEvQ9mnQ= +github.com/posthog/posthog-go v1.9.0 h1:7tRfnaHqPNrBNTnSnFLQwJ5aVz6LOBngiwl15lD8bHU= +github.com/posthog/posthog-go v1.9.0/go.mod h1:0i1H2BlsK9mHvHGc9Kp6oenUlHUqPl45hWzRtR/2PVI= 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= From 1aea2593e9c78f72b1f669e794ed54c51ee0267d Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 16 Jan 2026 14:50:25 -0300 Subject: [PATCH 50/58] chore: use posthog's default exception reporting We had a manual handling since now, but since then PostHog added official support via their SDK. This should give us stack traces! --- internal/event/event.go | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/internal/event/event.go b/internal/event/event.go index 1dee1d49113684d80a5d3f390b7f912d22d7231d..674586b06bee03f22c1bd880a5bd39b740c75f66 100644 --- a/internal/event/event.go +++ b/internal/event/event.go @@ -7,6 +7,7 @@ import ( "path/filepath" "reflect" "runtime" + "time" "github.com/charmbracelet/crush/internal/version" "github.com/posthog/posthog-go" @@ -85,19 +86,16 @@ func Error(err any, props ...any) { if client == nil { return } - // The PostHog Go client does not yet support sending exceptions. - // We're mimicking the behavior by sending the minimal info required - // for PostHog to recognize this as an exception event. - props = append( - []any{ - "$exception_list", - []map[string]string{ - {"type": reflect.TypeOf(err).String(), "value": fmt.Sprintf("%v", err)}, - }, - }, - props..., - ) - send("$exception", props...) + posthogErr := client.Enqueue(posthog.NewDefaultException( + time.Now(), + distinctId, + reflect.TypeOf(err).String(), + fmt.Sprintf("%v", err), + )) + if err != nil { + slog.Error("Failed to enqueue PostHog error", "err", err, "props", props, "posthogErr", posthogErr) + return + } } func Flush() { From 3c64ee0ce1989e3f5cc97d7b5df8e97fcc04e90a Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 16 Jan 2026 15:59:23 -0500 Subject: [PATCH 52/58] fix: editor: exclude native clipboard support from linux/386 builds (#1903) --- internal/tui/components/chat/editor/clipboard.go | 8 ++++++++ .../components/chat/editor/clipboard_linux_386.go | 7 +++++++ .../tui/components/chat/editor/clipboard_other.go | 15 +++++++++++++++ internal/tui/components/chat/editor/editor.go | 10 +++++++--- 4 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 internal/tui/components/chat/editor/clipboard.go create mode 100644 internal/tui/components/chat/editor/clipboard_linux_386.go create mode 100644 internal/tui/components/chat/editor/clipboard_other.go diff --git a/internal/tui/components/chat/editor/clipboard.go b/internal/tui/components/chat/editor/clipboard.go new file mode 100644 index 0000000000000000000000000000000000000000..de4b95da3cab6069bf31f61b5fb9e2908f970c07 --- /dev/null +++ b/internal/tui/components/chat/editor/clipboard.go @@ -0,0 +1,8 @@ +package editor + +type clipboardFormat int + +const ( + clipboardFormatText clipboardFormat = iota + clipboardFormatImage +) diff --git a/internal/tui/components/chat/editor/clipboard_linux_386.go b/internal/tui/components/chat/editor/clipboard_linux_386.go new file mode 100644 index 0000000000000000000000000000000000000000..85f4111f7ea2ec50da457720c4cfba28f1e18ca7 --- /dev/null +++ b/internal/tui/components/chat/editor/clipboard_linux_386.go @@ -0,0 +1,7 @@ +//go:build linux && 386 + +package editor + +func readClipboard(clipboardFormat) ([]byte, error) { + return nil, errClipboardPlatformUnsupported +} diff --git a/internal/tui/components/chat/editor/clipboard_other.go b/internal/tui/components/chat/editor/clipboard_other.go new file mode 100644 index 0000000000000000000000000000000000000000..6c744385ef5d9ca58a3f6a3aca4ba55492bedd7c --- /dev/null +++ b/internal/tui/components/chat/editor/clipboard_other.go @@ -0,0 +1,15 @@ +//go:build !linux || !386 + +package editor + +import "github.com/aymanbagabas/go-nativeclipboard" + +func readClipboard(f clipboardFormat) ([]byte, error) { + switch f { + case clipboardFormatText: + return nativeclipboard.Text.Read() + case clipboardFormatImage: + return nativeclipboard.Image.Read() + } + return nil, errClipboardUnknownFormat +} diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index b5cadb8cde8a1ced8543d01eb7abd28d906f1597..8f7c43c76a965539db3c3d6de4f46377c8a10a5c 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -16,7 +16,6 @@ import ( "charm.land/bubbles/v2/textarea" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - nativeclipboard "github.com/aymanbagabas/go-nativeclipboard" "github.com/charmbracelet/crush/internal/app" "github.com/charmbracelet/crush/internal/filetracker" "github.com/charmbracelet/crush/internal/fsext" @@ -35,6 +34,11 @@ import ( "github.com/charmbracelet/x/editor" ) +var ( + errClipboardPlatformUnsupported = fmt.Errorf("clipboard operations are not supported on this platform") + errClipboardUnknownFormat = fmt.Errorf("unknown clipboard format") +) + type Editor interface { util.Model layout.Sizeable @@ -341,12 +345,12 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { } // Handle image paste from clipboard if key.Matches(msg, m.keyMap.PasteImage) { - imageData, err := nativeclipboard.Image.Read() + imageData, err := readClipboard(clipboardFormatImage) if err != nil || len(imageData) == 0 { // If no image data found, try to get text data (could be file path) var textData []byte - textData, err = nativeclipboard.Text.Read() + textData, err = readClipboard(clipboardFormatText) if err != nil || len(textData) == 0 { // If clipboard is empty, show a warning return m, util.ReportWarn("No data found in clipboard. Note: Some terminals may not support reading image data from clipboard directly.") From 13c6fe671eb9dda68a807a7f4dd8e16ba61bf965 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 16 Jan 2026 16:13:17 -0500 Subject: [PATCH 54/58] fix: add freebsd support for clipboard on 386 architecture --- internal/tui/components/chat/editor/clipboard_linux_386.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/components/chat/editor/clipboard_linux_386.go b/internal/tui/components/chat/editor/clipboard_linux_386.go index 85f4111f7ea2ec50da457720c4cfba28f1e18ca7..c4e82645d1840fa3ac92f44165a70473a0037a9d 100644 --- a/internal/tui/components/chat/editor/clipboard_linux_386.go +++ b/internal/tui/components/chat/editor/clipboard_linux_386.go @@ -1,4 +1,4 @@ -//go:build linux && 386 +//go:build (linux || freebsd) && 386 package editor From 2787dc39c962ade8f7fd836b22f590666f3fc000 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 16 Jan 2026 16:36:47 -0500 Subject: [PATCH 55/58] fix: skip native clipboard support on unsupported platforms --- .../{clipboard_linux_386.go => clipboard_not_supported.go} | 2 +- .../chat/editor/{clipboard_other.go => clipboard_supported.go} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename internal/tui/components/chat/editor/{clipboard_linux_386.go => clipboard_not_supported.go} (61%) rename internal/tui/components/chat/editor/{clipboard_other.go => clipboard_supported.go} (82%) diff --git a/internal/tui/components/chat/editor/clipboard_linux_386.go b/internal/tui/components/chat/editor/clipboard_not_supported.go similarity index 61% rename from internal/tui/components/chat/editor/clipboard_linux_386.go rename to internal/tui/components/chat/editor/clipboard_not_supported.go index c4e82645d1840fa3ac92f44165a70473a0037a9d..dfecc09dca05ca5d07dd1db109fe3178f6c357b8 100644 --- a/internal/tui/components/chat/editor/clipboard_linux_386.go +++ b/internal/tui/components/chat/editor/clipboard_not_supported.go @@ -1,4 +1,4 @@ -//go:build (linux || freebsd) && 386 +//go:build !(darwin || linux || windows) || arm || 386 || ios || android package editor diff --git a/internal/tui/components/chat/editor/clipboard_other.go b/internal/tui/components/chat/editor/clipboard_supported.go similarity index 82% rename from internal/tui/components/chat/editor/clipboard_other.go rename to internal/tui/components/chat/editor/clipboard_supported.go index 6c744385ef5d9ca58a3f6a3aca4ba55492bedd7c..10b910fcccf348d9382058aa9082bfea2595a5b0 100644 --- a/internal/tui/components/chat/editor/clipboard_other.go +++ b/internal/tui/components/chat/editor/clipboard_supported.go @@ -1,4 +1,4 @@ -//go:build !linux || !386 +//go:build (linux || darwin || windows) && !386 && !arm && !android package editor From 26e98acd5700d7b5fa0c415d368e31712837eb6d Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 16 Jan 2026 16:39:47 -0500 Subject: [PATCH 56/58] chore: bump purego to v0.10.0-alpha.3.0.20260115160133-57859678ab72 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index df7a9053cf007703712b2c1acd947db843649ef0..770fdd7d04c36909edcfabff1777a13b1823c519 100644 --- a/go.mod +++ b/go.mod @@ -108,7 +108,7 @@ require ( github.com/disintegration/gift v1.1.2 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff // indirect + github.com/ebitengine/purego v0.10.0-alpha.3.0.20260115160133-57859678ab72 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect diff --git a/go.sum b/go.sum index db43880aed6a487365203f17a331d6d267aa9a6e..5e1cb24cf95c53384a2dea077c3633c267c2d11d 100644 --- a/go.sum +++ b/go.sum @@ -152,8 +152,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff h1:vAcU1VsCRstZ9ty11yD/L0WDyT73S/gVfmuWvcWX5DA= -github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.0-alpha.3.0.20260115160133-57859678ab72 h1:7LxHj6bTGLfcjjDMZyTH8ZDB8nQrcwoFNr1s4yiWtac= +github.com/ebitengine/purego v0.10.0-alpha.3.0.20260115160133-57859678ab72/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= From e55706507ff00460f09cfabb8b15278ccc2ee8d0 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 16 Jan 2026 16:46:17 -0500 Subject: [PATCH 58/58] fix: don't build native clipboard for ios --- internal/tui/components/chat/editor/clipboard_supported.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/components/chat/editor/clipboard_supported.go b/internal/tui/components/chat/editor/clipboard_supported.go index 10b910fcccf348d9382058aa9082bfea2595a5b0..175a4b4ea4dfaea03916dc1012c313201f1846f8 100644 --- a/internal/tui/components/chat/editor/clipboard_supported.go +++ b/internal/tui/components/chat/editor/clipboard_supported.go @@ -1,4 +1,4 @@ -//go:build (linux || darwin || windows) && !386 && !arm && !android +//go:build (linux || darwin || windows) && !arm && !386 && !ios && !android package editor