From 5b66c8d81e07f77ee824d54c5567ab0bb9a2834b Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Thu, 18 Dec 2025 14:31:58 -0500 Subject: [PATCH 01/29] fix: handle `ctrl+c` in hypercrush auth (#1665) --- internal/cmd/login.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/cmd/login.go b/internal/cmd/login.go index 334f32c7067d6820323363ea80a8978ae9229685..0d6c910f407e63d9a52e14878769a0381779cb46 100644 --- a/internal/cmd/login.go +++ b/internal/cmd/login.go @@ -75,8 +75,7 @@ func loginHyper() error { if !hyperp.Enabled() { return fmt.Errorf("hyper not enabled") } - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill) - defer cancel() + ctx := getLoginContext() resp, err := hyper.InitiateDeviceAuth(ctx) if err != nil { From b945baa3ddf8dd81e090514b06a35ade3d508363 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 18 Dec 2025 16:53:43 -0300 Subject: [PATCH 02/29] fix(onboarding): address `c` key press not working on onboarding (#1663) --- internal/tui/components/chat/splash/splash.go | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 6a7db9440453b8d1f7751bd5ca7eb66ecd339828..e8e372f6ea20c52731ec6539ec058770df27aab2 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -205,25 +205,23 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { } case tea.KeyPressMsg: switch { - case key.Matches(msg, s.keyMap.Copy): - if 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"), - ) - } else if s.showClaudeAuthMethodChooser { - u, cmd := s.claudeAuthMethodChooser.Update(msg) - s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser) - return s, cmd - } else if s.showClaudeOAuth2 { - u, cmd := s.claudeOAuth2.Update(msg) - s.claudeOAuth2 = u.(*claude.OAuth2) - return s, cmd - } + 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): if s.showClaudeAuthMethodChooser { s.claudeAuthMethodChooser.SetDefaults() From 13f315cb30efb673178ec4059547a2306a0d7c50 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 18 Dec 2025 14:42:17 -0300 Subject: [PATCH 03/29] feat: make hyper and copilot login work on onboarding --- internal/tui/components/chat/splash/splash.go | 222 ++++++++++++++---- .../tui/components/dialogs/models/models.go | 42 ++-- internal/tui/page/chat/chat.go | 29 ++- 3 files changed, 221 insertions(+), 72 deletions(-) diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index e8e372f6ea20c52731ec6539ec058770df27aab2..32f30b5df7a32ff27dc011331ec4857ce36cddc8 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -12,12 +12,15 @@ import ( "github.com/atotto/clipboard" "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/agent" + hyperp "github.com/charmbracelet/crush/internal/agent/hyper" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/home" "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" "github.com/charmbracelet/crush/internal/tui/components/logo" lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp" @@ -55,6 +58,12 @@ type Splash interface { // IsClaudeOAuthComplete returns whether Claude OAuth flow is complete IsClaudeOAuthComplete() bool + + // IsShowingClaudeOAuth2 returns whether showing Hyper OAuth2 flow + IsShowingHyperOAuth2() bool + + // IsShowingClaudeOAuth2 returns whether showing GitHub Copilot OAuth2 flow + IsShowingCopilotOAuth2() bool } const ( @@ -87,6 +96,14 @@ type splashCmp struct { isAPIKeyValid bool apiKeyValue string + // Hyper device flow state + hyperDeviceFlow *hyper.DeviceFlow + showHyperDeviceFlow bool + + // Copilot device flow state + copilotDeviceFlow *copilot.DeviceFlow + showCopilotDeviceFlow bool + // Claude state claudeAuthMethodChooser *claude.AuthMethodChooser claudeOAuth2 *claude.OAuth2 @@ -186,6 +203,26 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { } return s, tea.Batch(cmds...) + case hyper.DeviceFlowCompletedMsg: + s.showHyperDeviceFlow = false + return s, s.saveAPIKeyAndContinue(msg.Token, true) + case hyper.DeviceAuthInitiatedMsg, hyper.DeviceFlowErrorMsg: + if s.hyperDeviceFlow != nil { + u, cmd := s.hyperDeviceFlow.Update(msg) + s.hyperDeviceFlow = u.(*hyper.DeviceFlow) + return s, cmd + } + return s, nil + case copilot.DeviceAuthInitiatedMsg, copilot.DeviceFlowErrorMsg: + if s.copilotDeviceFlow != nil { + u, cmd := s.copilotDeviceFlow.Update(msg) + s.copilotDeviceFlow = u.(*copilot.DeviceFlow) + return s, cmd + } + return s, nil + case copilot.DeviceFlowCompletedMsg: + s.showCopilotDeviceFlow = false + return s, s.saveAPIKeyAndContinue(msg.Token, true) case claude.AuthenticationCompleteMsg: s.showClaudeAuthMethodChooser = false s.showClaudeOAuth2 = false @@ -205,6 +242,10 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { } case tea.KeyPressMsg: switch { + case key.Matches(msg, s.keyMap.Copy) && s.showHyperDeviceFlow: + 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), @@ -223,21 +264,27 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { s.claudeOAuth2 = u.(*claude.OAuth2) return s, cmd case key.Matches(msg, s.keyMap.Back): - if s.showClaudeAuthMethodChooser { + switch { + case s.showClaudeAuthMethodChooser: s.claudeAuthMethodChooser.SetDefaults() s.showClaudeAuthMethodChooser = false return s, nil - } - if s.showClaudeOAuth2 { + case s.showClaudeOAuth2: s.claudeOAuth2.SetDefaults() s.showClaudeOAuth2 = false s.showClaudeAuthMethodChooser = true return s, nil - } - if s.isAPIKeyValid { + case s.showHyperDeviceFlow: + s.hyperDeviceFlow = nil + s.showHyperDeviceFlow = false return s, nil - } - if s.needsAPIKey { + case s.showCopilotDeviceFlow: + s.copilotDeviceFlow = nil + s.showCopilotDeviceFlow = false + return s, nil + case s.isAPIKeyValid: + return s, nil + case s.needsAPIKey: if s.selectedModel.Provider.ID == catwalk.InferenceProviderAnthropic { s.showClaudeAuthMethodChooser = true } @@ -249,7 +296,8 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { return s, nil } case key.Matches(msg, s.keyMap.Select): - if s.showClaudeAuthMethodChooser { + switch { + case s.showClaudeAuthMethodChooser: selectedItem := s.modelList.SelectedModel() if selectedItem == nil { return s, nil @@ -267,16 +315,17 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { s.showClaudeOAuth2 = true } return s, nil - } - if s.showClaudeOAuth2 { + case s.showClaudeOAuth2: m2, cmd2 := s.claudeOAuth2.ValidationConfirm() s.claudeOAuth2 = m2.(*claude.OAuth2) return s, cmd2 - } - if s.isAPIKeyValid { + case s.showHyperDeviceFlow: + return s, s.hyperDeviceFlow.CopyCodeAndOpenURL() + case s.showCopilotDeviceFlow: + return s, s.copilotDeviceFlow.CopyCodeAndOpenURL() + case s.isAPIKeyValid: return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true) - } - if s.isOnboarding && !s.needsAPIKey { + case s.isOnboarding && !s.needsAPIKey: selectedItem := s.modelList.SelectedModel() if selectedItem == nil { return s, nil @@ -286,9 +335,22 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { s.isOnboarding = false return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{})) } else { - if selectedItem.Provider.ID == catwalk.InferenceProviderAnthropic { + switch selectedItem.Provider.ID { + case catwalk.InferenceProviderAnthropic: s.showClaudeAuthMethodChooser = true return s, nil + case hyperp.Name: + s.selectedModel = selectedItem + s.showHyperDeviceFlow = true + s.hyperDeviceFlow = hyper.NewDeviceFlow() + s.hyperDeviceFlow.SetWidth(min(s.width-2, 60)) + return s, s.hyperDeviceFlow.Init() + case catwalk.InferenceProviderCopilot: + s.selectedModel = selectedItem + s.showCopilotDeviceFlow = true + s.copilotDeviceFlow = copilot.NewDeviceFlow() + s.copilotDeviceFlow.SetWidth(min(s.width-2, 60)) + return s, s.copilotDeviceFlow.Init() } // Provider not configured, show API key input s.needsAPIKey = true @@ -296,7 +358,7 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { s.apiKeyInput.SetProviderName(selectedItem.Provider.Name) return s, nil } - } else if s.needsAPIKey { + case s.needsAPIKey: // Handle API key submission s.apiKeyValue = strings.TrimSpace(s.apiKeyInput.Value()) if s.apiKeyValue == "" { @@ -337,7 +399,7 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { } }, ) - } else if s.needsProjectInit { + case s.needsProjectInit: return s, s.initializeProject() } case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight): @@ -385,44 +447,71 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { return s, s.initializeProject() } default: - if s.showClaudeAuthMethodChooser { + switch { + case s.showClaudeAuthMethodChooser: u, cmd := s.claudeAuthMethodChooser.Update(msg) s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser) return s, cmd - } else if s.showClaudeOAuth2 { + case s.showClaudeOAuth2: u, cmd := s.claudeOAuth2.Update(msg) s.claudeOAuth2 = u.(*claude.OAuth2) return s, cmd - } else if s.needsAPIKey { + case s.showHyperDeviceFlow: + u, cmd := s.hyperDeviceFlow.Update(msg) + s.hyperDeviceFlow = u.(*hyper.DeviceFlow) + return s, cmd + case s.showCopilotDeviceFlow: + u, cmd := s.copilotDeviceFlow.Update(msg) + s.copilotDeviceFlow = u.(*copilot.DeviceFlow) + return s, cmd + case s.needsAPIKey: u, cmd := s.apiKeyInput.Update(msg) s.apiKeyInput = u.(*models.APIKeyInput) return s, cmd - } else if s.isOnboarding { + case s.isOnboarding: u, cmd := s.modelList.Update(msg) s.modelList = u return s, cmd } } case tea.PasteMsg: - if s.showClaudeOAuth2 { + switch { + case s.showClaudeOAuth2: u, cmd := s.claudeOAuth2.Update(msg) s.claudeOAuth2 = u.(*claude.OAuth2) return s, cmd - } else if s.needsAPIKey { + case s.showHyperDeviceFlow: + u, cmd := s.hyperDeviceFlow.Update(msg) + s.hyperDeviceFlow = u.(*hyper.DeviceFlow) + return s, cmd + case s.showCopilotDeviceFlow: + u, cmd := s.copilotDeviceFlow.Update(msg) + s.copilotDeviceFlow = u.(*copilot.DeviceFlow) + return s, cmd + case s.needsAPIKey: u, cmd := s.apiKeyInput.Update(msg) s.apiKeyInput = u.(*models.APIKeyInput) return s, cmd - } else if s.isOnboarding { + case s.isOnboarding: var cmd tea.Cmd s.modelList, cmd = s.modelList.Update(msg) return s, cmd } case spinner.TickMsg: - if s.showClaudeOAuth2 { + switch { + case s.showClaudeOAuth2: u, cmd := s.claudeOAuth2.Update(msg) s.claudeOAuth2 = u.(*claude.OAuth2) return s, cmd - } else { + case s.showHyperDeviceFlow: + u, cmd := s.hyperDeviceFlow.Update(msg) + s.hyperDeviceFlow = u.(*hyper.DeviceFlow) + return s, cmd + case s.showCopilotDeviceFlow: + u, cmd := s.copilotDeviceFlow.Update(msg) + s.copilotDeviceFlow = u.(*copilot.DeviceFlow) + return s, cmd + default: u, cmd := s.apiKeyInput.Update(msg) s.apiKeyInput = u.(*models.APIKeyInput) return s, cmd @@ -560,7 +649,9 @@ func (s *splashCmp) isProviderConfigured(providerID string) bool { func (s *splashCmp) View() string { t := styles.CurrentTheme() var content string - if s.showClaudeAuthMethodChooser { + + switch { + case s.showClaudeAuthMethodChooser: remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) chooserView := s.claudeAuthMethodChooser.View() authMethodSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( @@ -576,7 +667,7 @@ func (s *splashCmp) View() string { s.logoRendered, authMethodSelector, ) - } else if s.showClaudeOAuth2 { + case s.showClaudeOAuth2: remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) oauth2View := s.claudeOAuth2.View() oauthSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( @@ -592,7 +683,39 @@ func (s *splashCmp) View() string { s.logoRendered, oauthSelector, ) - } else if s.needsAPIKey { + case s.showHyperDeviceFlow: + remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) + hyperView := s.hyperDeviceFlow.View() + hyperSelector := 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 Hyper"), + "", + hyperView, + ), + ) + content = lipgloss.JoinVertical( + lipgloss.Left, + s.logoRendered, + hyperSelector, + ) + case s.showCopilotDeviceFlow: + remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) + copilotView := s.copilotDeviceFlow.View() + copilotSelector := 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 GitHub Copilot"), + "", + copilotView, + ), + ) + content = lipgloss.JoinVertical( + lipgloss.Left, + s.logoRendered, + copilotSelector, + ) + case s.needsAPIKey: remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) apiKeyView := t.S().Base.PaddingLeft(1).Render(s.apiKeyInput.View()) apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( @@ -606,7 +729,7 @@ func (s *splashCmp) View() string { s.logoRendered, apiKeySelector, ) - } else if s.isOnboarding { + case s.isOnboarding: modelListView := s.modelList.View() remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) modelSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( @@ -622,7 +745,7 @@ func (s *splashCmp) View() string { s.logoRendered, modelSelector, ) - } else if s.needsProjectInit { + case s.needsProjectInit: titleStyle := t.S().Base.Foreground(t.FgBase) pathStyle := t.S().Base.Foreground(t.Success).PaddingLeft(2) bodyStyle := t.S().Base.Foreground(t.FgMuted) @@ -673,7 +796,7 @@ func (s *splashCmp) View() string { "", initContent, ) - } else { + default: parts := []string{ s.logoRendered, s.infoSection(), @@ -690,28 +813,25 @@ func (s *splashCmp) View() string { } func (s *splashCmp) Cursor() *tea.Cursor { - if s.showClaudeAuthMethodChooser { + switch { + case s.showClaudeAuthMethodChooser: return nil - } - if s.showClaudeOAuth2 { + 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 - } - if s.needsAPIKey { + case s.needsAPIKey: cursor := s.apiKeyInput.Cursor() if cursor != nil { return s.moveCursor(cursor) } - } else if s.isOnboarding { + case s.isOnboarding: cursor := s.modelList.Cursor() if cursor != nil { return s.moveCursor(cursor) } - } else { - return nil } return nil } @@ -803,13 +923,14 @@ func (s *splashCmp) logoGap() int { // Bindings implements SplashPage. func (s *splashCmp) Bindings() []key.Binding { - if s.showClaudeAuthMethodChooser { + switch { + case s.showClaudeAuthMethodChooser: return []key.Binding{ s.keyMap.Select, s.keyMap.Tab, s.keyMap.Back, } - } else if s.showClaudeOAuth2 { + case s.showClaudeOAuth2: bindings := []key.Binding{ s.keyMap.Select, } @@ -817,18 +938,18 @@ func (s *splashCmp) Bindings() []key.Binding { bindings = append(bindings, s.keyMap.Copy) } return bindings - } else if s.needsAPIKey { + case s.needsAPIKey: return []key.Binding{ s.keyMap.Select, s.keyMap.Back, } - } else if s.isOnboarding { + case s.isOnboarding: return []key.Binding{ s.keyMap.Select, s.keyMap.Next, s.keyMap.Previous, } - } else if s.needsProjectInit { + case s.needsProjectInit: return []key.Binding{ s.keyMap.Select, s.keyMap.Yes, @@ -836,8 +957,9 @@ func (s *splashCmp) Bindings() []key.Binding { s.keyMap.Tab, s.keyMap.LeftRight, } + default: + return []key.Binding{} } - return []key.Binding{} } func (s *splashCmp) getMaxInfoWidth() int { @@ -938,3 +1060,11 @@ func (s *splashCmp) IsClaudeOAuthURLState() bool { 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 +} + +func (s *splashCmp) IsShowingCopilotOAuth2() bool { + return s.showCopilotDeviceFlow +} diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go index 8ed2ffbf0bf0ddd4641fbbda6f0e2b20a1967e07..ff7bd4f0409693454222b45567c23a151f8de077 100644 --- a/internal/tui/components/dialogs/models/models.go +++ b/internal/tui/components/dialogs/models/models.go @@ -174,13 +174,10 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { case tea.KeyPressMsg: switch { // Handle Hyper device flow keys - case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && (m.showHyperDeviceFlow || m.showCopilotDeviceFlow): - if m.hyperDeviceFlow != nil { - return m, m.hyperDeviceFlow.CopyCode() - } - if m.copilotDeviceFlow != nil { - return m, m.copilotDeviceFlow.CopyCode() - } + case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showHyperDeviceFlow: + 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), @@ -337,28 +334,26 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { return m, m.modelList.SetModelType(LargeModelType) } case key.Matches(msg, m.keyMap.Close): - if m.showHyperDeviceFlow { + switch { + case m.showHyperDeviceFlow: if m.hyperDeviceFlow != nil { m.hyperDeviceFlow.Cancel() } m.showHyperDeviceFlow = false m.selectedModel = nil - } - if m.showCopilotDeviceFlow { + case m.showCopilotDeviceFlow: if m.copilotDeviceFlow != nil { m.copilotDeviceFlow.Cancel() } m.showCopilotDeviceFlow = false m.selectedModel = nil - } - if m.showClaudeAuthMethodChooser { + case m.showClaudeAuthMethodChooser: m.claudeAuthMethodChooser.SetDefaults() m.showClaudeAuthMethodChooser = false m.keyMap.isClaudeAuthChoiceHelp = false m.keyMap.isClaudeOAuthHelp = false return m, nil - } - if m.needsAPIKey { + case m.needsAPIKey: if m.isAPIKeyValid { return m, nil } @@ -369,37 +364,40 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { m.apiKeyValue = "" m.apiKeyInput.Reset() return m, nil + default: + return m, util.CmdHandler(dialogs.CloseDialogMsg{}) } - return m, util.CmdHandler(dialogs.CloseDialogMsg{}) default: - if m.showClaudeAuthMethodChooser { + switch { + case m.showClaudeAuthMethodChooser: u, cmd := m.claudeAuthMethodChooser.Update(msg) m.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser) return m, cmd - } else if m.showClaudeOAuth2 { + case m.showClaudeOAuth2: u, cmd := m.claudeOAuth2.Update(msg) m.claudeOAuth2 = u.(*claude.OAuth2) return m, cmd - } else if m.needsAPIKey { + case m.needsAPIKey: u, cmd := m.apiKeyInput.Update(msg) m.apiKeyInput = u.(*APIKeyInput) return m, cmd - } else { + default: u, cmd := m.modelList.Update(msg) m.modelList = u return m, cmd } } case tea.PasteMsg: - if m.showClaudeOAuth2 { + switch { + case m.showClaudeOAuth2: u, cmd := m.claudeOAuth2.Update(msg) m.claudeOAuth2 = u.(*claude.OAuth2) return m, cmd - } else if m.needsAPIKey { + case m.needsAPIKey: u, cmd := m.apiKeyInput.Update(msg) m.apiKeyInput = u.(*APIKeyInput) return m, cmd - } else { + default: var cmd tea.Cmd m.modelList, cmd = m.modelList.Update(msg) return m, cmd diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 8ba323e1b2320445cdc2d5a60f5f270a2ee5ce96..33a71f9e3b9d184c3fb18423a91dd0baf8c2a0b9 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -31,7 +31,9 @@ import ( "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" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper" "github.com/charmbracelet/crush/internal/tui/components/dialogs/models" "github.com/charmbracelet/crush/internal/tui/components/dialogs/reasoning" "github.com/charmbracelet/crush/internal/tui/page" @@ -335,7 +337,14 @@ 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: + case claude.ValidationCompletedMsg, + claude.AuthenticationCompleteMsg, + hyper.DeviceFlowCompletedMsg, + hyper.DeviceAuthInitiatedMsg, + hyper.DeviceFlowErrorMsg, + copilot.DeviceAuthInitiatedMsg, + copilot.DeviceFlowErrorMsg, + copilot.DeviceFlowCompletedMsg: if p.focusedPane == PanelTypeSplash { u, cmd := p.splash.Update(msg) p.splash = u.(splash.Splash) @@ -1050,7 +1059,8 @@ func (p *chatPage) Help() help.KeyMap { fullList = append(fullList, []key.Binding{v}) } case p.isOnboarding && p.splash.IsShowingClaudeOAuth2(): - if p.splash.IsClaudeOAuthURLState() { + switch { + case p.splash.IsClaudeOAuthURLState(): shortList = append(shortList, key.NewBinding( key.WithKeys("enter"), @@ -1061,14 +1071,25 @@ func (p *chatPage) Help() help.KeyMap { key.WithHelp("c", "copy url"), ), ) - } else if p.splash.IsClaudeOAuthComplete() { + case p.splash.IsClaudeOAuthComplete(): shortList = append(shortList, key.NewBinding( key.WithKeys("enter"), key.WithHelp("enter", "continue"), ), ) - } else { + case p.splash.IsShowingHyperOAuth2() || p.splash.IsShowingCopilotOAuth2(): + shortList = append(shortList, + key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "copy url & open signup"), + ), + key.NewBinding( + key.WithKeys("c"), + key.WithHelp("c", "copy url"), + ), + ) + default: shortList = append(shortList, key.NewBinding( key.WithKeys("enter"), From 68ad2981a0017a510f1df6ba89c7492ecb1b4338 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 18 Dec 2025 16:19:20 -0300 Subject: [PATCH 04/29] fix(copilot): change import to happen on demand, and also on onboarding --- internal/config/config.go | 5 ++++- internal/config/copilot.go | 7 ++++++- internal/config/load.go | 7 ------- internal/tui/components/chat/splash/splash.go | 4 ++++ internal/tui/components/dialogs/models/models.go | 5 +++++ 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index a91350b5bc894161bc5fdbd44720c27b46fc1063..e5878a5d0c999c612e3b5e2c5dd61ec4c4dc324d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,6 +9,7 @@ import ( "net/http" "net/url" "os" + "path/filepath" "slices" "strings" "time" @@ -498,7 +499,6 @@ func (c *Config) HasConfigField(key string) bool { } func (c *Config) SetConfigField(key string, value any) error { - // read the data data, err := os.ReadFile(c.dataConfigDir) if err != nil { if os.IsNotExist(err) { @@ -512,6 +512,9 @@ func (c *Config) SetConfigField(key string, value any) error { if err != nil { return fmt.Errorf("failed to set 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) } diff --git a/internal/config/copilot.go b/internal/config/copilot.go index f9ebc2f4fbddf602c67ae6fc81f5e6ca02d57b27..ee50bec43d6ce5754799adf4bfe99ba9b357d690 100644 --- a/internal/config/copilot.go +++ b/internal/config/copilot.go @@ -6,11 +6,12 @@ import ( "log/slog" "testing" + "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/oauth" "github.com/charmbracelet/crush/internal/oauth/copilot" ) -func (c *Config) importCopilot() (*oauth.Token, bool) { +func (c *Config) ImportCopilot() (*oauth.Token, bool) { if testing.Testing() { return nil, false } @@ -31,6 +32,10 @@ func (c *Config) importCopilot() (*oauth.Token, bool) { return nil, false } + if err := c.SetProviderAPIKey(string(catwalk.InferenceProviderCopilot), token); err != nil { + return token, false + } + if err := cmp.Or( c.SetConfigField("providers.copilot.api_key", token.AccessToken), c.SetConfigField("providers.copilot.oauth", token), diff --git a/internal/config/load.go b/internal/config/load.go index 0d16702dcdd35eb7d431ddfe4a0b35ab48e4debc..27866e5891afc8774cb5d5ac2c8fd4f979161e2f 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -133,8 +133,6 @@ func PushPopCrushEnv() func() { } func (c *Config) configureProviders(env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error { - c.importCopilot() - knownProviderNames := make(map[string]bool) restore := PushPopCrushEnv() defer restore() @@ -206,11 +204,6 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil: prepared.SetupClaudeCode() case p.ID == catwalk.InferenceProviderCopilot: - if config.OAuthToken != nil { - if token, ok := c.importCopilot(); ok { - prepared.OAuthToken = token - } - } if config.OAuthToken != nil { prepared.SetupGitHubCopilot() } diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 32f30b5df7a32ff27dc011331ec4857ce36cddc8..ba512b5b3911f966b8760e6e3ccbce1ec20e92ca 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -346,6 +346,10 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { s.hyperDeviceFlow.SetWidth(min(s.width-2, 60)) return s, s.hyperDeviceFlow.Init() case catwalk.InferenceProviderCopilot: + if token, ok := config.Get().ImportCopilot(); ok { + s.selectedModel = selectedItem + return s, s.saveAPIKeyAndContinue(token, true) + } s.selectedModel = selectedItem s.showCopilotDeviceFlow = true s.copilotDeviceFlow = copilot.NewDeviceFlow() diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go index ff7bd4f0409693454222b45567c23a151f8de077..06da780edd48cf689113575d39e2ed5805fa27e2 100644 --- a/internal/tui/components/dialogs/models/models.go +++ b/internal/tui/components/dialogs/models/models.go @@ -307,6 +307,11 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { m.hyperDeviceFlow.SetWidth(m.width - 2) return m, m.hyperDeviceFlow.Init() case catwalk.InferenceProviderCopilot: + if token, ok := config.Get().ImportCopilot(); ok { + m.selectedModel = selectedItem + m.selectedModelType = modelType + return m, m.saveOauthTokenAndContinue(token, true) + } m.showCopilotDeviceFlow = true m.selectedModel = selectedItem m.selectedModelType = modelType From 2ba5a82e45d4adc69040512127ebb14b52d9a41b Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 18 Dec 2025 17:47:28 -0300 Subject: [PATCH 05/29] chore(ux): remove extra uneeded line --- internal/tui/components/chat/splash/splash.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index ba512b5b3911f966b8760e6e3ccbce1ec20e92ca..06ec0bfb71b9dc6723a3eba60f3e67eac3271ca6 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -694,7 +694,6 @@ func (s *splashCmp) View() string { lipgloss.JoinVertical( lipgloss.Left, t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Hyper"), - "", hyperView, ), ) @@ -710,7 +709,6 @@ func (s *splashCmp) View() string { lipgloss.JoinVertical( lipgloss.Left, t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth GitHub Copilot"), - "", copilotView, ), ) From 549c9e2adfd4fb40af346fa835debbb563e6190b Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 18 Dec 2025 18:09:25 -0300 Subject: [PATCH 06/29] chore(deps): update fantasy and catwalk (#1667) --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 8cdfbfd733c017827c884699b7d1a639fac8325b..48f8ef5e14732bbd9f57bedbf7d028d844345790 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 charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e - charm.land/fantasy v0.5.3 + charm.land/fantasy v0.5.4 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 @@ -18,7 +18,7 @@ require ( github.com/aymanbagabas/go-udiff v0.3.1 github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/charlievieth/fastwalk v1.0.14 - github.com/charmbracelet/catwalk v0.11.0 + github.com/charmbracelet/catwalk v0.11.2 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 d107ae21a26cdfe2061d697813ad7a018f86177b..970cc24f29889e74a7c4adbac9be1682e99f3470 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ 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/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.5.3 h1:+6meCTaH9lrqrcVTEBgsaSkkY0ctC/6dtIufKZcMdMI= -charm.land/fantasy v0.5.3/go.mod h1:WnH5fJJRMGylx1fL1ow9Kfq0+sPMr5fenpHYAnoTlTg= +charm.land/fantasy v0.5.4 h1:tl8qq/prtFbtwMGDBTLg0QibPdVV02Esp9+a2Zov8Io= +charm.land/fantasy v0.5.4/go.mod h1:WnH5fJJRMGylx1fL1ow9Kfq0+sPMr5fenpHYAnoTlTg= 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= @@ -92,8 +92,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.11.0 h1:PU3rkc4h4YVJEn9Iyb/1rQAaF4hEd04fuG4tj3vv4dg= -github.com/charmbracelet/catwalk v0.11.0/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ= +github.com/charmbracelet/catwalk v0.11.2 h1:m+eE7yv/uIrKW95FpFeGDMFrAugotylX89XzpkZwlLk= +github.com/charmbracelet/catwalk v0.11.2/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 07c0b282fe4e7e850d9be4b615a492aeac38f46b Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Thu, 18 Dec 2025 22:11:55 -0300 Subject: [PATCH 08/29] chore(legal): @flatsponge 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 2fbf3ccd2d8af44fa7951c3c6547cae7e66f640a..8cb56a4ae4189e7eac5975c801c728cbce0013ff 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -959,6 +959,14 @@ "created_at": "2025-12-14T09:41:12Z", "repoId": 987670088, "pullRequestNo": 1628 + }, + { + "name": "flatsponge", + "id": 104839509, + "comment_id": 3673002560, + "created_at": "2025-12-19T01:11:45Z", + "repoId": 987670088, + "pullRequestNo": 1668 } ] } \ No newline at end of file From 0f76e63276c5d17bcf12bdf973f91a7feca7df19 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 19 Dec 2025 10:21:03 +0100 Subject: [PATCH 09/29] fix: remove unsupported image types --- internal/agent/tools/view.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/internal/agent/tools/view.go b/internal/agent/tools/view.go index 3f9db0496f7bfbbb8b6e0f28f98b8435c952b289..aacd4cab23231c1b27f3d2589578e81e29cf6ed3 100644 --- a/internal/agent/tools/view.go +++ b/internal/agent/tools/view.go @@ -148,8 +148,8 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss params.Limit = DefaultReadLimit } - isImage, mimeType := getImageMimeType(filePath) - if isImage { + isSupportedImage, mimeType := getImageMimeType(filePath) + if isSupportedImage { if !GetSupportsImagesFromContext(ctx) { modelName := GetModelNameFromContext(ctx) return fantasy.NewTextErrorResponse(fmt.Sprintf("This model (%s) does not support image data.", modelName)), nil @@ -282,10 +282,6 @@ func getImageMimeType(filePath string) (bool, string) { return true, "image/png" case ".gif": return true, "image/gif" - case ".bmp": - return true, "image/bmp" - case ".svg": - return true, "image/svg+xml" case ".webp": return true, "image/webp" default: From 88b19ad936400ae7b1364dca7902f124aca85196 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Fri, 19 Dec 2025 09:14:17 -0300 Subject: [PATCH 10/29] chore(legal): @jonhoo 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 8cb56a4ae4189e7eac5975c801c728cbce0013ff..d85df4b8fa7bebfce410b0f159d1073705ce2562 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -967,6 +967,14 @@ "created_at": "2025-12-19T01:11:45Z", "repoId": 987670088, "pullRequestNo": 1668 + }, + { + "name": "jonhoo", + "id": 176295, + "comment_id": 3674853134, + "created_at": "2025-12-19T12:14:08Z", + "repoId": 987670088, + "pullRequestNo": 1675 } ] } \ No newline at end of file From 4f6d0e9373ffb015cba1715a4985298e037d6d14 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 19 Dec 2025 13:19:36 +0100 Subject: [PATCH 11/29] fix: race condition (#1649) --- internal/agent/agent.go | 33 +++++++++--- internal/db/db.go | 98 ++++++++++++++++++++---------------- internal/db/querier.go | 1 + internal/db/sessions.sql.go | 29 +++++++++++ internal/db/sql/sessions.sql | 9 ++++ internal/message/content.go | 9 ++++ internal/message/message.go | 12 +++-- internal/pubsub/broker.go | 11 ++-- internal/session/session.go | 13 +++++ 9 files changed, 152 insertions(+), 63 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 62025b1943af245e94da6da744036e8040029c65..30539e5dba5a5b7aa66c2d2ffc35336dbe3d9774 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -171,10 +171,9 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy var wg sync.WaitGroup // Generate title if first message. if len(msgs) == 0 { + titleCtx := ctx // Copy to avoid race with ctx reassignment below. wg.Go(func() { - sessionLock.Lock() - a.generateTitle(ctx, ¤tSession, call.Prompt) - sessionLock.Unlock() + a.generateTitle(titleCtx, call.SessionID, call.Prompt) }) } @@ -723,7 +722,7 @@ func (a *sessionAgent) getSessionMessages(ctx context.Context, session session.S return msgs, nil } -func (a *sessionAgent) generateTitle(ctx context.Context, session *session.Session, prompt string) { +func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, prompt string) { if prompt == "" { return } @@ -768,8 +767,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, session *session.Sessi return } - session.Title = title - + // Calculate usage and cost. var openrouterCost *float64 for _, step := range resp.Steps { stepCost := a.openrouterCost(step.ProviderMetadata) @@ -782,8 +780,27 @@ func (a *sessionAgent) generateTitle(ctx context.Context, session *session.Sessi } } - a.updateSessionUsage(a.smallModel, session, resp.TotalUsage, openrouterCost) - _, saveErr := a.sessions.Save(ctx, *session) + modelConfig := a.smallModel.CatwalkCfg + cost := modelConfig.CostPer1MInCached/1e6*float64(resp.TotalUsage.CacheCreationTokens) + + modelConfig.CostPer1MOutCached/1e6*float64(resp.TotalUsage.CacheReadTokens) + + 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 + } + + promptTokens := resp.TotalUsage.InputTokens + resp.TotalUsage.CacheCreationTokens + completionTokens := resp.TotalUsage.OutputTokens + resp.TotalUsage.CacheReadTokens + + // Atomically update only title and usage fields to avoid overriding other + // concurrent session updates. + saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, title, promptTokens, completionTokens, cost) if saveErr != nil { slog.Error("failed to save session title & usage", "error", saveErr) return diff --git a/internal/db/db.go b/internal/db/db.go index 6f57f2c2c6c7c2854e93fa6246cad6dbfcfa569c..7fa2e6528743dcb5485c0de9b4a3f2b46eb39376 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -84,6 +84,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.updateSessionStmt, err = db.PrepareContext(ctx, updateSession); err != nil { return nil, fmt.Errorf("error preparing query UpdateSession: %w", err) } + if q.updateSessionTitleAndUsageStmt, err = db.PrepareContext(ctx, updateSessionTitleAndUsage); err != nil { + return nil, fmt.Errorf("error preparing query UpdateSessionTitleAndUsage: %w", err) + } return &q, nil } @@ -189,6 +192,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing updateSessionStmt: %w", cerr) } } + if q.updateSessionTitleAndUsageStmt != nil { + if cerr := q.updateSessionTitleAndUsageStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateSessionTitleAndUsageStmt: %w", cerr) + } + } return err } @@ -226,53 +234,55 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar } type Queries struct { - db DBTX - tx *sql.Tx - createFileStmt *sql.Stmt - createMessageStmt *sql.Stmt - createSessionStmt *sql.Stmt - deleteFileStmt *sql.Stmt - deleteMessageStmt *sql.Stmt - deleteSessionStmt *sql.Stmt - deleteSessionFilesStmt *sql.Stmt - deleteSessionMessagesStmt *sql.Stmt - getFileStmt *sql.Stmt - getFileByPathAndSessionStmt *sql.Stmt - getMessageStmt *sql.Stmt - getSessionByIDStmt *sql.Stmt - listFilesByPathStmt *sql.Stmt - listFilesBySessionStmt *sql.Stmt - listLatestSessionFilesStmt *sql.Stmt - listMessagesBySessionStmt *sql.Stmt - listNewFilesStmt *sql.Stmt - listSessionsStmt *sql.Stmt - updateMessageStmt *sql.Stmt - updateSessionStmt *sql.Stmt + db DBTX + tx *sql.Tx + createFileStmt *sql.Stmt + createMessageStmt *sql.Stmt + createSessionStmt *sql.Stmt + deleteFileStmt *sql.Stmt + deleteMessageStmt *sql.Stmt + deleteSessionStmt *sql.Stmt + deleteSessionFilesStmt *sql.Stmt + deleteSessionMessagesStmt *sql.Stmt + getFileStmt *sql.Stmt + getFileByPathAndSessionStmt *sql.Stmt + getMessageStmt *sql.Stmt + getSessionByIDStmt *sql.Stmt + listFilesByPathStmt *sql.Stmt + listFilesBySessionStmt *sql.Stmt + listLatestSessionFilesStmt *sql.Stmt + listMessagesBySessionStmt *sql.Stmt + listNewFilesStmt *sql.Stmt + listSessionsStmt *sql.Stmt + updateMessageStmt *sql.Stmt + updateSessionStmt *sql.Stmt + updateSessionTitleAndUsageStmt *sql.Stmt } func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{ - db: tx, - tx: tx, - createFileStmt: q.createFileStmt, - createMessageStmt: q.createMessageStmt, - createSessionStmt: q.createSessionStmt, - deleteFileStmt: q.deleteFileStmt, - deleteMessageStmt: q.deleteMessageStmt, - deleteSessionStmt: q.deleteSessionStmt, - deleteSessionFilesStmt: q.deleteSessionFilesStmt, - deleteSessionMessagesStmt: q.deleteSessionMessagesStmt, - getFileStmt: q.getFileStmt, - getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt, - getMessageStmt: q.getMessageStmt, - getSessionByIDStmt: q.getSessionByIDStmt, - listFilesByPathStmt: q.listFilesByPathStmt, - listFilesBySessionStmt: q.listFilesBySessionStmt, - listLatestSessionFilesStmt: q.listLatestSessionFilesStmt, - listMessagesBySessionStmt: q.listMessagesBySessionStmt, - listNewFilesStmt: q.listNewFilesStmt, - listSessionsStmt: q.listSessionsStmt, - updateMessageStmt: q.updateMessageStmt, - updateSessionStmt: q.updateSessionStmt, + db: tx, + tx: tx, + createFileStmt: q.createFileStmt, + createMessageStmt: q.createMessageStmt, + createSessionStmt: q.createSessionStmt, + deleteFileStmt: q.deleteFileStmt, + deleteMessageStmt: q.deleteMessageStmt, + deleteSessionStmt: q.deleteSessionStmt, + deleteSessionFilesStmt: q.deleteSessionFilesStmt, + deleteSessionMessagesStmt: q.deleteSessionMessagesStmt, + getFileStmt: q.getFileStmt, + getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt, + getMessageStmt: q.getMessageStmt, + getSessionByIDStmt: q.getSessionByIDStmt, + listFilesByPathStmt: q.listFilesByPathStmt, + listFilesBySessionStmt: q.listFilesBySessionStmt, + listLatestSessionFilesStmt: q.listLatestSessionFilesStmt, + listMessagesBySessionStmt: q.listMessagesBySessionStmt, + listNewFilesStmt: q.listNewFilesStmt, + listSessionsStmt: q.listSessionsStmt, + updateMessageStmt: q.updateMessageStmt, + updateSessionStmt: q.updateSessionStmt, + updateSessionTitleAndUsageStmt: q.updateSessionTitleAndUsageStmt, } } diff --git a/internal/db/querier.go b/internal/db/querier.go index 0978eb2c6e4c7b1aa80888530bb5169a1d2bcec3..dfa6d722535b4265f3f54331d1904523a648f562 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -29,6 +29,7 @@ type Querier interface { ListSessions(ctx context.Context) ([]Session, error) UpdateMessage(ctx context.Context, arg UpdateMessageParams) error UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error) + UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error } var _ Querier = (*Queries)(nil) diff --git a/internal/db/sessions.sql.go b/internal/db/sessions.sql.go index 012e70b40e825fce3b5941420f992715c2bfd6c7..3b1ecbfecb3c5d947e84b1ec07f7a3f72b8d6139 100644 --- a/internal/db/sessions.sql.go +++ b/internal/db/sessions.sql.go @@ -199,3 +199,32 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (S ) return i, err } + +const updateSessionTitleAndUsage = `-- name: UpdateSessionTitleAndUsage :exec +UPDATE sessions +SET + title = ?, + prompt_tokens = prompt_tokens + ?, + completion_tokens = completion_tokens + ?, + cost = cost + ? +WHERE id = ? +` + +type UpdateSessionTitleAndUsageParams struct { + Title string `json:"title"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + Cost float64 `json:"cost"` + ID string `json:"id"` +} + +func (q *Queries) UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error { + _, err := q.exec(ctx, q.updateSessionTitleAndUsageStmt, updateSessionTitleAndUsage, + arg.Title, + arg.PromptTokens, + arg.CompletionTokens, + arg.Cost, + arg.ID, + ) + return err +} diff --git a/internal/db/sql/sessions.sql b/internal/db/sql/sessions.sql index 9f67305981445d187a454f5b4ce5084c82f00a8a..54bc072a0dcd7462d805f30cf832714e1f7d7705 100644 --- a/internal/db/sql/sessions.sql +++ b/internal/db/sql/sessions.sql @@ -46,6 +46,15 @@ SET WHERE id = ? RETURNING *; +-- name: UpdateSessionTitleAndUsage :exec +UPDATE sessions +SET + title = ?, + prompt_tokens = prompt_tokens + ?, + completion_tokens = completion_tokens + ?, + cost = cost + ? +WHERE id = ?; + -- name: DeleteSession :exec DELETE FROM sessions diff --git a/internal/message/content.go b/internal/message/content.go index 358ad120d8f87109ea8888984ad236b155388788..7333f738c0aa685833c57cc97086e61928d3f51e 100644 --- a/internal/message/content.go +++ b/internal/message/content.go @@ -407,6 +407,15 @@ func (m *Message) SetToolResults(tr []ToolResult) { } } +// Clone returns a deep copy of the message with an independent Parts slice. +// This prevents race conditions when the message is modified concurrently. +func (m *Message) Clone() Message { + clone := *m + clone.Parts = make([]ContentPart, len(m.Parts)) + copy(clone.Parts, m.Parts) + return clone +} + func (m *Message) AddFinish(reason FinishReason, message, details string) { // remove any existing finish part for i, part := range m.Parts { diff --git a/internal/message/message.go b/internal/message/message.go index 97db7fe34d5a5169b1f6ef5686a3d27760b44909..a09d0acbf590e840541a7d5e057fb89513cc0618 100644 --- a/internal/message/message.go +++ b/internal/message/message.go @@ -51,7 +51,9 @@ func (s *service) Delete(ctx context.Context, id string) error { if err != nil { return err } - s.Publish(pubsub.DeletedEvent, message) + // Clone the message before publishing to avoid race conditions with + // concurrent modifications to the Parts slice. + s.Publish(pubsub.DeletedEvent, message.Clone()) return nil } @@ -85,7 +87,9 @@ func (s *service) Create(ctx context.Context, sessionID string, params CreateMes if err != nil { return Message{}, err } - s.Publish(pubsub.CreatedEvent, message) + // Clone the message before publishing to avoid race conditions with + // concurrent modifications to the Parts slice. + s.Publish(pubsub.CreatedEvent, message.Clone()) return message, nil } @@ -124,7 +128,9 @@ func (s *service) Update(ctx context.Context, message Message) error { return err } message.UpdatedAt = time.Now().Unix() - s.Publish(pubsub.UpdatedEvent, message) + // Clone the message before publishing to avoid race conditions with + // concurrent modifications to the Parts slice. + s.Publish(pubsub.UpdatedEvent, message.Clone()) return nil } diff --git a/internal/pubsub/broker.go b/internal/pubsub/broker.go index 80948d3d515a4fb5dad0d4dc36adbbff4e502993..ed14cbfed6c8fd44355501e16457e0dd92a494bc 100644 --- a/internal/pubsub/broker.go +++ b/internal/pubsub/broker.go @@ -92,22 +92,17 @@ func (b *Broker[T]) GetSubscriberCount() int { func (b *Broker[T]) Publish(t EventType, payload T) { b.mu.RLock() + defer b.mu.RUnlock() + select { case <-b.done: - b.mu.RUnlock() return default: } - subscribers := make([]chan Event[T], 0, len(b.subs)) - for sub := range b.subs { - subscribers = append(subscribers, sub) - } - b.mu.RUnlock() - event := Event[T]{Type: t, Payload: payload} - for _, sub := range subscribers { + for sub := range b.subs { select { case sub <- event: default: diff --git a/internal/session/session.go b/internal/session/session.go index 6d5e9a437c3c5a996973823e8903f47ea1cee514..3792cc1d576cdd7ebd0dbf0b64670c746718da9c 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -50,6 +50,7 @@ type Service interface { Get(ctx context.Context, id string) (Session, error) List(ctx context.Context) ([]Session, error) Save(ctx context.Context, session Session) (Session, error) + UpdateTitleAndUsage(ctx context.Context, sessionID, title string, promptTokens, completionTokens int64, cost float64) error Delete(ctx context.Context, id string) error // Agent tool session management @@ -156,6 +157,18 @@ func (s *service) Save(ctx context.Context, session Session) (Session, error) { return session, nil } +// UpdateTitleAndUsage updates only the title and usage fields atomically. +// This is safer than fetching, modifying, and saving the entire session. +func (s *service) UpdateTitleAndUsage(ctx context.Context, sessionID, title string, promptTokens, completionTokens int64, cost float64) error { + return s.q.UpdateSessionTitleAndUsage(ctx, db.UpdateSessionTitleAndUsageParams{ + ID: sessionID, + Title: title, + PromptTokens: promptTokens, + CompletionTokens: completionTokens, + Cost: cost, + }) +} + func (s *service) List(ctx context.Context) ([]Session, error) { dbSessions, err := s.q.ListSessions(ctx) if err != nil { From 1c5443358e99bb196bdd812969109126cb99937f Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 19 Dec 2025 13:22:05 +0100 Subject: [PATCH 12/29] fix: initial api key load (#1672) --- internal/agent/coordinator.go | 10 ++++------ internal/config/config.go | 4 +--- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index ea214a5bcfb8e65e6d5dee826854345168a2eb86..7ce37758897da42cd8515c94c26b9e24add6ea1e 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -516,14 +516,13 @@ func (c *coordinator) buildAgentModels(ctx context.Context) (Model, Model, error }, nil } -func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) { +func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string, isOauth bool) (fantasy.Provider, error) { var opts []anthropic.Option - if strings.HasPrefix(apiKey, "Bearer ") { + if isOauth { // NOTE: Prevent the SDK from picking up the API key from env. os.Setenv("ANTHROPIC_API_KEY", "") - - headers["Authorization"] = apiKey + headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey) } else if apiKey != "" { // X-Api-Key header opts = append(opts, anthropic.WithAPIKey(apiKey)) @@ -541,7 +540,6 @@ func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map httpClient := log.NewHTTPClient() opts = append(opts, anthropic.WithHTTPClient(httpClient)) } - return anthropic.New(opts...) } @@ -722,7 +720,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) + return c.buildAnthropicProvider(baseURL, apiKey, headers, providerCfg.OAuthToken != nil) case openrouter.Name: return c.buildOpenrouterProvider(baseURL, apiKey, headers) case azure.Name: diff --git a/internal/config/config.go b/internal/config/config.go index e5878a5d0c999c612e3b5e2c5dd61ec4c4dc324d..887e58b66d92c860c5d7fa9bc7a512b3853be4f4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -550,13 +550,12 @@ func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error slog.Info("Successfully refreshed OAuth token", "provider", providerID) providerConfig.OAuthToken = newToken + providerConfig.APIKey = newToken.AccessToken switch providerID { case string(catwalk.InferenceProviderAnthropic): - providerConfig.APIKey = fmt.Sprintf("Bearer %s", newToken.AccessToken) providerConfig.SetupClaudeCode() case string(catwalk.InferenceProviderCopilot): - providerConfig.APIKey = newToken.AccessToken providerConfig.SetupGitHubCopilot() } @@ -595,7 +594,6 @@ func (c *Config) SetProviderAPIKey(providerID string, apiKey any) error { providerConfig.OAuthToken = v switch providerID { case string(catwalk.InferenceProviderAnthropic): - providerConfig.APIKey = fmt.Sprintf("Bearer %s", v.AccessToken) providerConfig.SetupClaudeCode() case string(catwalk.InferenceProviderCopilot): providerConfig.SetupGitHubCopilot() From 519eedc7249768bd759633833f9f28dddea2de90 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Fri, 19 Dec 2025 12:23:11 +0000 Subject: [PATCH 13/29] chore: auto-update files --- internal/agent/hyper/provider.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/agent/hyper/provider.json b/internal/agent/hyper/provider.json index 5558750e38e35024615b41b71243888a1a1ebd6c..483d5b2d1d6f6514c6df0e6b543db701bab51880 100644 --- a/internal/agent/hyper/provider.json +++ b/internal/agent/hyper/provider.json @@ -1 +1 @@ -{"name":"Charm Hyper","id":"hyper","api_endpoint":"https://console.charm.land/api/v1/fantasy","type":"hyper","default_large_model_id":"claude-sonnet-4-5","default_small_model_id":"claude-3-5-haiku","models":[{"id":"Kimi-K2-0905","name":"Kimi K2 0905","cost_per_1m_in":0.55,"cost_per_1m_out":2.19,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":256000,"default_max_tokens":10000,"can_reason":true,"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"claude-3-5-haiku","name":"Claude 3.5 Haiku","cost_per_1m_in":0.7999999999999999,"cost_per_1m_out":4,"cost_per_1m_in_cached":1,"cost_per_1m_out_cached":0.08,"context_window":200000,"default_max_tokens":5000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"claude-3-5-sonnet","name":"Claude 3.5 Sonnet (New)","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":5000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"claude-3-7-sonnet","name":"Claude 3.7 Sonnet","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-haiku-4-5","name":"Claude 4.5 Haiku","cost_per_1m_in":1,"cost_per_1m_out":5,"cost_per_1m_in_cached":1.25,"cost_per_1m_out_cached":0.09999999999999999,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4","name":"Claude Opus 4","cost_per_1m_in":15,"cost_per_1m_out":75,"cost_per_1m_in_cached":18.75,"cost_per_1m_out_cached":1.5,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-1","name":"Claude Opus 4.1","cost_per_1m_in":15,"cost_per_1m_out":75,"cost_per_1m_in_cached":18.75,"cost_per_1m_out_cached":1.5,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-5","name":"Claude Opus 4.5","cost_per_1m_in":5,"cost_per_1m_out":25,"cost_per_1m_in_cached":6.25,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4","name":"Claude Sonnet 4","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-2.5-flash","name":"Gemini 2.5 Flash","cost_per_1m_in":0.3,"cost_per_1m_out":2.5,"cost_per_1m_in_cached":0.3833,"cost_per_1m_out_cached":0.075,"context_window":1048576,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-2.5-pro","name":"Gemini 2.5 Pro","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":1.625,"cost_per_1m_out_cached":0.31,"context_window":1048576,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"glm-4.6","name":"GLM-4.6","cost_per_1m_in":0.6,"cost_per_1m_out":2.2,"cost_per_1m_in_cached":0.11,"cost_per_1m_out_cached":0,"context_window":204800,"default_max_tokens":131072,"can_reason":true,"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"gpt-4.1","name":"GPT-4.1","cost_per_1m_in":2,"cost_per_1m_out":8,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.5,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4.1-mini","name":"GPT-4.1 Mini","cost_per_1m_in":0.39999999999999997,"cost_per_1m_out":1.5999999999999999,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.09999999999999999,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4.1-nano","name":"GPT-4.1 Nano","cost_per_1m_in":0.09999999999999999,"cost_per_1m_out":0.39999999999999997,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.024999999999999998,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4o","name":"GPT-4o","cost_per_1m_in":2.5,"cost_per_1m_out":10,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":1.25,"context_window":128000,"default_max_tokens":8192,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4o-mini","name":"GPT-4o-mini","cost_per_1m_in":0.15,"cost_per_1m_out":0.6,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.075,"context_window":128000,"default_max_tokens":8192,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-5","name":"GPT-5","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-codex","name":"GPT-5 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-mini","name":"GPT-5 Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-nano","name":"GPT-5 Nano","cost_per_1m_in":0.05,"cost_per_1m_out":0.4,"cost_per_1m_in_cached":0.005,"cost_per_1m_out_cached":0.005,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1","name":"GPT-5.1","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex","name":"GPT-5.1 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-max","name":"GPT-5.1 Codex Max","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-mini","name":"GPT-5.1 Codex Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2","name":"GPT-5.2","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"o3","name":"o3","cost_per_1m_in":2,"cost_per_1m_out":8,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"o3-mini","name":"o3 Mini","cost_per_1m_in":1.1,"cost_per_1m_out":4.4,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.55,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"o4-mini","name":"o4 Mini","cost_per_1m_in":1.1,"cost_per_1m_out":4.4,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.275,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"qwen3-coder-480b-a35b-instruct","name":"Qwen 3 480B Coder","cost_per_1m_in":0.82,"cost_per_1m_out":3.29,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":131072,"default_max_tokens":65536,"can_reason":false,"supports_attachments":false,"options":{}}]} \ No newline at end of file +{"name":"Charm Hyper","id":"hyper","api_endpoint":"https://console.charm.land/api/v1/fantasy","type":"hyper","default_large_model_id":"claude-sonnet-4-5","default_small_model_id":"claude-3-5-haiku","models":[{"id":"Kimi-K2-0905","name":"Kimi K2 0905","cost_per_1m_in":0.55,"cost_per_1m_out":2.19,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":256000,"default_max_tokens":10000,"can_reason":true,"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"claude-3-5-haiku","name":"Claude 3.5 Haiku","cost_per_1m_in":0.7999999999999999,"cost_per_1m_out":4,"cost_per_1m_in_cached":1,"cost_per_1m_out_cached":0.08,"context_window":200000,"default_max_tokens":5000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"claude-3-5-sonnet","name":"Claude 3.5 Sonnet (New)","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":5000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"claude-3-7-sonnet","name":"Claude 3.7 Sonnet","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-haiku-4-5","name":"Claude 4.5 Haiku","cost_per_1m_in":1,"cost_per_1m_out":5,"cost_per_1m_in_cached":1.25,"cost_per_1m_out_cached":0.09999999999999999,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4","name":"Claude Opus 4","cost_per_1m_in":15,"cost_per_1m_out":75,"cost_per_1m_in_cached":18.75,"cost_per_1m_out_cached":1.5,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-1","name":"Claude Opus 4.1","cost_per_1m_in":15,"cost_per_1m_out":75,"cost_per_1m_in_cached":18.75,"cost_per_1m_out_cached":1.5,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-5","name":"Claude Opus 4.5","cost_per_1m_in":5,"cost_per_1m_out":25,"cost_per_1m_in_cached":6.25,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4","name":"Claude Sonnet 4","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-2.5-flash","name":"Gemini 2.5 Flash","cost_per_1m_in":0.3,"cost_per_1m_out":2.5,"cost_per_1m_in_cached":0.3833,"cost_per_1m_out_cached":0.075,"context_window":1048576,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-2.5-pro","name":"Gemini 2.5 Pro","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":1.625,"cost_per_1m_out_cached":0.31,"context_window":1048576,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"glm-4.6","name":"GLM-4.6","cost_per_1m_in":0.6,"cost_per_1m_out":2.2,"cost_per_1m_in_cached":0.11,"cost_per_1m_out_cached":0,"context_window":204800,"default_max_tokens":131072,"can_reason":true,"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"gpt-4.1","name":"GPT-4.1","cost_per_1m_in":2,"cost_per_1m_out":8,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.5,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4.1-mini","name":"GPT-4.1 Mini","cost_per_1m_in":0.39999999999999997,"cost_per_1m_out":1.5999999999999999,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.09999999999999999,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4.1-nano","name":"GPT-4.1 Nano","cost_per_1m_in":0.09999999999999999,"cost_per_1m_out":0.39999999999999997,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.024999999999999998,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4o","name":"GPT-4o","cost_per_1m_in":2.5,"cost_per_1m_out":10,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":1.25,"context_window":128000,"default_max_tokens":8192,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4o-mini","name":"GPT-4o-mini","cost_per_1m_in":0.15,"cost_per_1m_out":0.6,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.075,"context_window":128000,"default_max_tokens":8192,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-5","name":"GPT-5","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.25,"cost_per_1m_out_cached":0.25,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"default_reasoning_effort":"minimal","supports_attachments":true,"options":{}},{"id":"gpt-5-codex","name":"GPT-5 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-mini","name":"GPT-5 Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"default_reasoning_effort":"low","supports_attachments":true,"options":{}},{"id":"gpt-5-nano","name":"GPT-5 Nano","cost_per_1m_in":0.05,"cost_per_1m_out":0.4,"cost_per_1m_in_cached":0.005,"cost_per_1m_out_cached":0.005,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"default_reasoning_effort":"low","supports_attachments":true,"options":{}},{"id":"gpt-5.1","name":"GPT-5.1","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex","name":"GPT-5.1 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-max","name":"GPT-5.1 Codex Max","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-mini","name":"GPT-5.1 Codex Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2","name":"GPT-5.2","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"o3","name":"o3","cost_per_1m_in":2,"cost_per_1m_out":8,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"o3-mini","name":"o3 Mini","cost_per_1m_in":1.1,"cost_per_1m_out":4.4,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.55,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"o4-mini","name":"o4 Mini","cost_per_1m_in":1.1,"cost_per_1m_out":4.4,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.275,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"qwen3-coder-480b-a35b-instruct","name":"Qwen 3 480B Coder","cost_per_1m_in":0.82,"cost_per_1m_out":3.29,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":131072,"default_max_tokens":65536,"can_reason":false,"supports_attachments":false,"options":{}}]} \ No newline at end of file From 2e177fa7aa7f4f19bf08a370f2309493451a2b34 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Fri, 19 Dec 2025 12:24:24 +0000 Subject: [PATCH 14/29] chore: auto-update files --- internal/agent/hyper/provider.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/agent/hyper/provider.json b/internal/agent/hyper/provider.json index 483d5b2d1d6f6514c6df0e6b543db701bab51880..5558750e38e35024615b41b71243888a1a1ebd6c 100644 --- a/internal/agent/hyper/provider.json +++ b/internal/agent/hyper/provider.json @@ -1 +1 @@ -{"name":"Charm Hyper","id":"hyper","api_endpoint":"https://console.charm.land/api/v1/fantasy","type":"hyper","default_large_model_id":"claude-sonnet-4-5","default_small_model_id":"claude-3-5-haiku","models":[{"id":"Kimi-K2-0905","name":"Kimi K2 0905","cost_per_1m_in":0.55,"cost_per_1m_out":2.19,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":256000,"default_max_tokens":10000,"can_reason":true,"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"claude-3-5-haiku","name":"Claude 3.5 Haiku","cost_per_1m_in":0.7999999999999999,"cost_per_1m_out":4,"cost_per_1m_in_cached":1,"cost_per_1m_out_cached":0.08,"context_window":200000,"default_max_tokens":5000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"claude-3-5-sonnet","name":"Claude 3.5 Sonnet (New)","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":5000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"claude-3-7-sonnet","name":"Claude 3.7 Sonnet","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-haiku-4-5","name":"Claude 4.5 Haiku","cost_per_1m_in":1,"cost_per_1m_out":5,"cost_per_1m_in_cached":1.25,"cost_per_1m_out_cached":0.09999999999999999,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4","name":"Claude Opus 4","cost_per_1m_in":15,"cost_per_1m_out":75,"cost_per_1m_in_cached":18.75,"cost_per_1m_out_cached":1.5,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-1","name":"Claude Opus 4.1","cost_per_1m_in":15,"cost_per_1m_out":75,"cost_per_1m_in_cached":18.75,"cost_per_1m_out_cached":1.5,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-5","name":"Claude Opus 4.5","cost_per_1m_in":5,"cost_per_1m_out":25,"cost_per_1m_in_cached":6.25,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4","name":"Claude Sonnet 4","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-2.5-flash","name":"Gemini 2.5 Flash","cost_per_1m_in":0.3,"cost_per_1m_out":2.5,"cost_per_1m_in_cached":0.3833,"cost_per_1m_out_cached":0.075,"context_window":1048576,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-2.5-pro","name":"Gemini 2.5 Pro","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":1.625,"cost_per_1m_out_cached":0.31,"context_window":1048576,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"glm-4.6","name":"GLM-4.6","cost_per_1m_in":0.6,"cost_per_1m_out":2.2,"cost_per_1m_in_cached":0.11,"cost_per_1m_out_cached":0,"context_window":204800,"default_max_tokens":131072,"can_reason":true,"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"gpt-4.1","name":"GPT-4.1","cost_per_1m_in":2,"cost_per_1m_out":8,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.5,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4.1-mini","name":"GPT-4.1 Mini","cost_per_1m_in":0.39999999999999997,"cost_per_1m_out":1.5999999999999999,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.09999999999999999,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4.1-nano","name":"GPT-4.1 Nano","cost_per_1m_in":0.09999999999999999,"cost_per_1m_out":0.39999999999999997,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.024999999999999998,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4o","name":"GPT-4o","cost_per_1m_in":2.5,"cost_per_1m_out":10,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":1.25,"context_window":128000,"default_max_tokens":8192,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4o-mini","name":"GPT-4o-mini","cost_per_1m_in":0.15,"cost_per_1m_out":0.6,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.075,"context_window":128000,"default_max_tokens":8192,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-5","name":"GPT-5","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.25,"cost_per_1m_out_cached":0.25,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"default_reasoning_effort":"minimal","supports_attachments":true,"options":{}},{"id":"gpt-5-codex","name":"GPT-5 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-mini","name":"GPT-5 Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"default_reasoning_effort":"low","supports_attachments":true,"options":{}},{"id":"gpt-5-nano","name":"GPT-5 Nano","cost_per_1m_in":0.05,"cost_per_1m_out":0.4,"cost_per_1m_in_cached":0.005,"cost_per_1m_out_cached":0.005,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"default_reasoning_effort":"low","supports_attachments":true,"options":{}},{"id":"gpt-5.1","name":"GPT-5.1","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex","name":"GPT-5.1 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-max","name":"GPT-5.1 Codex Max","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-mini","name":"GPT-5.1 Codex Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2","name":"GPT-5.2","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"o3","name":"o3","cost_per_1m_in":2,"cost_per_1m_out":8,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"o3-mini","name":"o3 Mini","cost_per_1m_in":1.1,"cost_per_1m_out":4.4,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.55,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"o4-mini","name":"o4 Mini","cost_per_1m_in":1.1,"cost_per_1m_out":4.4,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.275,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"qwen3-coder-480b-a35b-instruct","name":"Qwen 3 480B Coder","cost_per_1m_in":0.82,"cost_per_1m_out":3.29,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":131072,"default_max_tokens":65536,"can_reason":false,"supports_attachments":false,"options":{}}]} \ No newline at end of file +{"name":"Charm Hyper","id":"hyper","api_endpoint":"https://console.charm.land/api/v1/fantasy","type":"hyper","default_large_model_id":"claude-sonnet-4-5","default_small_model_id":"claude-3-5-haiku","models":[{"id":"Kimi-K2-0905","name":"Kimi K2 0905","cost_per_1m_in":0.55,"cost_per_1m_out":2.19,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":256000,"default_max_tokens":10000,"can_reason":true,"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"claude-3-5-haiku","name":"Claude 3.5 Haiku","cost_per_1m_in":0.7999999999999999,"cost_per_1m_out":4,"cost_per_1m_in_cached":1,"cost_per_1m_out_cached":0.08,"context_window":200000,"default_max_tokens":5000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"claude-3-5-sonnet","name":"Claude 3.5 Sonnet (New)","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":5000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"claude-3-7-sonnet","name":"Claude 3.7 Sonnet","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-haiku-4-5","name":"Claude 4.5 Haiku","cost_per_1m_in":1,"cost_per_1m_out":5,"cost_per_1m_in_cached":1.25,"cost_per_1m_out_cached":0.09999999999999999,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4","name":"Claude Opus 4","cost_per_1m_in":15,"cost_per_1m_out":75,"cost_per_1m_in_cached":18.75,"cost_per_1m_out_cached":1.5,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-1","name":"Claude Opus 4.1","cost_per_1m_in":15,"cost_per_1m_out":75,"cost_per_1m_in_cached":18.75,"cost_per_1m_out_cached":1.5,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-5","name":"Claude Opus 4.5","cost_per_1m_in":5,"cost_per_1m_out":25,"cost_per_1m_in_cached":6.25,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4","name":"Claude Sonnet 4","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-2.5-flash","name":"Gemini 2.5 Flash","cost_per_1m_in":0.3,"cost_per_1m_out":2.5,"cost_per_1m_in_cached":0.3833,"cost_per_1m_out_cached":0.075,"context_window":1048576,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-2.5-pro","name":"Gemini 2.5 Pro","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":1.625,"cost_per_1m_out_cached":0.31,"context_window":1048576,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"glm-4.6","name":"GLM-4.6","cost_per_1m_in":0.6,"cost_per_1m_out":2.2,"cost_per_1m_in_cached":0.11,"cost_per_1m_out_cached":0,"context_window":204800,"default_max_tokens":131072,"can_reason":true,"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"gpt-4.1","name":"GPT-4.1","cost_per_1m_in":2,"cost_per_1m_out":8,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.5,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4.1-mini","name":"GPT-4.1 Mini","cost_per_1m_in":0.39999999999999997,"cost_per_1m_out":1.5999999999999999,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.09999999999999999,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4.1-nano","name":"GPT-4.1 Nano","cost_per_1m_in":0.09999999999999999,"cost_per_1m_out":0.39999999999999997,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.024999999999999998,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4o","name":"GPT-4o","cost_per_1m_in":2.5,"cost_per_1m_out":10,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":1.25,"context_window":128000,"default_max_tokens":8192,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4o-mini","name":"GPT-4o-mini","cost_per_1m_in":0.15,"cost_per_1m_out":0.6,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.075,"context_window":128000,"default_max_tokens":8192,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-5","name":"GPT-5","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-codex","name":"GPT-5 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-mini","name":"GPT-5 Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-nano","name":"GPT-5 Nano","cost_per_1m_in":0.05,"cost_per_1m_out":0.4,"cost_per_1m_in_cached":0.005,"cost_per_1m_out_cached":0.005,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1","name":"GPT-5.1","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex","name":"GPT-5.1 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-max","name":"GPT-5.1 Codex Max","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-mini","name":"GPT-5.1 Codex Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2","name":"GPT-5.2","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"o3","name":"o3","cost_per_1m_in":2,"cost_per_1m_out":8,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"o3-mini","name":"o3 Mini","cost_per_1m_in":1.1,"cost_per_1m_out":4.4,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.55,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"o4-mini","name":"o4 Mini","cost_per_1m_in":1.1,"cost_per_1m_out":4.4,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.275,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"qwen3-coder-480b-a35b-instruct","name":"Qwen 3 480B Coder","cost_per_1m_in":0.82,"cost_per_1m_out":3.29,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":131072,"default_max_tokens":65536,"can_reason":false,"supports_attachments":false,"options":{}}]} \ No newline at end of file From ec226090e2865b5bc9f3d4a186c6d71afdce6dc8 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 19 Dec 2025 10:19:21 -0300 Subject: [PATCH 15/29] fix: splash padding y (#1680) Signed-off-by: Carlos Alexandro Becker --- internal/tui/components/chat/splash/splash.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 06ec0bfb71b9dc6723a3eba60f3e67eac3271ca6..8a053294da3e342661c0db8b38cd371103c943b1 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -656,7 +656,7 @@ func (s *splashCmp) View() string { switch { case s.showClaudeAuthMethodChooser: - remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) + remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY chooserView := s.claudeAuthMethodChooser.View() authMethodSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( lipgloss.JoinVertical( @@ -672,7 +672,7 @@ func (s *splashCmp) View() string { authMethodSelector, ) case s.showClaudeOAuth2: - remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) + remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY oauth2View := s.claudeOAuth2.View() oauthSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( lipgloss.JoinVertical( @@ -688,7 +688,7 @@ func (s *splashCmp) View() string { oauthSelector, ) case s.showHyperDeviceFlow: - remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) + remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY hyperView := s.hyperDeviceFlow.View() hyperSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( lipgloss.JoinVertical( @@ -703,7 +703,7 @@ func (s *splashCmp) View() string { hyperSelector, ) case s.showCopilotDeviceFlow: - remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) + remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY copilotView := s.copilotDeviceFlow.View() copilotSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( lipgloss.JoinVertical( @@ -718,7 +718,7 @@ func (s *splashCmp) View() string { copilotSelector, ) case s.needsAPIKey: - remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) + remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY apiKeyView := t.S().Base.PaddingLeft(1).Render(s.apiKeyInput.View()) apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( lipgloss.JoinVertical( @@ -733,7 +733,7 @@ func (s *splashCmp) View() string { ) case s.isOnboarding: modelListView := s.modelList.View() - remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) + remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY modelSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( lipgloss.JoinVertical( lipgloss.Left, From 5d419e806bdd202e84549ff580922e1a6f150d6c Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 19 Dec 2025 11:42:46 -0300 Subject: [PATCH 16/29] fix(aws-bedrock): update fantasy with `panic` fix (#1681) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 48f8ef5e14732bbd9f57bedbf7d028d844345790..bb4cc61ca228928d0f8fdd65b9741cd2d6253b7a 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 charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e - charm.land/fantasy v0.5.4 + charm.land/fantasy v0.5.5 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 diff --git a/go.sum b/go.sum index 970cc24f29889e74a7c4adbac9be1682e99f3470..2f3cb4fa2a48029e02ff2731daa374851e10fd3a 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ 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/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.5.4 h1:tl8qq/prtFbtwMGDBTLg0QibPdVV02Esp9+a2Zov8Io= -charm.land/fantasy v0.5.4/go.mod h1:WnH5fJJRMGylx1fL1ow9Kfq0+sPMr5fenpHYAnoTlTg= +charm.land/fantasy v0.5.5 h1:Dw/NBLH9HLX/ouCz604RXGD7BYzr0lT56/B4ylMGZjg= +charm.land/fantasy v0.5.5/go.mod h1:QyJLJGissYdBifvitgAxFcYhNACSr0G1faC75CIESUk= 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= From b3f73213fd7ee20329632d3b9d47f792ba00482c Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 19 Dec 2025 16:39:00 -0300 Subject: [PATCH 17/29] feat: paste long content as an attachment (#1634) Signed-off-by: Carlos Alexandro Becker Co-authored-by: Kujtim Hoxha --- internal/agent/agent.go | 7 +- internal/agent/coordinator.go | 9 +- internal/message/attachment.go | 5 + internal/message/content.go | 37 ++++ internal/tui/components/chat/editor/editor.go | 170 ++++++++++++------ .../tui/components/chat/messages/messages.go | 31 +++- internal/tui/page/chat/chat.go | 7 +- internal/tui/styles/icons.go | 3 +- 8 files changed, 202 insertions(+), 67 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 30539e5dba5a5b7aa66c2d2ffc35336dbe3d9774..65655a21385baa5d98926ed62ba89ba0aac2c539 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -200,7 +200,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy var currentAssistant *message.Message var shouldSummarize bool result, err := agent.Stream(genCtx, fantasy.AgentStreamCall{ - Prompt: call.Prompt, + Prompt: message.PromptWithTextAttachments(call.Prompt, call.Attachments), Files: files, Messages: history, ProviderOptions: call.ProviderOptions, @@ -649,11 +649,11 @@ func (a *sessionAgent) getCacheControlOptions() fantasy.ProviderOptions { } func (a *sessionAgent) createUserMessage(ctx context.Context, call SessionAgentCall) (message.Message, error) { + parts := []message.ContentPart{message.TextContent{Text: call.Prompt}} var attachmentParts []message.ContentPart for _, attachment := range call.Attachments { attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content}) } - parts := []message.ContentPart{message.TextContent{Text: call.Prompt}} parts = append(parts, attachmentParts...) msg, err := a.messages.Create(ctx, call.SessionID, message.CreateMessageParams{ Role: message.User, @@ -690,6 +690,9 @@ If not, please feel free to ignore. Again do not mention this message to the use var files []fantasy.FilePart for _, attachment := range attachments { + if attachment.IsText() { + continue + } files = append(files, fantasy.FilePart{ Filename: attachment.FileName, Data: attachment.Content, diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 7ce37758897da42cd8515c94c26b9e24add6ea1e..ef2bdfc9cd7671b43ba22ec8a02b77b7510e5518 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -123,7 +123,14 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, } if !model.CatwalkCfg.SupportsImages && attachments != nil { - attachments = nil + // filter out image attachments + filteredAttachments := make([]message.Attachment, 0, len(attachments)) + for _, att := range attachments { + if att.IsText() { + filteredAttachments = append(filteredAttachments, att) + } + } + attachments = filteredAttachments } providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider) diff --git a/internal/message/attachment.go b/internal/message/attachment.go index 6e89f001436ed120d52c08c05ade8c8a741cfb7a..0e3b70a8766c74d37399c1ba8c38fe19e74f871d 100644 --- a/internal/message/attachment.go +++ b/internal/message/attachment.go @@ -1,8 +1,13 @@ package message +import "strings" + type Attachment struct { FilePath string FileName string MimeType string Content []byte } + +func (a Attachment) IsText() bool { return strings.HasPrefix(a.MimeType, "text/") } +func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") } diff --git a/internal/message/content.go b/internal/message/content.go index 7333f738c0aa685833c57cc97086e61928d3f51e..6c03d42aed05a7772f37d15dc782bf96c8b69685 100644 --- a/internal/message/content.go +++ b/internal/message/content.go @@ -3,6 +3,7 @@ package message import ( "encoding/base64" "errors" + "fmt" "slices" "strings" "time" @@ -435,16 +436,52 @@ func (m *Message) AddBinary(mimeType string, data []byte) { m.Parts = append(m.Parts, BinaryContent{MIMEType: mimeType, Data: data}) } +func PromptWithTextAttachments(prompt string, attachments []Attachment) string { + 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" + addedAttachments = true + } + tag := `\n` + if content.FilePath != "" { + tag = fmt.Sprintf("\n", content.FilePath) + } + prompt += tag + prompt += "\n" + string(content.Content) + "\n\n" + } + return prompt +} + func (m *Message) ToAIMessage() []fantasy.Message { var messages []fantasy.Message switch m.Role { case User: var parts []fantasy.MessagePart text := strings.TrimSpace(m.Content().Text) + var textAttachments []Attachment + for _, content := range m.BinaryContent() { + if !strings.HasPrefix(content.MIMEType, "text/") { + continue + } + textAttachments = append(textAttachments, Attachment{ + FilePath: content.Path, + MimeType: content.MIMEType, + Content: content.Data, + }) + } + text = PromptWithTextAttachments(text, textAttachments) if text != "" { parts = append(parts, fantasy.TextPart{Text: text}) } for _, content := range m.BinaryContent() { + // skip text attachements + if strings.HasPrefix(content.MIMEType, "text/") { + continue + } parts = append(parts, fantasy.FilePart{ Filename: content.Path, Data: content.Data, diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index bd90d90a7ea26294ddd7e4149c14f6e7f32e1cb5..014d662ce59d1de84f16cd17057aa158c80384a7 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -2,6 +2,7 @@ package editor import ( "context" + "errors" "fmt" "math/rand" "net/http" @@ -29,6 +30,7 @@ import ( "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/x/ansi" ) type Editor interface { @@ -84,10 +86,7 @@ var DeleteKeyMaps = DeleteAttachmentKeyMaps{ ), } -const ( - maxAttachments = 5 - maxFileResults = 25 -) +const maxFileResults = 25 type OpenEditorMsg struct { Text string @@ -145,14 +144,14 @@ func (m *editorCmp) send() tea.Cmd { return util.CmdHandler(dialogs.OpenDialogMsg{Model: quit.NewQuitDialog()}) } - m.textarea.Reset() attachments := m.attachments - m.attachments = nil if value == "" { return nil } + m.textarea.Reset() + m.attachments = nil // Change the placeholder when sending a new message. m.randomizePlaceholders() @@ -176,9 +175,6 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { case tea.WindowSizeMsg: return m, m.repositionCompletions case filepicker.FilePickedMsg: - if len(m.attachments) >= maxAttachments { - return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments)) - } m.attachments = append(m.attachments, msg.Attachment) return m, nil case completions.CompletionsOpenedMsg: @@ -206,6 +202,17 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { m.currentQuery = "" m.completionsStartIndex = 0 } + content, err := os.ReadFile(item.Path) + if err != nil { + // if it fails, let the LLM handle it later. + return m, nil + } + m.attachments = append(m.attachments, message.Attachment{ + FilePath: item.Path, + FileName: filepath.Base(item.Path), + MimeType: mimeOf(content), + Content: content, + }) } case commands.OpenExternalEditorMsg: @@ -217,39 +224,30 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { m.textarea.SetValue(msg.Text) m.textarea.MoveToEnd() case tea.PasteMsg: - path := strings.ReplaceAll(msg.Content, "\\ ", " ") - // try to get an image - path, err := filepath.Abs(strings.TrimSpace(path)) - if err != nil { + content, path, err := pasteToFile(msg) + if errors.Is(err, errNotAFile) { m.textarea, cmd = m.textarea.Update(msg) return m, cmd } - isAllowedType := false - for _, ext := range filepicker.AllowedTypes { - if strings.HasSuffix(path, ext) { - isAllowedType = true - break - } - } - if !isAllowedType { - m.textarea, cmd = m.textarea.Update(msg) - return m, cmd + if err != nil { + return m, util.ReportError(err) } - tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize) - if tooBig { - m.textarea, cmd = m.textarea.Update(msg) - return m, cmd + + if len(content) > maxAttachmentSize { + return m, util.ReportWarn("File is too big (>5mb)") } - content, err := os.ReadFile(path) - if err != nil { - m.textarea, cmd = m.textarea.Update(msg) - return m, cmd + mimeType := mimeOf(content) + attachment := message.Attachment{ + FilePath: path, + FileName: filepath.Base(path), + MimeType: mimeType, + Content: content, } - 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} + 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, }) @@ -427,18 +425,17 @@ func (m *editorCmp) View() string { m.textarea.Placeholder = "Yolo mode!" } if len(m.attachments) == 0 { - content := t.S().Base.Padding(1).Render( + return t.S().Base.Padding(1).Render( m.textarea.View(), ) - return content } - content := t.S().Base.Padding(0, 1, 1, 1).Render( - lipgloss.JoinVertical(lipgloss.Top, + return t.S().Base.Padding(0, 1, 1, 1).Render( + lipgloss.JoinVertical( + lipgloss.Top, m.attachmentsContent(), m.textarea.View(), ), ) - return content } func (m *editorCmp) SetSize(width, height int) tea.Cmd { @@ -456,24 +453,45 @@ func (m *editorCmp) GetSize() (int, int) { func (m *editorCmp) attachmentsContent() string { var styledAttachments []string t := styles.CurrentTheme() - attachmentStyles := t.S().Base. - MarginLeft(1). + attachmentStyle := t.S().Base. + Padding(0, 1). + MarginRight(1). Background(t.FgMuted). - Foreground(t.FgBase) + Foreground(t.FgBase). + Render + iconStyle := t.S().Base. + Foreground(t.BgSubtle). + Background(t.Green). + Padding(0, 1). + Bold(true). + Render + rmStyle := t.S().Base. + Padding(0, 1). + Bold(true). + Background(t.Red). + Foreground(t.FgBase). + Render for i, attachment := range m.attachments { - var filename string - if len(attachment.FileName) > 10 { - filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7]) - } else { - filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName) + filename := ansi.Truncate(filepath.Base(attachment.FileName), 10, "...") + icon := styles.ImageIcon + if attachment.IsText() { + icon = styles.TextIcon } if m.deleteMode { - filename = fmt.Sprintf("%d%s", i, filename) + styledAttachments = append( + styledAttachments, + rmStyle(fmt.Sprintf("%d", i)), + attachmentStyle(filename), + ) + continue } - styledAttachments = append(styledAttachments, attachmentStyles.Render(filename)) + styledAttachments = append( + styledAttachments, + iconStyle(icon), + attachmentStyle(filename), + ) } - content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...) - return content + return lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...) } func (m *editorCmp) SetPosition(x, y int) tea.Cmd { @@ -597,3 +615,51 @@ func New(app *app.App) Editor { return e } + +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 +} + +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 + } + return content, f.Name(), nil +} + +func filepathToFile(name string) ([]byte, string, error) { + path, err := filepath.Abs(strings.TrimSpace(strings.ReplaceAll(name, "\\", ""))) + if err != nil { + return nil, "", err + } + content, err := os.ReadFile(path) + if err != nil { + return nil, "", err + } + return content, path, nil +} + +func mimeOf(content []byte) string { + mimeBufferSize := min(512, len(content)) + return http.DetectContentType(content[:mimeBufferSize]) +} diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 38012c235df4d455c1b826f6a5ff491783ea1f5e..1359823edb7a783cd23b600e1ddae3870f2a2107 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -227,19 +227,32 @@ func (m *messageCmp) renderUserMessage() string { m.toMarkdown(m.message.Content().String()), } - attachmentStyles := t.S().Text. - MarginLeft(1). - Background(t.BgSubtle) + attachmentStyle := t.S().Base. + Padding(0, 1). + MarginRight(1). + Background(t.FgMuted). + Foreground(t.FgBase). + Render + iconStyle := t.S().Base. + Foreground(t.BgSubtle). + Background(t.Green). + Padding(0, 1). + Bold(true). + Render attachments := make([]string, len(m.message.BinaryContent())) for i, attachment := range m.message.BinaryContent() { const maxFilenameWidth = 10 - filename := filepath.Base(attachment.Path) - attachments[i] = attachmentStyles.Render(fmt.Sprintf( - " %s %s ", - styles.DocumentIcon, - ansi.Truncate(filename, maxFilenameWidth, "..."), - )) + filename := ansi.Truncate(filepath.Base(attachment.Path), 10, "...") + icon := styles.ImageIcon + if strings.HasPrefix(attachment.MIMEType, "text/") { + icon = styles.TextIcon + } + attachments[i] = lipgloss.JoinHorizontal( + lipgloss.Left, + iconStyle(icon), + attachmentStyle(filename), + ) } if len(attachments) > 0 { diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 33a71f9e3b9d184c3fb18423a91dd0baf8c2a0b9..8c54b028f90326ac8cee1cacb0df2377528e4a2b 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -613,8 +613,11 @@ func (p *chatPage) View() string { pillsArea = pillsRow } - style := t.S().Base.MarginTop(1).PaddingLeft(3) - pillsArea = style.Render(pillsArea) + pillsArea = t.S().Base. + MaxWidth(p.width). + MarginTop(1). + PaddingLeft(3). + Render(pillsArea) } if p.compact { diff --git a/internal/tui/styles/icons.go b/internal/tui/styles/icons.go index dfb3cf0c27ccf4a90d84f256d40e1a9a87fc5aa3..0db13358a2f9812293c18497b71ba138484b8f17 100644 --- a/internal/tui/styles/icons.go +++ b/internal/tui/styles/icons.go @@ -10,7 +10,8 @@ const ( ArrowRightIcon string = "→" CenterSpinnerIcon string = "⋯" LoadingIcon string = "⟳" - DocumentIcon string = "🖼" + ImageIcon string = "■" + TextIcon string = "☰" ModelIcon string = "◇" // Tool call icons From 41335547790de560fad7f33a69ef32d53591c41f Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sun, 21 Dec 2025 11:20:28 -0500 Subject: [PATCH 19/29] fix: prevent filename insertion when dragging attachments (#1683) --- internal/tui/components/chat/editor/editor.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 014d662ce59d1de84f16cd17057aa158c80384a7..1623ba806cd9e93bd1544aabbdb6a7e3f6604bcc 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -247,7 +247,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, }) From f86f2f8e145fedcb671a77e45d5e65404e6316ec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Dec 2025 10:27:08 +0000 Subject: [PATCH 20/29] chore(deps): bump stefanzweifel/git-auto-commit-action from 7.0.0 to 7.1.0 in the all group (#1692) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/schema-update.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/schema-update.yml b/.github/workflows/schema-update.yml index 949dac5c260497344969c182302baa0d968113bf..5bc1f29d91969f32757f9ad78f7742e7e20b7f3e 100644 --- a/.github/workflows/schema-update.yml +++ b/.github/workflows/schema-update.yml @@ -19,7 +19,7 @@ jobs: go-version-file: go.mod - run: go run . schema > ./schema.json - run: go generate ./internal/agent/hyper/... - - uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v5 + - uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v5 with: commit_message: "chore: auto-update files" branch: main From d2ef109b7f6ff620c589c484cee0e002f489f375 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Dec 2025 10:27:28 +0000 Subject: [PATCH 21/29] chore(deps): bump the all group with 2 updates (#1693) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 6 +++--- go.sum | 16 ++++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index bb4cc61ca228928d0f8fdd65b9741cd2d6253b7a..110c30ec3f74e0c78d5d34f6b0f3d53f41b6ae58 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ 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.20.0 + github.com/alecthomas/chroma/v2 v2.21.1 github.com/atotto/clipboard v0.1.4 github.com/aymanbagabas/go-udiff v0.3.1 github.com/bmatcuk/doublestar/v4 v4.9.1 @@ -38,7 +38,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.3.0 github.com/modelcontextprotocol/go-sdk v1.1.0 github.com/muesli/termenv v0.16.0 - github.com/ncruces/go-sqlite3 v0.30.3 + github.com/ncruces/go-sqlite3 v0.30.4 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/nxadm/tail v1.4.11 github.com/openai/openai-go/v2 v2.7.1 @@ -145,7 +145,7 @@ require ( github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect github.com/spf13/pflag v1.0.9 // indirect - github.com/tetratelabs/wazero v1.10.1 // indirect + github.com/tetratelabs/wazero v1.11.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/u-root/u-root v0.14.1-0.20250807200646-5e7721023dc7 // indirect diff --git a/go.sum b/go.sum index 2f3cb4fa2a48029e02ff2731daa374851e10fd3a..708abf9dee68a8e94601588100c41e0323525da6 100644 --- a/go.sum +++ b/go.sum @@ -39,10 +39,10 @@ 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.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= -github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= -github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= -github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA= +github.com/alecthomas/chroma/v2 v2.21.1/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= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= @@ -248,8 +248,8 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/ncruces/go-sqlite3 v0.30.3 h1:X/CgWW9GzmIAkEPrifhKqf0cC15DuOVxAJaHFTTAURQ= -github.com/ncruces/go-sqlite3 v0.30.3/go.mod h1:AxKu9sRxkludimFocbktlY6LiYSkxiI5gTA8r+os/Nw= +github.com/ncruces/go-sqlite3 v0.30.4 h1:j9hEoOL7f9ZoXl8uqXVniaq1VNwlWAXihZbTvhqPPjA= +github.com/ncruces/go-sqlite3 v0.30.4/go.mod h1:7WR20VSC5IZusKhUdiR9y1NsUqnZgqIYCmKKoMEYg68= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= @@ -310,8 +310,8 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8= -github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU= +github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= +github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= From b19db1d80cb7516afd2a4c143f9f416da6180a97 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Mon, 22 Dec 2025 13:07:55 -0300 Subject: [PATCH 22/29] chore(legal): @Mr777x-enf 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 d85df4b8fa7bebfce410b0f159d1073705ce2562..ed65cc52a8663e1555bd71d7b488a1bbce5e7a88 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -975,6 +975,14 @@ "created_at": "2025-12-19T12:14:08Z", "repoId": 987670088, "pullRequestNo": 1675 + }, + { + "name": "Mr777x-enf", + "id": 248610315, + "comment_id": 3682737876, + "created_at": "2025-12-22T16:07:47Z", + "repoId": 987670088, + "pullRequestNo": 1694 } ] } \ No newline at end of file From 9c34f3bd05c89e244be06a4ad22b3304a5fec629 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:59:19 -0300 Subject: [PATCH 24/29] chore(legal): @yuguorui 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 ed65cc52a8663e1555bd71d7b488a1bbce5e7a88..9c22e48d5f395ee5c0275deef9a7f4c10f877621 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -983,6 +983,14 @@ "created_at": "2025-12-22T16:07:47Z", "repoId": 987670088, "pullRequestNo": 1694 + }, + { + "name": "yuguorui", + "id": 6182414, + "comment_id": 3687495909, + "created_at": "2025-12-23T17:59:11Z", + "repoId": 987670088, + "pullRequestNo": 1709 } ] } \ No newline at end of file From 993aa22f09d92c578a9fb3a3e6b7b01922102888 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Sat, 27 Dec 2025 03:02:07 -0300 Subject: [PATCH 25/29] chore(legal): @aeroxy 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 9c22e48d5f395ee5c0275deef9a7f4c10f877621..546b43a957dd7823b0c9d96cddabbe299e7e8846 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -991,6 +991,14 @@ "created_at": "2025-12-23T17:59:11Z", "repoId": 987670088, "pullRequestNo": 1709 + }, + { + "name": "aeroxy", + "id": 2761307, + "comment_id": 3693734613, + "created_at": "2025-12-27T06:01:58Z", + "repoId": 987670088, + "pullRequestNo": 1723 } ] } \ No newline at end of file From 63a3d831881d76b9a4aab615ba8581498fcb5ae0 Mon Sep 17 00:00:00 2001 From: James Trew <66286082+jamestrew@users.noreply.github.com> Date: Sat, 27 Dec 2025 12:24:44 -0500 Subject: [PATCH 26/29] fix(tui): guard model selection when list is empty (#1715) --- internal/tui/components/dialogs/models/models.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go index 06da780edd48cf689113575d39e2ed5805fa27e2..afca44ecd5e64e42e3b375311d3c5ff8efaedd5b 100644 --- a/internal/tui/components/dialogs/models/models.go +++ b/internal/tui/components/dialogs/models/models.go @@ -199,6 +199,9 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { return m, m.copilotDeviceFlow.CopyCodeAndOpenURL() } selectedItem := m.modelList.SelectedModel() + if selectedItem == nil { + return m, nil + } modelType := config.SelectedModelTypeLarge if m.modelList.GetModelType() == SmallModelType { From 1fbe7d48900d8734d4a8c4aaffe734e95f2b786d Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Sat, 27 Dec 2025 21:10:04 +0100 Subject: [PATCH 27/29] feat: agent skills (#1690) Co-authored-by: Christian Rocha --- README.md | 43 +++++ go.mod | 2 +- internal/agent/common_test.go | 4 + internal/agent/coordinator.go | 2 +- internal/agent/prompt/prompt.go | 47 +++-- internal/agent/templates/coder.md.tpl | 10 ++ internal/agent/tools/view.go | 63 ++++++- internal/config/config.go | 1 + internal/config/load.go | 31 ++++ internal/skills/skills.go | 164 +++++++++++++++++ internal/skills/skills_test.go | 249 ++++++++++++++++++++++++++ 11 files changed, 591 insertions(+), 25 deletions(-) create mode 100644 internal/skills/skills.go create mode 100644 internal/skills/skills_test.go diff --git a/README.md b/README.md index c268cb7cedf4b80632dbd75458ad3db90900edf0..4d876ef648b8237a4e2a172c23acfe5e05ec386b 100644 --- a/README.md +++ b/README.md @@ -362,6 +362,49 @@ completely hidden from the agent. To disable tools from MCP servers, see the [MCP config section](#mcps). +### Agent Skills + +Crush supports the [Agent Skills](https://agentskills.io) open standard for +extending agent capabilities with reusable skill packages. Skills are folders +containing a `SKILL.md` file with instructions that Crush can discover and +activate on demand. + +Skills are discovered from: + +- `~/.config/crush/skills/` on Unix (default, can be overridden with `CRUSH_SKILLS_DIR`) +- `%LOCALAPPDATA%\crush\skills\` on Windows (default, can be overridden with `CRUSH_SKILLS_DIR`) +- Additional paths configured via `options.skills_paths` + +```jsonc +{ + "$schema": "https://charm.land/crush.json", + "options": { + "skills_paths": [ + "~/.config/crush/skills", // Windows: "%LOCALAPPDATA%\\crush\\skills", + "./project-skills" + ] + } +} +``` + +You can get started with example skills from [anthropics/skills](https://github.com/anthropics/skills): + +```bash +# Unix +mkdir -p ~/.config/crush/skills +cd ~/.config/crush/skills +git clone https://github.com/anthropics/skills.git _temp +mv _temp/skills/* . && rm -rf _temp +``` + +```powershell +# Windows (PowerShell) +mkdir -Force "$env:LOCALAPPDATA\crush\skills" +cd "$env:LOCALAPPDATA\crush\skills" +git clone https://github.com/anthropics/skills.git _temp +mv _temp/skills/* . ; rm -r -force _temp +``` + ### Initialization When you initialize a project, Crush analyzes your codebase and creates diff --git a/go.mod b/go.mod index 110c30ec3f74e0c78d5d34f6b0f3d53f41b6ae58..185494a12d021596c9100b2bebc553776ac0e8d0 100644 --- a/go.mod +++ b/go.mod @@ -61,6 +61,7 @@ require ( golang.org/x/sync v0.19.0 golang.org/x/text v0.32.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 + gopkg.in/yaml.v3 v3.0.1 mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5 mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5 ) @@ -177,5 +178,4 @@ 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 - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go index 0464af1def5661492a2b26af4ba75f0ae44c9e9c..bfe987ffb9a3bf73556b502724a115f41fcc6caf 100644 --- a/internal/agent/common_test.go +++ b/internal/agent/common_test.go @@ -178,6 +178,10 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel GeneratedWith: true, } + // Clear skills paths to ensure test reproducibility - user's skills + // would be included in prompt and break VCR cassette matching. + cfg.Options.SkillsPaths = []string{} + systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), *cfg) if err != nil { return nil, err diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index ef2bdfc9cd7671b43ba22ec8a02b77b7510e5518..363f5690c8868ebb95726d1f66f628f301abef91 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -390,7 +390,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls), tools.NewSourcegraphTool(nil), tools.NewTodosTool(c.sessions), - tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir()), + tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...), tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()), ) diff --git a/internal/agent/prompt/prompt.go b/internal/agent/prompt/prompt.go index d10fbcae3c3a37f295ec9f9de637cb130d9b6abc..d68c7c132116c49cd004bee52169be7487133efa 100644 --- a/internal/agent/prompt/prompt.go +++ b/internal/agent/prompt/prompt.go @@ -14,6 +14,7 @@ import ( "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/home" "github.com/charmbracelet/crush/internal/shell" + "github.com/charmbracelet/crush/internal/skills" ) // Prompt represents a template-based prompt generator. @@ -26,15 +27,16 @@ type Prompt struct { } type PromptDat struct { - Provider string - Model string - Config config.Config - WorkingDir string - IsGitRepo bool - Platform string - Date string - GitStatus string - ContextFiles []ContextFile + Provider string + Model string + Config config.Config + WorkingDir string + IsGitRepo bool + Platform string + Date string + GitStatus string + ContextFiles []ContextFile + AvailSkillXML string } type ContextFile struct { @@ -162,15 +164,28 @@ func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg con files[pathKey] = content } + // Discover and load skills metadata. + var availSkillXML string + if len(cfg.Options.SkillsPaths) > 0 { + expandedPaths := make([]string, 0, len(cfg.Options.SkillsPaths)) + for _, pth := range cfg.Options.SkillsPaths { + expandedPaths = append(expandedPaths, expandPath(pth, cfg)) + } + if discoveredSkills := skills.Discover(expandedPaths); len(discoveredSkills) > 0 { + availSkillXML = skills.ToPromptXML(discoveredSkills) + } + } + isGit := isGitRepo(cfg.WorkingDir()) data := PromptDat{ - Provider: provider, - Model: model, - Config: cfg, - WorkingDir: filepath.ToSlash(workingDir), - IsGitRepo: isGit, - Platform: platform, - Date: p.now().Format("1/2/2006"), + Provider: provider, + Model: model, + Config: cfg, + WorkingDir: filepath.ToSlash(workingDir), + IsGitRepo: isGit, + Platform: platform, + Date: p.now().Format("1/2/2006"), + AvailSkillXML: availSkillXML, } if isGit { var err error diff --git a/internal/agent/templates/coder.md.tpl b/internal/agent/templates/coder.md.tpl index 225e021efe5f08f2cfd68184d684af2cbd57684e..3e9476d4ee08e5025c8c83845afa79295891e164 100644 --- a/internal/agent/templates/coder.md.tpl +++ b/internal/agent/templates/coder.md.tpl @@ -360,6 +360,16 @@ Diagnostics (lint/typecheck) included in tool output. - Ignore issues in files you didn't touch (unless user asks) {{end}} +{{- if .AvailSkillXML}} + +{{.AvailSkillXML}} + + +When a user task matches a skill's description, read the skill's SKILL.md file to get full instructions. +Skills are activated by reading their location path. Follow the skill's instructions to complete the task. +If a skill mentions scripts, references, or assets, they are placed in the same folder as the skill itself (e.g., scripts/, references/, assets/ subdirectories within the skill's folder). + +{{end}} {{if .ContextFiles}} diff --git a/internal/agent/tools/view.go b/internal/agent/tools/view.go index aacd4cab23231c1b27f3d2589578e81e29cf6ed3..577fcad4dc0eaf65c46aec7e8c1e9a1b32c97062 100644 --- a/internal/agent/tools/view.go +++ b/internal/agent/tools/view.go @@ -38,6 +38,7 @@ type viewTool struct { lspClients *csync.Map[string, *lsp.Client] workingDir string permissions permission.Service + skillsPaths []string } type ViewResponseMetadata struct { @@ -52,7 +53,7 @@ const ( MaxLineLength = 2000 ) -func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string) fantasy.AgentTool { +func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string, skillsPaths ...string) fantasy.AgentTool { return fantasy.NewAgentTool( ViewToolName, string(viewDescription), @@ -76,8 +77,11 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss } relPath, err := filepath.Rel(absWorkingDir, absFilePath) - if err != nil || strings.HasPrefix(relPath, "..") { - // File is outside working directory, request permission + isOutsideWorkDir := err != nil || strings.HasPrefix(relPath, "..") + isSkillFile := isInSkillsPath(absFilePath, skillsPaths) + + // Request permission for files outside working directory, unless it's a skill file. + if isOutsideWorkDir && !isSkillFile { sessionID := GetSessionFromContext(ctx) if sessionID == "" { return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory") @@ -137,15 +141,19 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil } - // Check file size - if fileInfo.Size() > MaxReadSize { + // Based on the specifications we should not limit the skills read. + if !isSkillFile && fileInfo.Size() > MaxReadSize { return fantasy.NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes", fileInfo.Size(), MaxReadSize)), nil } - // Set default limit if not provided + // Set default limit if not provided (no limit for SKILL.md files) if params.Limit <= 0 { - params.Limit = DefaultReadLimit + if isSkillFile { + params.Limit = 1000000 // Effectively no limit for skill files + } else { + params.Limit = DefaultReadLimit + } } isSupportedImage, mimeType := getImageMimeType(filePath) @@ -315,3 +323,44 @@ func (s *LineScanner) Text() string { func (s *LineScanner) Err() error { return s.scanner.Err() } + +// isInSkillsPath checks if filePath is within any of the configured skills +// directories. Returns true for files that can be read without permission +// prompts and without size limits. +// +// Note that symlinks are resolved to prevent path traversal attacks via +// symbolic links. +func isInSkillsPath(filePath string, skillsPaths []string) bool { + if len(skillsPaths) == 0 { + return false + } + + absFilePath, err := filepath.Abs(filePath) + if err != nil { + return false + } + + evalFilePath, err := filepath.EvalSymlinks(absFilePath) + if err != nil { + return false + } + + for _, skillsPath := range skillsPaths { + absSkillsPath, err := filepath.Abs(skillsPath) + if err != nil { + continue + } + + evalSkillsPath, err := filepath.EvalSymlinks(absSkillsPath) + if err != nil { + continue + } + + relPath, err := filepath.Rel(evalSkillsPath, evalFilePath) + if err == nil && !strings.HasPrefix(relPath, "..") { + return true + } + } + + return false +} diff --git a/internal/config/config.go b/internal/config/config.go index 887e58b66d92c860c5d7fa9bc7a512b3853be4f4..e68ad8c27ca7e3c2313a3b18b48bcbedc3d677e9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -256,6 +256,7 @@ func (Attribution) JSONSchemaExtend(schema *jsonschema.Schema) { type Options struct { ContextPaths []string `json:"context_paths,omitempty" jsonschema:"description=Paths to files containing context information for the AI,example=.cursorrules,example=CRUSH.md"` + SkillsPaths []string `json:"skills_paths,omitempty" jsonschema:"description=Paths to directories containing Agent Skills (folders with SKILL.md files),example=~/.config/crush/skills,example=./skills"` TUI *TUIOptions `json:"tui,omitempty" jsonschema:"description=Terminal user interface options"` Debug bool `json:"debug,omitempty" jsonschema:"description=Enable debug logging,default=false"` DebugLSP bool `json:"debug_lsp,omitempty" jsonschema:"description=Enable debug logging for LSP servers,default=false"` diff --git a/internal/config/load.go b/internal/config/load.go index 27866e5891afc8774cb5d5ac2c8fd4f979161e2f..b16df0ee76d66e08e0a2e51862b8c5846100dafb 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -329,6 +329,9 @@ func (c *Config) setDefaults(workingDir, dataDir string) { if c.Options.ContextPaths == nil { c.Options.ContextPaths = []string{} } + if c.Options.SkillsPaths == nil { + c.Options.SkillsPaths = []string{} + } if dataDir != "" { c.Options.DataDirectory = dataDir } else if c.Options.DataDirectory == "" { @@ -362,6 +365,12 @@ func (c *Config) setDefaults(workingDir, dataDir string) { slices.Sort(c.Options.ContextPaths) c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths) + // Add the default skills directory if not already present. + defaultSkillsDir := GlobalSkillsDir() + if !slices.Contains(c.Options.SkillsPaths, defaultSkillsDir) { + c.Options.SkillsPaths = append([]string{defaultSkillsDir}, c.Options.SkillsPaths...) + } + if str, ok := os.LookupEnv("CRUSH_DISABLE_PROVIDER_AUTO_UPDATE"); ok { c.Options.DisableProviderAutoUpdate, _ = strconv.ParseBool(str) } @@ -736,3 +745,25 @@ func isInsideWorktree() bool { ).CombinedOutput() return err == nil && strings.TrimSpace(string(bts)) == "true" } + +// GlobalSkillsDir returns the default directory for Agent Skills. +// Skills in this directory are auto-discovered and their files can be read +// without permission prompts. +func GlobalSkillsDir() string { + if crushSkills := os.Getenv("CRUSH_SKILLS_DIR"); crushSkills != "" { + return crushSkills + } + if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { + return filepath.Join(xdgConfigHome, appName, "skills") + } + + if runtime.GOOS == "windows" { + localAppData := cmp.Or( + os.Getenv("LOCALAPPDATA"), + filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"), + ) + return filepath.Join(localAppData, appName, "skills") + } + + return filepath.Join(home.Dir(), ".config", appName, "skills") +} diff --git a/internal/skills/skills.go b/internal/skills/skills.go new file mode 100644 index 0000000000000000000000000000000000000000..384f589d423b0855b27c985f0914049e17135393 --- /dev/null +++ b/internal/skills/skills.go @@ -0,0 +1,164 @@ +// Package skills implements the Agent Skills open standard. +// See https://agentskills.io for the specification. +package skills + +import ( + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "regexp" + "strings" + + "gopkg.in/yaml.v3" +) + +const ( + SkillFileName = "SKILL.md" + MaxNameLength = 64 + MaxDescriptionLength = 1024 + MaxCompatibilityLength = 500 +) + +var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`) + +// Skill represents a parsed SKILL.md file. +type Skill struct { + Name string `yaml:"name" json:"name"` + Description string `yaml:"description" json:"description"` + License string `yaml:"license,omitempty" json:"license,omitempty"` + Compatibility string `yaml:"compatibility,omitempty" json:"compatibility,omitempty"` + Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"` + Instructions string `yaml:"-" json:"instructions"` + Path string `yaml:"-" json:"path"` + SkillFilePath string `yaml:"-" json:"skill_file_path"` +} + +// Validate checks if the skill meets spec requirements. +func (s *Skill) Validate() error { + var errs []error + + if s.Name == "" { + errs = append(errs, errors.New("name is required")) + } else { + if len(s.Name) > MaxNameLength { + errs = append(errs, fmt.Errorf("name exceeds %d characters", MaxNameLength)) + } + if !namePattern.MatchString(s.Name) { + errs = append(errs, errors.New("name must be alphanumeric with hyphens, no leading/trailing/consecutive hyphens")) + } + if s.Path != "" && !strings.EqualFold(filepath.Base(s.Path), s.Name) { + errs = append(errs, fmt.Errorf("name %q must match directory %q", s.Name, filepath.Base(s.Path))) + } + } + + if s.Description == "" { + errs = append(errs, errors.New("description is required")) + } else if len(s.Description) > MaxDescriptionLength { + errs = append(errs, fmt.Errorf("description exceeds %d characters", MaxDescriptionLength)) + } + + if len(s.Compatibility) > MaxCompatibilityLength { + errs = append(errs, fmt.Errorf("compatibility exceeds %d characters", MaxCompatibilityLength)) + } + + return errors.Join(errs...) +} + +// Parse parses a SKILL.md file. +func Parse(path string) (*Skill, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + frontmatter, body, err := splitFrontmatter(string(content)) + if err != nil { + return nil, err + } + + var skill Skill + if err := yaml.Unmarshal([]byte(frontmatter), &skill); err != nil { + return nil, fmt.Errorf("parsing frontmatter: %w", err) + } + + skill.Instructions = strings.TrimSpace(body) + skill.Path = filepath.Dir(path) + skill.SkillFilePath = path + + return &skill, nil +} + +// splitFrontmatter extracts YAML frontmatter and body from markdown content. +func splitFrontmatter(content string) (frontmatter, body string, err error) { + // Normalize line endings to \n for consistent parsing. + content = strings.ReplaceAll(content, "\r\n", "\n") + if !strings.HasPrefix(content, "---\n") { + return "", "", errors.New("no YAML frontmatter found") + } + + rest := strings.TrimPrefix(content, "---\n") + before, after, ok := strings.Cut(rest, "\n---") + if !ok { + return "", "", errors.New("unclosed frontmatter") + } + + return before, after, nil +} + +// Discover finds all valid skills in the given paths. +func Discover(paths []string) []*Skill { + var skills []*Skill + seen := make(map[string]bool) + + for _, base := range paths { + filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() || d.Name() != SkillFileName || seen[path] { + return nil + } + seen[path] = true + skill, err := Parse(path) + if err != nil { + slog.Warn("Failed to parse skill file", "path", path, "error", err) + return nil + } + if err := skill.Validate(); err != nil { + slog.Warn("Skill validation failed", "path", path, "error", err) + return nil + } + slog.Info("Successfully loaded skill", "name", skill.Name, "path", path) + skills = append(skills, skill) + return nil + }) + } + + return skills +} + +// ToPromptXML generates XML for injection into the system prompt. +func ToPromptXML(skills []*Skill) string { + if len(skills) == 0 { + return "" + } + + var sb strings.Builder + sb.WriteString("\n") + for _, s := range skills { + sb.WriteString(" \n") + fmt.Fprintf(&sb, " %s\n", escape(s.Name)) + fmt.Fprintf(&sb, " %s\n", escape(s.Description)) + fmt.Fprintf(&sb, " %s\n", escape(s.SkillFilePath)) + sb.WriteString(" \n") + } + sb.WriteString("") + return sb.String() +} + +func escape(s string) string { + r := strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """, "'", "'") + return r.Replace(s) +} diff --git a/internal/skills/skills_test.go b/internal/skills/skills_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f90d7cb341d85e85945efc06f46dd372a7cf4725 --- /dev/null +++ b/internal/skills/skills_test.go @@ -0,0 +1,249 @@ +package skills + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + wantName string + wantDesc string + wantLicense string + wantCompat string + wantMeta map[string]string + wantTools string + wantInstr string + wantErr bool + }{ + { + name: "full skill", + content: `--- +name: pdf-processing +description: Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs. +license: Apache-2.0 +compatibility: Requires python 3.8+, pdfplumber, pdfrw libraries +metadata: + author: example-org + version: "1.0" +--- + +# PDF Processing + +## When to use this skill +Use this skill when the user needs to work with PDF files. +`, + wantName: "pdf-processing", + wantDesc: "Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs.", + wantLicense: "Apache-2.0", + wantCompat: "Requires python 3.8+, pdfplumber, pdfrw libraries", + wantMeta: map[string]string{"author": "example-org", "version": "1.0"}, + wantInstr: "# PDF Processing\n\n## When to use this skill\nUse this skill when the user needs to work with PDF files.", + }, + { + name: "minimal skill", + content: `--- +name: my-skill +description: A simple skill for testing. +--- + +# My Skill + +Instructions here. +`, + wantName: "my-skill", + wantDesc: "A simple skill for testing.", + wantInstr: "# My Skill\n\nInstructions here.", + }, + { + name: "no frontmatter", + content: "# Just Markdown\n\nNo frontmatter here.", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Write content to temp file. + dir := t.TempDir() + path := filepath.Join(dir, "SKILL.md") + require.NoError(t, os.WriteFile(path, []byte(tt.content), 0o644)) + + skill, err := Parse(path) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + require.Equal(t, tt.wantName, skill.Name) + require.Equal(t, tt.wantDesc, skill.Description) + require.Equal(t, tt.wantLicense, skill.License) + require.Equal(t, tt.wantCompat, skill.Compatibility) + + if tt.wantMeta != nil { + require.Equal(t, tt.wantMeta, skill.Metadata) + } + + require.Equal(t, tt.wantInstr, skill.Instructions) + }) + } +} + +func TestSkillValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + skill Skill + wantErr bool + errMsg string + }{ + { + name: "valid skill", + skill: Skill{ + Name: "pdf-processing", + Description: "Processes PDF files.", + Path: "/skills/pdf-processing", + }, + }, + { + name: "missing name", + skill: Skill{Description: "Some description."}, + wantErr: true, + errMsg: "name is required", + }, + { + name: "missing description", + skill: Skill{Name: "my-skill", Path: "/skills/my-skill"}, + wantErr: true, + errMsg: "description is required", + }, + { + name: "name too long", + skill: Skill{Name: strings.Repeat("a", 65), Description: "Some description."}, + wantErr: true, + errMsg: "exceeds", + }, + { + name: "valid name - mixed case", + skill: Skill{Name: "MySkill", Description: "Some description.", Path: "/skills/MySkill"}, + wantErr: false, + }, + { + name: "invalid name - starts with hyphen", + skill: Skill{Name: "-my-skill", Description: "Some description."}, + wantErr: true, + errMsg: "alphanumeric with hyphens", + }, + { + name: "name doesn't match directory", + skill: Skill{Name: "my-skill", Description: "Some description.", Path: "/skills/other-skill"}, + wantErr: true, + errMsg: "must match directory", + }, + { + name: "description too long", + skill: Skill{Name: "my-skill", Description: strings.Repeat("a", 1025), Path: "/skills/my-skill"}, + wantErr: true, + errMsg: "description exceeds", + }, + { + name: "compatibility too long", + skill: Skill{Name: "my-skill", Description: "desc", Compatibility: strings.Repeat("a", 501), Path: "/skills/my-skill"}, + wantErr: true, + errMsg: "compatibility exceeds", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := tt.skill.Validate() + if tt.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestDiscover(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + + // Create valid skill 1. + skill1Dir := filepath.Join(tmpDir, "skill-one") + require.NoError(t, os.MkdirAll(skill1Dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skill1Dir, "SKILL.md"), []byte(`--- +name: skill-one +description: First test skill. +--- +# Skill One +`), 0o644)) + + // Create valid skill 2 in nested directory. + skill2Dir := filepath.Join(tmpDir, "nested", "skill-two") + require.NoError(t, os.MkdirAll(skill2Dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skill2Dir, "SKILL.md"), []byte(`--- +name: skill-two +description: Second test skill. +--- +# Skill Two +`), 0o644)) + + // Create invalid skill (won't be included). + invalidDir := filepath.Join(tmpDir, "invalid-dir") + require.NoError(t, os.MkdirAll(invalidDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(invalidDir, "SKILL.md"), []byte(`--- +name: wrong-name +description: Name doesn't match directory. +--- +`), 0o644)) + + skills := Discover([]string{tmpDir}) + require.Len(t, skills, 2) + + names := make(map[string]bool) + for _, s := range skills { + names[s.Name] = true + } + require.True(t, names["skill-one"]) + require.True(t, names["skill-two"]) +} + +func TestToPromptXML(t *testing.T) { + t.Parallel() + + skills := []*Skill{ + {Name: "pdf-processing", Description: "Extracts text from PDFs.", SkillFilePath: "/skills/pdf-processing/SKILL.md"}, + {Name: "data-analysis", Description: "Analyzes datasets & charts.", SkillFilePath: "/skills/data-analysis/SKILL.md"}, + } + + xml := ToPromptXML(skills) + + require.Contains(t, xml, "") + require.Contains(t, xml, "pdf-processing") + require.Contains(t, xml, "Extracts text from PDFs.") + require.Contains(t, xml, "&") // XML escaping +} + +func TestToPromptXMLEmpty(t *testing.T) { + t.Parallel() + require.Empty(t, ToPromptXML(nil)) + require.Empty(t, ToPromptXML([]*Skill{})) +} From 67e8f140fab298698c7b15d41a95097256551ac3 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Sat, 27 Dec 2025 20:11:11 +0000 Subject: [PATCH 28/29] chore: auto-update files --- schema.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/schema.json b/schema.json index 974200854d28bb300c94613328485ea5a3e2165d..a2d88bfd5f4be210534209694a9f0c0eb5c993c0 100644 --- a/schema.json +++ b/schema.json @@ -367,6 +367,17 @@ "type": "array", "description": "Paths to files containing context information for the AI" }, + "skills_paths": { + "items": { + "type": "string", + "examples": [ + "~/.config/crush/skills", + "./skills" + ] + }, + "type": "array", + "description": "Paths to directories containing Agent Skills (folders with SKILL.md files)" + }, "tui": { "$ref": "#/$defs/TUIOptions", "description": "Terminal user interface options" From 59e4adc2fb034f51e0757a7ee94642196a758977 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sat, 27 Dec 2025 12:19:19 -0800 Subject: [PATCH 29/29] chore: minor internal/app improvements (#1696) --- internal/app/app.go | 4 ++-- internal/app/lsp.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 7e64eaf7bbd7fbc6d32935a26b284128ffb5445f..436579d0b9593a1f9fd36606ae1e1b81fd89e737 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -69,7 +69,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { messages := message.NewService(q) files := history.NewService(q, conn) skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests - allowedTools := []string{} + var allowedTools []string if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil { allowedTools = cfg.Permissions.AllowedTools } @@ -415,7 +415,7 @@ func (app *App) Shutdown() { }) } - // Call call cleanup functions. + // Call all cleanup functions. for _, cleanup := range app.cleanupFuncs { if cleanup != nil { wg.Go(func() { diff --git a/internal/app/lsp.go b/internal/app/lsp.go index 1a1ac86ca9ab35040e274b1c1f9b0ff9bb35a17e..dfebfe565d96ed2798e1e89cb7e82aaa7b78c13f 100644 --- a/internal/app/lsp.go +++ b/internal/app/lsp.go @@ -38,7 +38,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config // Create LSP client. lspClient, err := lsp.New(ctx, name, config, app.config.Resolver()) if err != nil { - slog.Error("Failed to create LSP client for", name, err) + slog.Error("Failed to create LSP client for", "name", name, "error", err) updateLSPState(name, lsp.StateError, err, nil, 0) return }