on main help sign in
feat: add support for claude code max (#1514)

Co-authored-by: Raphael Amorim <rapha850@gmail.com> Co-authored-by: Christian Rocha <christian@rocha.is>

commit191a6c803e51093faa45af6c1352411dc48bc1db authorAndrey Nering <andreynering@users.noreply.github.com> dateWed 26 Nov 2025, 11:38 (1 year ago) parent303bf8c
18 files changed+1219−72
README.md−11 go.mod+2−1 go.sum+3−2 internal/agent/agent.go+19−2 internal/config/config.go+48−8 internal/config/load.go+28 internal/oauth/claude/challenge.go+28 internal/oauth/claude/oauth.go+126 internal/oauth/token.go+23 internal/tui/components/chat/splash/keys.go+6−1 internal/tui/components/chat/splash/splash.go+231−17 internal/tui/components/dialogs/claude/method.go+115 internal/tui/components/dialogs/claude/oauth.go+267 internal/tui/components/dialogs/models/keys.go+66 internal/tui/components/dialogs/models/models.go+170−29 internal/tui/page/chat/chat.go+73 internal/tui/styles/charmtone.go+7 internal/tui/styles/theme.go+7−1
README.md−11
@@ -664,17 +664,6 @@ Or by setting the following in your config:
664664Crush also respects the [`DO_NOT_TRACK`](https://consoledonottrack.com)
665665convention which can be enabled via `export DO_NOT_TRACK=1`.
666666
667−## A Note on Claude Max and GitHub Copilot
668−
669−Crush only supports model providers through official, compliant APIs. We do not
670−support or endorse any methods that rely on personal Claude Max and GitHub
671−Copilot accounts or OAuth workarounds, which violate Anthropic and
672−Microsoft’s Terms of Service.
673−
674−We’re committed to building sustainable, trusted integrations with model
675−providers. If you’re a provider interested in working with us,
676−[reach out](mailto:vt100@charm.sh).
677−
678667## Contributing
679668
680669See the [contributing guide](https://github.com/charmbracelet/crush?tab=contributing-ov-file#contributing).
go.mod+2−1
@@ -6,7 +6,7 @@ require (
66 charm.land/bubbles/v2 v2.0.0-rc.1
77 charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251124184313-5de0f1f67562
88 charm.land/fantasy v0.3.2
9− charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410
9+ charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251119143523-0334bb4562ca
1010 charm.land/x/vcr v0.1.1
1111 github.com/JohannesKaufmann/html-to-markdown v1.6.0
1212 github.com/MakeNowJust/heredoc v1.0.0
@@ -41,6 +41,7 @@ require (
4141 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
4242 github.com/nxadm/tail v1.4.11
4343 github.com/openai/openai-go/v2 v2.7.1
44+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
4445 github.com/posthog/posthog-go v1.6.12
4546 github.com/pressly/goose/v3 v3.26.0
4647 github.com/qjebbs/go-jsons v1.0.0-alpha.4
go.sum+3−2
@@ -4,8 +4,8 @@ charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251124184313-5de0f1f67562 h1:61aovinon0n
44charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251124184313-5de0f1f67562/go.mod h1:IXFmnCnMLTWw/KQ9rEatSYqbAPAYi8kA3Yqwa1SFnLk=
55charm.land/fantasy v0.3.2 h1:yHTsSZ25LcICMRw3xzdz3OkaZtDQch+B5ljJo17HxgU=
66charm.land/fantasy v0.3.2/go.mod h1:sV8Ns/JTJHOaYOHPgVRDugMheAyxsW/nmdpVGrycYEk=
7−charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k=
8−charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU=
7+charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251119143523-0334bb4562ca h1:6bVc8OFotCS4sS7HKqxTudP7yn8Y0ODR6df2pdlY/+s=
8+charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251119143523-0334bb4562ca/go.mod h1:XSJjv7DaH4zd1Y27kZis295RkEj9OFR9zh2WffQQsKQ=
99charm.land/x/vcr v0.1.1 h1:PXCFMUG0rPtyk35rhfzYCJEduOzWXCIbrXTFq4OF/9Q=
1010charm.land/x/vcr v0.1.1/go.mod h1:eByq2gqzWvcct/8XE2XO5KznoWEBiXH56+y2gphbltM=
1111cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
@@ -408,6 +408,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
408408golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
409409golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
410410golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
411+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
411412golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
412413golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
413414golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
internal/agent/agent.go+19−2
@@ -238,8 +238,8 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
238238 }
239239 }
240240
241− if a.systemPromptPrefix != "" {
242− prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(a.systemPromptPrefix)}, prepared.Messages...)
241+ if promptPrefix := a.promptPrefix(); promptPrefix != "" {
242+ prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(promptPrefix)}, prepared.Messages...)
243243 }
244244
245245 var assistantMsg message.Message
@@ -789,6 +789,10 @@ func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session,
789789 modelConfig.CostPer1MIn/1e6*float64(usage.InputTokens) +
790790 modelConfig.CostPer1MOut/1e6*float64(usage.OutputTokens)
791791
792+ if a.isClaudeCode() {
793+ cost = 0
794+ }
795+
792796 a.eventTokensUsed(session.ID, model, usage, cost)
793797
794798 if overrideCost != nil {
@@ -882,3 +886,16 @@ func (a *sessionAgent) SetTools(tools []fantasy.AgentTool) {
882886func (a *sessionAgent) Model() Model {
883887 return a.largeModel
884888}
889+
890+func (a *sessionAgent) promptPrefix() string {
891+ if a.isClaudeCode() {
892+ return "You are Claude Code, Anthropic's official CLI for Claude."
893+ }
894+ return a.systemPromptPrefix
895+}
896+
897+func (a *sessionAgent) isClaudeCode() bool {
898+ cfg := config.Get()
899+ pc, ok := cfg.Providers.Get(a.largeModel.ModelCfg.Provider)
900+ return ok && pc.ID == string(catwalk.InferenceProviderAnthropic) && pc.OAuthToken != nil
901+}
internal/config/config.go+48−8
@@ -1,6 +1,7 @@
11package config
22
33import (
4+ "cmp"
45 "context"
56 "fmt"
67 "log/slog"
@@ -14,6 +15,7 @@ import (
1415 "github.com/charmbracelet/catwalk/pkg/catwalk"
1516 "github.com/charmbracelet/crush/internal/csync"
1617 "github.com/charmbracelet/crush/internal/env"
18+ "github.com/charmbracelet/crush/internal/oauth"
1719 "github.com/invopop/jsonschema"
1820 "github.com/tidwall/sjson"
1921)
@@ -92,6 +94,8 @@ type ProviderConfig struct {
9294 Type catwalk.Type `json:"type,omitempty" jsonschema:"description=Provider type that determines the API format,enum=openai,enum=openai-compat,enum=anthropic,enum=gemini,enum=azure,enum=vertexai,default=openai"`
9395 // The provider's API key.
9496 APIKey string `json:"api_key,omitempty" jsonschema:"description=API key for authentication with the provider,example=$OPENAI_API_KEY"`
97+ // OAuthToken for providers that use OAuth2 authentication.
98+ OAuthToken *oauth.Token `json:"oauth,omitempty" jsonschema:"description=OAuth2 token for authentication with the provider"`
9599 // Marks the provider as disabled.
96100 Disable bool `json:"disable,omitempty" jsonschema:"description=Whether this provider is disabled,default=false"`
97101
@@ -112,6 +116,24 @@ type ProviderConfig struct {
112116 Models []catwalk.Model `json:"models,omitempty" jsonschema:"description=List of models available from this provider"`
113117}
114118
119+func (pc *ProviderConfig) SetupClaudeCode() {
120+ if !strings.HasPrefix(pc.APIKey, "Bearer ") {
121+ pc.APIKey = fmt.Sprintf("Bearer %s", pc.APIKey)
122+ }
123+ pc.SystemPromptPrefix = "You are Claude Code, Anthropic's official CLI for Claude."
124+ pc.ExtraHeaders["anthropic-version"] = "2023-06-01"
125+
126+ value := pc.ExtraHeaders["anthropic-beta"]
127+ const want = "oauth-2025-04-20"
128+ if !strings.Contains(value, want) {
129+ if value != "" {
130+ value += ","
131+ }
132+ value += want
133+ }
134+ pc.ExtraHeaders["anthropic-beta"] = value
135+}
136+
115137type MCPType string
116138
117139const (
@@ -448,16 +470,34 @@ func (c *Config) SetConfigField(key string, value any) error {
448470 return nil
449471}
450472
451−func (c *Config) SetProviderAPIKey(providerID, apiKey string) error {
452− // First save to the config file
453− err := c.SetConfigField("providers."+providerID+".api_key", apiKey)
454− if err != nil {
455− return fmt.Errorf("failed to save API key to config file: %w", err)
473+func (c *Config) SetProviderAPIKey(providerID string, apiKey any) error {
474+ var providerConfig ProviderConfig
475+ var exists bool
476+ var setKeyOrToken func()
477+
478+ switch v := apiKey.(type) {
479+ case string:
480+ if err := c.SetConfigField(fmt.Sprintf("providers.%s.api_key", providerID), v); err != nil {
481+ return fmt.Errorf("failed to save api key to config file: %w", err)
482+ }
483+ setKeyOrToken = func() { providerConfig.APIKey = v }
484+ case *oauth.Token:
485+ if err := cmp.Or(
486+ c.SetConfigField(fmt.Sprintf("providers.%s.api_key", providerID), v.AccessToken),
487+ c.SetConfigField(fmt.Sprintf("providers.%s.oauth", providerID), v),
488+ ); err != nil {
489+ return err
490+ }
491+ setKeyOrToken = func() {
492+ providerConfig.APIKey = v.AccessToken
493+ providerConfig.OAuthToken = v
494+ providerConfig.SetupClaudeCode()
495+ }
456496 }
457497
458− providerConfig, exists := c.Providers.Get(providerID)
498+ providerConfig, exists = c.Providers.Get(providerID)
459499 if exists {
460− providerConfig.APIKey = apiKey
500+ setKeyOrToken()
461501 c.Providers.Set(providerID, providerConfig)
462502 return nil
463503 }
@@ -477,12 +517,12 @@ func (c *Config) SetProviderAPIKey(providerID, apiKey string) error {
477517 Name: foundProvider.Name,
478518 BaseURL: foundProvider.APIEndpoint,
479519 Type: foundProvider.Type,
480− APIKey: apiKey,
481520 Disable: false,
482521 ExtraHeaders: make(map[string]string),
483522 ExtraParams: make(map[string]string),
484523 Models: foundProvider.Models,
485524 }
525+ setKeyOrToken()
486526 } else {
487527 return fmt.Errorf("provider with ID %s not found in known providers", providerID)
488528 }
internal/config/load.go+28
@@ -1,6 +1,7 @@
11package config
22
33import (
4+ "cmp"
45 "context"
56 "encoding/json"
67 "fmt"
@@ -18,9 +19,11 @@ import (
1819 "github.com/charmbracelet/catwalk/pkg/catwalk"
1920 "github.com/charmbracelet/crush/internal/csync"
2021 "github.com/charmbracelet/crush/internal/env"
22+ "github.com/charmbracelet/crush/internal/event"
2123 "github.com/charmbracelet/crush/internal/fsext"
2224 "github.com/charmbracelet/crush/internal/home"
2325 "github.com/charmbracelet/crush/internal/log"
26+ "github.com/charmbracelet/crush/internal/oauth/claude"
2427 powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
2528)
2629
@@ -133,6 +136,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
133136 knownProviderNames := make(map[string]bool)
134137 restore := PushPopCrushEnv()
135138 defer restore()
139+
136140 for _, p := range knownProviders {
137141 knownProviderNames[string(p.ID)] = true
138142 config, configExists := c.Providers.Get(string(p.ID))
@@ -185,6 +189,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
185189 Name: p.Name,
186190 BaseURL: p.APIEndpoint,
187191 APIKey: p.APIKey,
192+ OAuthToken: config.OAuthToken,
188193 Type: p.Type,
189194 Disable: config.Disable,
190195 SystemPromptPrefix: config.SystemPromptPrefix,
@@ -194,6 +199,29 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
194199 Models: p.Models,
195200 }
196201
202+ if p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil {
203+ if config.OAuthToken.IsExpired() {
204+ newToken, err := claude.RefreshToken(context.TODO(), config.OAuthToken.RefreshToken)
205+ if err == nil {
206+ slog.Info("Successfully refreshed Anthropic OAuth token")
207+ config.OAuthToken = newToken
208+ prepared.OAuthToken = newToken
209+ if err := cmp.Or(
210+ c.SetConfigField("providers.anthropic.api_key", newToken.AccessToken),
211+ c.SetConfigField("providers.anthropic.oauth", newToken),
212+ ); err != nil {
213+ return err
214+ }
215+ } else {
216+ slog.Error("Failed to refresh Anthropic OAuth token", "error", err)
217+ event.Error(err)
218+ }
219+ } else {
220+ slog.Info("Using existing non-expired Anthropic OAuth token")
221+ }
222+ prepared.SetupClaudeCode()
223+ }
224+
197225 switch p.ID {
198226 // Handle specific providers that require additional configuration
199227 case catwalk.InferenceProviderVertexAI:
internal/oauth/claude/challenge.gonew file+28
@@ -0,0 +1,28 @@
1+package claude
2+
3+import (
4+ "crypto/rand"
5+ "crypto/sha256"
6+ "encoding/base64"
7+ "strings"
8+)
9+
10+// GetChallenge generates a PKCE verifier and its corresponding challenge.
11+func GetChallenge() (verifier string, challenge string, err error) {
12+ bytes := make([]byte, 32)
13+ if _, err := rand.Read(bytes); err != nil {
14+ return "", "", err
15+ }
16+ verifier = encodeBase64(bytes)
17+ hash := sha256.Sum256([]byte(verifier))
18+ challenge = encodeBase64(hash[:])
19+ return verifier, challenge, nil
20+}
21+
22+func encodeBase64(input []byte) (encoded string) {
23+ encoded = base64.StdEncoding.EncodeToString(input)
24+ encoded = strings.ReplaceAll(encoded, "=", "")
25+ encoded = strings.ReplaceAll(encoded, "+", "-")
26+ encoded = strings.ReplaceAll(encoded, "/", "_")
27+ return encoded
28+}
internal/oauth/claude/oauth.gonew file+126
@@ -0,0 +1,126 @@
1+package claude
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "net/url"
11+ "strings"
12+ "time"
13+
14+ "github.com/charmbracelet/crush/internal/oauth"
15+)
16+
17+const clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
18+
19+// AuthorizeURL returns the Claude Code Max OAuth2 authorization URL.
20+func AuthorizeURL(verifier, challenge string) (string, error) {
21+ u, err := url.Parse("https://claude.ai/oauth/authorize")
22+ if err != nil {
23+ return "", err
24+ }
25+ q := u.Query()
26+ q.Set("response_type", "code")
27+ q.Set("client_id", clientId)
28+ q.Set("redirect_uri", "https://console.anthropic.com/oauth/code/callback")
29+ q.Set("scope", "org:create_api_key user:profile user:inference")
30+ q.Set("code_challenge", challenge)
31+ q.Set("code_challenge_method", "S256")
32+ q.Set("state", verifier)
33+ u.RawQuery = q.Encode()
34+ return u.String(), nil
35+}
36+
37+// ExchangeToken exchanges the authorization code for an OAuth2 token.
38+func ExchangeToken(ctx context.Context, code, verifier string) (*oauth.Token, error) {
39+ code = strings.TrimSpace(code)
40+ parts := strings.SplitN(code, "#", 2)
41+ pure := parts[0]
42+ state := ""
43+ if len(parts) > 1 {
44+ state = parts[1]
45+ }
46+
47+ reqBody := map[string]string{
48+ "code": pure,
49+ "state": state,
50+ "grant_type": "authorization_code",
51+ "client_id": clientId,
52+ "redirect_uri": "https://console.anthropic.com/oauth/code/callback",
53+ "code_verifier": verifier,
54+ }
55+
56+ resp, err := request(ctx, "POST", "https://console.anthropic.com/v1/oauth/token", reqBody)
57+ if err != nil {
58+ return nil, err
59+ }
60+ defer resp.Body.Close()
61+
62+ body, err := io.ReadAll(resp.Body)
63+ if err != nil {
64+ return nil, err
65+ }
66+
67+ if resp.StatusCode != http.StatusOK {
68+ return nil, fmt.Errorf("claude code max: failed to exchange token: status %d body %q", resp.StatusCode, string(body))
69+ }
70+
71+ var token oauth.Token
72+ if err := json.Unmarshal(body, &token); err != nil {
73+ return nil, err
74+ }
75+ token.SetExpiresAt()
76+ return &token, nil
77+}
78+
79+// RefreshToken refreshes the OAuth2 token using the provided refresh token.
80+func RefreshToken(ctx context.Context, refreshToken string) (*oauth.Token, error) {
81+ reqBody := map[string]string{
82+ "grant_type": "refresh_token",
83+ "refresh_token": refreshToken,
84+ "client_id": clientId,
85+ }
86+
87+ resp, err := request(ctx, "POST", "https://console.anthropic.com/v1/oauth/token", reqBody)
88+ if err != nil {
89+ return nil, err
90+ }
91+ defer resp.Body.Close()
92+
93+ body, err := io.ReadAll(resp.Body)
94+ if err != nil {
95+ return nil, err
96+ }
97+
98+ if resp.StatusCode != http.StatusOK {
99+ return nil, fmt.Errorf("claude code max: failed to refresh token: status %d body %q", resp.StatusCode, string(body))
100+ }
101+
102+ var token oauth.Token
103+ if err := json.Unmarshal(body, &token); err != nil {
104+ return nil, err
105+ }
106+ token.SetExpiresAt()
107+ return &token, nil
108+}
109+
110+func request(ctx context.Context, method, url string, body any) (*http.Response, error) {
111+ date, err := json.Marshal(body)
112+ if err != nil {
113+ return nil, err
114+ }
115+
116+ req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(date))
117+ if err != nil {
118+ return nil, err
119+ }
120+
121+ req.Header.Set("Content-Type", "application/json")
122+ req.Header.Set("User-Agent", "anthropic")
123+
124+ client := &http.Client{Timeout: 30 * time.Second}
125+ return client.Do(req)
126+}
internal/oauth/token.gonew file+23
@@ -0,0 +1,23 @@
1+package oauth
2+
3+import (
4+ "time"
5+)
6+
7+// Token represents an OAuth2 token from Claude Code Max.
8+type Token struct {
9+ AccessToken string `json:"access_token"`
10+ RefreshToken string `json:"refresh_token"`
11+ ExpiresIn int `json:"expires_in"`
12+ ExpiresAt int64 `json:"expires_at"`
13+}
14+
15+// SetExpiresAt calculates and sets the ExpiresAt field based on the current time and ExpiresIn.
16+func (t *Token) SetExpiresAt() {
17+ t.ExpiresAt = time.Now().Add(time.Duration(t.ExpiresIn) * time.Second).Unix()
18+}
19+
20+// IsExpired checks if the token is expired or about to expire (within 10% of its lifetime).
21+func (t *Token) IsExpired() bool {
22+ return time.Now().Unix() >= (t.ExpiresAt - int64(t.ExpiresIn)/10)
23+}
internal/tui/components/chat/splash/keys.go+6−1
@@ -12,7 +12,8 @@ type KeyMap struct {
1212 No,
1313 Tab,
1414 LeftRight,
15− Back key.Binding
15+ Back,
16+ Copy key.Binding
1617}
1718
1819func DefaultKeyMap() KeyMap {
@@ -49,5 +50,9 @@ func DefaultKeyMap() KeyMap {
4950 key.WithKeys("esc", "alt+esc"),
5051 key.WithHelp("esc", "back"),
5152 ),
53+ Copy: key.NewBinding(
54+ key.WithKeys("c"),
55+ key.WithHelp("c", "copy url"),
56+ ),
5257 }
5358}
internal/tui/components/chat/splash/splash.go+231−17
@@ -9,6 +9,7 @@ import (
99 "charm.land/bubbles/v2/spinner"
1010 tea "charm.land/bubbletea/v2"
1111 "charm.land/lipgloss/v2"
12+ "github.com/atotto/clipboard"
1213 "github.com/charmbracelet/catwalk/pkg/catwalk"
1314 "github.com/charmbracelet/crush/internal/agent"
1415 "github.com/charmbracelet/crush/internal/config"
@@ -16,6 +17,7 @@ import (
1617 "github.com/charmbracelet/crush/internal/tui/components/chat"
1718 "github.com/charmbracelet/crush/internal/tui/components/core"
1819 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
20+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
1921 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
2022 "github.com/charmbracelet/crush/internal/tui/components/logo"
2123 lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
@@ -41,6 +43,18 @@ type Splash interface {
4143
4244 // IsAPIKeyValid returns whether the API key is valid
4345 IsAPIKeyValid() bool
46+
47+ // IsShowingClaudeAuthMethodChooser returns whether showing Claude auth method chooser
48+ IsShowingClaudeAuthMethodChooser() bool
49+
50+ // IsShowingClaudeOAuth2 returns whether showing Claude OAuth2 flow
51+ IsShowingClaudeOAuth2() bool
52+
53+ // IsClaudeOAuthURLState returns whether in OAuth URL state
54+ IsClaudeOAuthURLState() bool
55+
56+ // IsClaudeOAuthComplete returns whether Claude OAuth flow is complete
57+ IsClaudeOAuthComplete() bool
4458}
4559
4660const (
@@ -72,6 +86,12 @@ type splashCmp struct {
7286 selectedModel *models.ModelOption
7387 isAPIKeyValid bool
7488 apiKeyValue string
89+
90+ // Claude state
91+ claudeAuthMethodChooser *claude.AuthMethodChooser
92+ claudeOAuth2 *claude.OAuth2
93+ showClaudeAuthMethodChooser bool
94+ showClaudeOAuth2 bool
7595}
7696
7797func New() Splash {
@@ -97,6 +117,9 @@ func New() Splash {
97117 modelList: modelList,
98118 apiKeyInput: apiKeyInput,
99119 selectedNo: false,
120+
121+ claudeAuthMethodChooser: claude.NewAuthMethodChooser(),
122+ claudeOAuth2: claude.NewOAuth2(),
100123 }
101124}
102125
@@ -115,7 +138,12 @@ func (s *splashCmp) GetSize() (int, int) {
115138
116139// Init implements SplashPage.
117140func (s *splashCmp) Init() tea.Cmd {
118− return tea.Batch(s.modelList.Init(), s.apiKeyInput.Init())
141+ return tea.Batch(
142+ s.modelList.Init(),
143+ s.apiKeyInput.Init(),
144+ s.claudeAuthMethodChooser.Init(),
145+ s.claudeOAuth2.Init(),
146+ )
119147}
120148
121149// SetSize implements SplashPage.
@@ -131,6 +159,7 @@ func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
131159 s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2
132160 listWidth := min(60, width)
133161 s.apiKeyInput.SetWidth(width - 2)
162+ s.claudeAuthMethodChooser.SetWidth(min(width-2, 60))
134163 return s.modelList.SetSize(listWidth, s.listHeight)
135164}
136165
@@ -139,6 +168,28 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
139168 switch msg := msg.(type) {
140169 case tea.WindowSizeMsg:
141170 return s, s.SetSize(msg.Width, msg.Height)
171+ case claude.ValidationCompletedMsg:
172+ var cmds []tea.Cmd
173+ u, cmd := s.claudeOAuth2.Update(msg)
174+ s.claudeOAuth2 = u.(*claude.OAuth2)
175+ cmds = append(cmds, cmd)
176+
177+ if msg.State == claude.OAuthValidationStateValid {
178+ cmds = append(
179+ cmds,
180+ s.saveAPIKeyAndContinue(msg.Token, false),
181+ func() tea.Msg {
182+ time.Sleep(5 * time.Second)
183+ return claude.AuthenticationCompleteMsg{}
184+ },
185+ )
186+ }
187+
188+ return s, tea.Batch(cmds...)
189+ case claude.AuthenticationCompleteMsg:
190+ s.showClaudeAuthMethodChooser = false
191+ s.showClaudeOAuth2 = false
192+ return s, util.CmdHandler(OnboardingCompleteMsg{})
142193 case models.APIKeyStateChangeMsg:
143194 u, cmd := s.apiKeyInput.Update(msg)
144195 s.apiKeyInput = u.(*models.APIKeyInput)
@@ -150,16 +201,48 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
150201 return s, cmd
151202 case SubmitAPIKeyMsg:
152203 if s.isAPIKeyValid {
153− return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
204+ return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true)
154205 }
155206 case tea.KeyPressMsg:
156207 switch {
208+ case key.Matches(msg, s.keyMap.Copy):
209+ if s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateURL {
210+ return s, tea.Sequence(
211+ tea.SetClipboard(s.claudeOAuth2.URL),
212+ func() tea.Msg {
213+ _ = clipboard.WriteAll(s.claudeOAuth2.URL)
214+ return nil
215+ },
216+ util.ReportInfo("URL copied to clipboard"),
217+ )
218+ } else if s.showClaudeAuthMethodChooser {
219+ u, cmd := s.claudeAuthMethodChooser.Update(msg)
220+ s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
221+ return s, cmd
222+ } else if s.showClaudeOAuth2 {
223+ u, cmd := s.claudeOAuth2.Update(msg)
224+ s.claudeOAuth2 = u.(*claude.OAuth2)
225+ return s, cmd
226+ }
157227 case key.Matches(msg, s.keyMap.Back):
228+ if s.showClaudeAuthMethodChooser {
229+ s.claudeAuthMethodChooser.SetDefaults()
230+ s.showClaudeAuthMethodChooser = false
231+ return s, nil
232+ }
233+ if s.showClaudeOAuth2 {
234+ s.claudeOAuth2.SetDefaults()
235+ s.showClaudeOAuth2 = false
236+ s.showClaudeAuthMethodChooser = true
237+ return s, nil
238+ }
158239 if s.isAPIKeyValid {
159240 return s, nil
160241 }
161242 if s.needsAPIKey {
162− // Go back to model selection
243+ if s.selectedModel.Provider.ID == catwalk.InferenceProviderAnthropic {
244+ s.showClaudeAuthMethodChooser = true
245+ }
163246 s.needsAPIKey = false
164247 s.selectedModel = nil
165248 s.isAPIKeyValid = false
@@ -168,8 +251,32 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
168251 return s, nil
169252 }
170253 case key.Matches(msg, s.keyMap.Select):
254+ if s.showClaudeAuthMethodChooser {
255+ selectedItem := s.modelList.SelectedModel()
256+ if selectedItem == nil {
257+ return s, nil
258+ }
259+
260+ switch s.claudeAuthMethodChooser.State {
261+ case claude.AuthMethodAPIKey:
262+ s.showClaudeAuthMethodChooser = false
263+ s.needsAPIKey = true
264+ s.selectedModel = selectedItem
265+ s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
266+ case claude.AuthMethodOAuth2:
267+ s.selectedModel = selectedItem
268+ s.showClaudeAuthMethodChooser = false
269+ s.showClaudeOAuth2 = true
270+ }
271+ return s, nil
272+ }
273+ if s.showClaudeOAuth2 {
274+ m2, cmd2 := s.claudeOAuth2.ValidationConfirm()
275+ s.claudeOAuth2 = m2.(*claude.OAuth2)
276+ return s, cmd2
277+ }
171278 if s.isAPIKeyValid {
172− return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
279+ return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true)
173280 }
174281 if s.isOnboarding && !s.needsAPIKey {
175282 selectedItem := s.modelList.SelectedModel()
@@ -181,6 +288,10 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
181288 s.isOnboarding = false
182289 return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
183290 } else {
291+ if selectedItem.Provider.ID == catwalk.InferenceProviderAnthropic {
292+ s.showClaudeAuthMethodChooser = true
293+ return s, nil
294+ }
184295 // Provider not configured, show API key input
185296 s.needsAPIKey = true
186297 s.selectedModel = selectedItem
@@ -232,6 +343,10 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
232343 return s, s.initializeProject()
233344 }
234345 case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
346+ if s.showClaudeAuthMethodChooser {
347+ s.claudeAuthMethodChooser.ToggleChoice()
348+ return s, nil
349+ }
235350 if s.needsAPIKey {
236351 u, cmd := s.apiKeyInput.Update(msg)
237352 s.apiKeyInput = u.(*models.APIKeyInput)
@@ -272,7 +387,15 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
272387 return s, s.initializeProject()
273388 }
274389 default:
275− if s.needsAPIKey {
390+ if s.showClaudeAuthMethodChooser {
391+ u, cmd := s.claudeAuthMethodChooser.Update(msg)
392+ s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
393+ return s, cmd
394+ } else if s.showClaudeOAuth2 {
395+ u, cmd := s.claudeOAuth2.Update(msg)
396+ s.claudeOAuth2 = u.(*claude.OAuth2)
397+ return s, cmd
398+ } else if s.needsAPIKey {
276399 u, cmd := s.apiKeyInput.Update(msg)
277400 s.apiKeyInput = u.(*models.APIKeyInput)
278401 return s, cmd
@@ -283,7 +406,11 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
283406 }
284407 }
285408 case tea.PasteMsg:
286− if s.needsAPIKey {
409+ if s.showClaudeOAuth2 {
410+ u, cmd := s.claudeOAuth2.Update(msg)
411+ s.claudeOAuth2 = u.(*claude.OAuth2)
412+ return s, cmd
413+ } else if s.needsAPIKey {
287414 u, cmd := s.apiKeyInput.Update(msg)
288415 s.apiKeyInput = u.(*models.APIKeyInput)
289416 return s, cmd
@@ -293,14 +420,20 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
293420 return s, cmd
294421 }
295422 case spinner.TickMsg:
296− u, cmd := s.apiKeyInput.Update(msg)
297− s.apiKeyInput = u.(*models.APIKeyInput)
298− return s, cmd
423+ if s.showClaudeOAuth2 {
424+ u, cmd := s.claudeOAuth2.Update(msg)
425+ s.claudeOAuth2 = u.(*claude.OAuth2)
426+ return s, cmd
427+ } else {
428+ u, cmd := s.apiKeyInput.Update(msg)
429+ s.apiKeyInput = u.(*models.APIKeyInput)
430+ return s, cmd
431+ }
299432 }
300433 return s, nil
301434}
302435
303−func (s *splashCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
436+func (s *splashCmp) saveAPIKeyAndContinue(apiKey any, close bool) tea.Cmd {
304437 if s.selectedModel == nil {
305438 return nil
306439 }
@@ -318,7 +451,10 @@ func (s *splashCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
318451 s.selectedModel = nil
319452 s.isAPIKeyValid = false
320453
321− return tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
454+ if close {
455+ return tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
456+ }
457+ return cmd
322458}
323459
324460func (s *splashCmp) initializeProject() tea.Cmd {
@@ -426,7 +562,39 @@ func (s *splashCmp) isProviderConfigured(providerID string) bool {
426562func (s *splashCmp) View() string {
427563 t := styles.CurrentTheme()
428564 var content string
429− if s.needsAPIKey {
565+ if s.showClaudeAuthMethodChooser {
566+ remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
567+ chooserView := s.claudeAuthMethodChooser.View()
568+ authMethodSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
569+ lipgloss.JoinVertical(
570+ lipgloss.Left,
571+ t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Anthropic"),
572+ "",
573+ chooserView,
574+ ),
575+ )
576+ content = lipgloss.JoinVertical(
577+ lipgloss.Left,
578+ s.logoRendered,
579+ authMethodSelector,
580+ )
581+ } else if s.showClaudeOAuth2 {
582+ remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
583+ oauth2View := s.claudeOAuth2.View()
584+ oauthSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
585+ lipgloss.JoinVertical(
586+ lipgloss.Left,
587+ t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Anthropic"),
588+ "",
589+ oauth2View,
590+ ),
591+ )
592+ content = lipgloss.JoinVertical(
593+ lipgloss.Left,
594+ s.logoRendered,
595+ oauthSelector,
596+ )
597+ } else if s.needsAPIKey {
430598 remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
431599 apiKeyView := t.S().Base.PaddingLeft(1).Render(s.apiKeyInput.View())
432600 apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
@@ -524,6 +692,16 @@ func (s *splashCmp) View() string {
524692}
525693
526694func (s *splashCmp) Cursor() *tea.Cursor {
695+ if s.showClaudeAuthMethodChooser {
696+ return nil
697+ }
698+ if s.showClaudeOAuth2 {
699+ if cursor := s.claudeOAuth2.CodeInput.Cursor(); cursor != nil {
700+ cursor.Y += 2 // FIXME(@andreynering): Why do we need this?
701+ return s.moveCursor(cursor)
702+ }
703+ return nil
704+ }
527705 if s.needsAPIKey {
528706 cursor := s.apiKeyInput.Cursor()
529707 if cursor != nil {
@@ -596,17 +774,23 @@ func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
596774 }
597775 // Calculate the correct Y offset based on current state
598776 logoHeight := lipgloss.Height(s.logoRendered)
599− if s.needsAPIKey {
777+ if s.needsAPIKey || s.showClaudeOAuth2 {
778+ var view string
779+ if s.needsAPIKey {
780+ view = s.apiKeyInput.View()
781+ } else {
782+ view = s.claudeOAuth2.View()
783+ }
600784 infoSectionHeight := lipgloss.Height(s.infoSection())
601785 baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight
602− remainingHeight := s.height - baseOffset - lipgloss.Height(s.apiKeyInput.View()) - SplashScreenPaddingY
786+ remainingHeight := s.height - baseOffset - lipgloss.Height(view) - SplashScreenPaddingY
603787 offset := baseOffset + remainingHeight
604788 cursor.Y += offset
605− cursor.X = cursor.X + 1
789+ cursor.X += 1
606790 } else if s.isOnboarding {
607791 offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 2
608792 cursor.Y += offset
609− cursor.X = cursor.X + 1
793+ cursor.X += 1
610794 }
611795
612796 return cursor
@@ -621,7 +805,21 @@ func (s *splashCmp) logoGap() int {
621805
622806// Bindings implements SplashPage.
623807func (s *splashCmp) Bindings() []key.Binding {
624− if s.needsAPIKey {
808+ if s.showClaudeAuthMethodChooser {
809+ return []key.Binding{
810+ s.keyMap.Select,
811+ s.keyMap.Tab,
812+ s.keyMap.Back,
813+ }
814+ } else if s.showClaudeOAuth2 {
815+ bindings := []key.Binding{
816+ s.keyMap.Select,
817+ }
818+ if s.claudeOAuth2.State == claude.OAuthStateURL {
819+ bindings = append(bindings, s.keyMap.Copy)
820+ }
821+ return bindings
822+ } else if s.needsAPIKey {
625823 return []key.Binding{
626824 s.keyMap.Select,
627825 s.keyMap.Back,
@@ -726,3 +924,19 @@ func (s *splashCmp) IsShowingAPIKey() bool {
726924func (s *splashCmp) IsAPIKeyValid() bool {
727925 return s.isAPIKeyValid
728926}
927+
928+func (s *splashCmp) IsShowingClaudeAuthMethodChooser() bool {
929+ return s.showClaudeAuthMethodChooser
930+}
931+
932+func (s *splashCmp) IsShowingClaudeOAuth2() bool {
933+ return s.showClaudeOAuth2
934+}
935+
936+func (s *splashCmp) IsClaudeOAuthURLState() bool {
937+ return s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateURL
938+}
939+
940+func (s *splashCmp) IsClaudeOAuthComplete() bool {
941+ return s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateCode && s.claudeOAuth2.ValidationState == claude.OAuthValidationStateValid
942+}
internal/tui/components/dialogs/claude/method.gonew file+115
@@ -0,0 +1,115 @@
1+package claude
2+
3+import (
4+ tea "charm.land/bubbletea/v2"
5+ "charm.land/lipgloss/v2"
6+ "github.com/charmbracelet/crush/internal/tui/styles"
7+ "github.com/charmbracelet/crush/internal/tui/util"
8+)
9+
10+type AuthMethod int
11+
12+const (
13+ AuthMethodAPIKey AuthMethod = iota
14+ AuthMethodOAuth2
15+)
16+
17+type AuthMethodChooser struct {
18+ State AuthMethod
19+ width int
20+ isOnboarding bool
21+}
22+
23+func NewAuthMethodChooser() *AuthMethodChooser {
24+ return &AuthMethodChooser{
25+ State: AuthMethodOAuth2,
26+ }
27+}
28+
29+func (a *AuthMethodChooser) Init() tea.Cmd {
30+ return nil
31+}
32+
33+func (a *AuthMethodChooser) Update(msg tea.Msg) (util.Model, tea.Cmd) {
34+ return a, nil
35+}
36+
37+func (a *AuthMethodChooser) View() string {
38+ t := styles.CurrentTheme()
39+
40+ white := lipgloss.NewStyle().Foreground(t.White)
41+ primary := lipgloss.NewStyle().Foreground(t.Primary)
42+ success := lipgloss.NewStyle().Foreground(t.Success)
43+
44+ titleStyle := white
45+ if a.isOnboarding {
46+ titleStyle = primary
47+ }
48+
49+ question := lipgloss.
50+ NewStyle().
51+ Margin(0, 1).
52+ Render(titleStyle.Render("How would you like to authenticate with ") + success.Render("Anthropic") + titleStyle.Render("?"))
53+
54+ squareWidth := (a.width - 2) / 2
55+ squareHeight := squareWidth / 3
56+ if isOdd(squareHeight) {
57+ squareHeight++
58+ }
59+
60+ square := lipgloss.NewStyle().
61+ Width(squareWidth).
62+ Height(squareHeight).
63+ Margin(0, 0).
64+ Border(lipgloss.RoundedBorder())
65+
66+ squareText := lipgloss.NewStyle().
67+ Width(squareWidth - 2).
68+ Height(squareHeight).
69+ Align(lipgloss.Center).
70+ AlignVertical(lipgloss.Center)
71+
72+ oauthBorder := t.AuthBorderSelected
73+ oauthText := t.AuthTextSelected
74+ apiKeyBorder := t.AuthBorderUnselected
75+ apiKeyText := t.AuthTextUnselected
76+
77+ if a.State == AuthMethodAPIKey {
78+ oauthBorder, apiKeyBorder = apiKeyBorder, oauthBorder
79+ oauthText, apiKeyText = apiKeyText, oauthText
80+ }
81+
82+ return lipgloss.JoinVertical(
83+ lipgloss.Left,
84+ question,
85+ "",
86+ lipgloss.JoinHorizontal(
87+ lipgloss.Center,
88+ square.MarginLeft(1).
89+ Inherit(oauthBorder).Render(squareText.Inherit(oauthText).Render("Claude Account\nwith Subscription")),
90+ square.MarginRight(1).
91+ Inherit(apiKeyBorder).Render(squareText.Inherit(apiKeyText).Render("API Key")),
92+ ),
93+ )
94+}
95+
96+func (a *AuthMethodChooser) SetDefaults() {
97+ a.State = AuthMethodOAuth2
98+}
99+
100+func (a *AuthMethodChooser) SetWidth(w int) {
101+ a.width = w
102+}
103+
104+func (a *AuthMethodChooser) ToggleChoice() {
105+ switch a.State {
106+ case AuthMethodAPIKey:
107+ a.State = AuthMethodOAuth2
108+ case AuthMethodOAuth2:
109+ a.State = AuthMethodAPIKey
110+ }
111+}
112+
113+func isOdd(n int) bool {
114+ return n%2 != 0
115+}
internal/tui/components/dialogs/claude/oauth.gonew file+267
@@ -0,0 +1,267 @@
1+package claude
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/url"
7+
8+ "charm.land/bubbles/v2/spinner"
9+ "charm.land/bubbles/v2/textinput"
10+ tea "charm.land/bubbletea/v2"
11+ "charm.land/lipgloss/v2"
12+ "github.com/charmbracelet/crush/internal/oauth"
13+ "github.com/charmbracelet/crush/internal/oauth/claude"
14+ "github.com/charmbracelet/crush/internal/tui/styles"
15+ "github.com/charmbracelet/crush/internal/tui/util"
16+ "github.com/pkg/browser"
17+ "github.com/zeebo/xxh3"
18+)
19+
20+type OAuthState int
21+
22+const (
23+ OAuthStateURL OAuthState = iota
24+ OAuthStateCode
25+)
26+
27+type OAuthValidationState int
28+
29+const (
30+ OAuthValidationStateNone OAuthValidationState = iota
31+ OAuthValidationStateVerifying
32+ OAuthValidationStateValid
33+ OAuthValidationStateError
34+)
35+
36+type ValidationCompletedMsg struct {
37+ State OAuthValidationState
38+ Token *oauth.Token
39+}
40+
41+type AuthenticationCompleteMsg struct{}
42+
43+type OAuth2 struct {
44+ State OAuthState
45+ ValidationState OAuthValidationState
46+ width int
47+ isOnboarding bool
48+
49+ // URL page
50+ err error
51+ verifier string
52+ challenge string
53+ URL string
54+ urlId string
55+ token *oauth.Token
56+
57+ // Code input page
58+ CodeInput textinput.Model
59+ spinner spinner.Model
60+}
61+
62+func NewOAuth2() *OAuth2 {
63+ return &OAuth2{
64+ State: OAuthStateURL,
65+ }
66+}
67+
68+func (o *OAuth2) Init() tea.Cmd {
69+ t := styles.CurrentTheme()
70+
71+ verifier, challenge, err := claude.GetChallenge()
72+ if err != nil {
73+ o.err = err
74+ return nil
75+ }
76+
77+ url, err := claude.AuthorizeURL(verifier, challenge)
78+ if err != nil {
79+ o.err = err
80+ return nil
81+ }
82+
83+ o.verifier = verifier
84+ o.challenge = challenge
85+ o.URL = url
86+
87+ h := xxh3.New()
88+ _, _ = h.WriteString(o.URL)
89+ o.urlId = fmt.Sprintf("id=%x", h.Sum(nil))
90+
91+ o.CodeInput = textinput.New()
92+ o.CodeInput.Placeholder = "Paste or type"
93+ o.CodeInput.SetVirtualCursor(false)
94+ o.CodeInput.Prompt = "> "
95+ o.CodeInput.SetStyles(t.S().TextInput)
96+ o.CodeInput.SetWidth(50)
97+
98+ o.spinner = spinner.New(
99+ spinner.WithSpinner(spinner.Dot),
100+ spinner.WithStyle(t.S().Base.Foreground(t.Green)),
101+ )
102+
103+ return nil
104+}
105+
106+func (o *OAuth2) Update(msg tea.Msg) (util.Model, tea.Cmd) {
107+ var cmds []tea.Cmd
108+
109+ switch msg := msg.(type) {
110+ case ValidationCompletedMsg:
111+ o.ValidationState = msg.State
112+ o.token = msg.Token
113+ switch o.ValidationState {
114+ case OAuthValidationStateError:
115+ o.CodeInput.Focus()
116+ }
117+ o.updatePrompt()
118+ }
119+
120+ if o.ValidationState == OAuthValidationStateVerifying {
121+ var cmd tea.Cmd
122+ o.spinner, cmd = o.spinner.Update(msg)
123+ cmds = append(cmds, cmd)
124+ o.updatePrompt()
125+ }
126+ {
127+ var cmd tea.Cmd
128+ o.CodeInput, cmd = o.CodeInput.Update(msg)
129+ cmds = append(cmds, cmd)
130+ }
131+
132+ return o, tea.Batch(cmds...)
133+}
134+
135+func (o *OAuth2) ValidationConfirm() (util.Model, tea.Cmd) {
136+ var cmds []tea.Cmd
137+
138+ switch {
139+ case o.State == OAuthStateURL:
140+ _ = browser.OpenURL(o.URL)
141+ o.State = OAuthStateCode
142+ cmds = append(cmds, o.CodeInput.Focus())
143+ case o.ValidationState == OAuthValidationStateNone || o.ValidationState == OAuthValidationStateError:
144+ o.CodeInput.Blur()
145+ o.ValidationState = OAuthValidationStateVerifying
146+ cmds = append(cmds, o.spinner.Tick, o.validateCode)
147+ case o.ValidationState == OAuthValidationStateValid:
148+ cmds = append(cmds, func() tea.Msg { return AuthenticationCompleteMsg{} })
149+ }
150+
151+ o.updatePrompt()
152+ return o, tea.Batch(cmds...)
153+}
154+
155+func (o *OAuth2) View() string {
156+ t := styles.CurrentTheme()
157+
158+ whiteStyle := lipgloss.NewStyle().Foreground(t.White)
159+ primaryStyle := lipgloss.NewStyle().Foreground(t.Primary)
160+ successStyle := lipgloss.NewStyle().Foreground(t.Success)
161+ errorStyle := lipgloss.NewStyle().Foreground(t.Error)
162+
163+ titleStyle := whiteStyle
164+ if o.isOnboarding {
165+ titleStyle = primaryStyle
166+ }
167+
168+ switch {
169+ case o.err != nil:
170+ return lipgloss.NewStyle().
171+ Margin(0, 1).
172+ Foreground(t.Error).
173+ Render(o.err.Error())
174+ case o.State == OAuthStateURL:
175+ heading := lipgloss.
176+ NewStyle().
177+ Margin(0, 1).
178+ Render(titleStyle.Render("Press enter key to open the following ") + successStyle.Render("URL") + titleStyle.Render(":"))
179+
180+ return lipgloss.JoinVertical(
181+ lipgloss.Left,
182+ heading,
183+ "",
184+ lipgloss.NewStyle().
185+ Margin(0, 1).
186+ Foreground(t.FgMuted).
187+ Hyperlink(o.URL, o.urlId).
188+ Render(o.displayUrl()),
189+ )
190+ case o.State == OAuthStateCode:
191+ var heading string
192+
193+ switch o.ValidationState {
194+ case OAuthValidationStateNone:
195+ st := lipgloss.NewStyle().Margin(0, 1)
196+ heading = st.Render(titleStyle.Render("Enter the ") + successStyle.Render("code") + titleStyle.Render(" you received."))
197+ case OAuthValidationStateVerifying:
198+ heading = titleStyle.Margin(0, 1).Render("Verifying...")
199+ case OAuthValidationStateValid:
200+ heading = successStyle.Margin(0, 1).Render("Validated.")
201+ case OAuthValidationStateError:
202+ heading = errorStyle.Margin(0, 1).Render("Invalid. Try again?")
203+ }
204+
205+ return lipgloss.JoinVertical(
206+ lipgloss.Left,
207+ heading,
208+ "",
209+ " "+o.CodeInput.View(),
210+ )
211+ default:
212+ panic("claude oauth2: invalid state")
213+ }
214+}
215+
216+func (o *OAuth2) SetDefaults() {
217+ o.State = OAuthStateURL
218+ o.ValidationState = OAuthValidationStateNone
219+ o.CodeInput.SetValue("")
220+ o.err = nil
221+}
222+
223+func (o *OAuth2) SetWidth(w int) {
224+ o.width = w
225+ o.CodeInput.SetWidth(w - 4)
226+}
227+
228+func (o *OAuth2) SetError(err error) {
229+ o.err = err
230+}
231+
232+func (o *OAuth2) validateCode() tea.Msg {
233+ token, err := claude.ExchangeToken(context.Background(), o.CodeInput.Value(), o.verifier)
234+ if err != nil || token == nil {
235+ return ValidationCompletedMsg{State: OAuthValidationStateError}
236+ }
237+ return ValidationCompletedMsg{State: OAuthValidationStateValid, Token: token}
238+}
239+
240+func (o *OAuth2) updatePrompt() {
241+ switch o.ValidationState {
242+ case OAuthValidationStateNone:
243+ o.CodeInput.Prompt = "> "
244+ case OAuthValidationStateVerifying:
245+ o.CodeInput.Prompt = o.spinner.View() + " "
246+ case OAuthValidationStateValid:
247+ o.CodeInput.Prompt = styles.CheckIcon + " "
248+ case OAuthValidationStateError:
249+ o.CodeInput.Prompt = styles.ErrorIcon + " "
250+ }
251+}
252+
253+// Remove query params for display
254+// e.g., "https://claude.ai/oauth/authorize?..." -> "https://claude.ai/oauth/authorize..."
255+func (o *OAuth2) displayUrl() string {
256+ parsed, err := url.Parse(o.URL)
257+ if err != nil {
258+ return o.URL
259+ }
260+
261+ if parsed.RawQuery != "" {
262+ parsed.RawQuery = ""
263+ return parsed.String() + "..."
264+ }
265+
266+ return o.URL
267+}
internal/tui/components/dialogs/models/keys.go+66
@@ -8,11 +8,17 @@ type KeyMap struct {
88 Select,
99 Next,
1010 Previous,
11+ Choose,
1112 Tab,
1213 Close key.Binding
1314
1415 isAPIKeyHelp bool
1516 isAPIKeyValid bool
17+
18+ isClaudeAuthChoiseHelp bool
19+ isClaudeOAuthHelp bool
20+ isClaudeOAuthURLState bool
21+ isClaudeOAuthHelpComplete bool
1622}
1723
1824func DefaultKeyMap() KeyMap {
@@ -29,6 +35,10 @@ func DefaultKeyMap() KeyMap {
2935 key.WithKeys("up", "ctrl+p"),
3036 key.WithHelp("↑", "previous item"),
3137 ),
38+ Choose: key.NewBinding(
39+ key.WithKeys("left", "right", "h", "l"),
40+ key.WithHelp("←→", "choose"),
41+ ),
3242 Tab: key.NewBinding(
3343 key.WithKeys("tab"),
3444 key.WithHelp("tab", "toggle type"),
@@ -64,8 +74,64 @@ func (k KeyMap) FullHelp() [][]key.Binding {
6474
6575// ShortHelp implements help.KeyMap.
6676func (k KeyMap) ShortHelp() []key.Binding {
77+ if k.isClaudeAuthChoiseHelp {
78+ return []key.Binding{
79+ key.NewBinding(
80+ key.WithKeys("left", "right", "h", "l"),
81+ key.WithHelp("←→", "choose"),
82+ ),
83+ key.NewBinding(
84+ key.WithKeys("enter"),
85+ key.WithHelp("enter", "accept"),
86+ ),
87+ key.NewBinding(
88+ key.WithKeys("esc"),
89+ key.WithHelp("esc", "back"),
90+ ),
91+ }
92+ }
93+ if k.isClaudeOAuthHelp {
94+ if k.isClaudeOAuthHelpComplete {
95+ return []key.Binding{
96+ key.NewBinding(
97+ key.WithKeys("enter"),
98+ key.WithHelp("enter", "close"),
99+ ),
100+ }
101+ }
102+
103+ enterHelp := "submit"
104+ if k.isClaudeOAuthURLState {
105+ enterHelp = "open"
106+ }
107+
108+ bindings := []key.Binding{
109+ key.NewBinding(
110+ key.WithKeys("enter"),
111+ key.WithHelp("enter", enterHelp),
112+ ),
113+ }
114+
115+ if k.isClaudeOAuthURLState {
116+ bindings = append(bindings, key.NewBinding(
117+ key.WithKeys("c"),
118+ key.WithHelp("c", "copy url"),
119+ ))
120+ }
121+
122+ bindings = append(bindings, key.NewBinding(
123+ key.WithKeys("esc"),
124+ key.WithHelp("esc", "back"),
125+ ))
126+
127+ return bindings
128+ }
67129 if k.isAPIKeyHelp && !k.isAPIKeyValid {
68130 return []key.Binding{
131+ key.NewBinding(
132+ key.WithKeys("enter"),
133+ key.WithHelp("enter", "submit"),
134+ ),
69135 k.Close,
70136 }
71137 } else if k.isAPIKeyValid {
internal/tui/components/dialogs/models/models.go+170−29
@@ -9,10 +9,12 @@ import (
99 "charm.land/bubbles/v2/spinner"
1010 tea "charm.land/bubbletea/v2"
1111 "charm.land/lipgloss/v2"
12+ "github.com/atotto/clipboard"
1213 "github.com/charmbracelet/catwalk/pkg/catwalk"
1314 "github.com/charmbracelet/crush/internal/config"
1415 "github.com/charmbracelet/crush/internal/tui/components/core"
1516 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
17+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
1618 "github.com/charmbracelet/crush/internal/tui/exp/list"
1719 "github.com/charmbracelet/crush/internal/tui/styles"
1820 "github.com/charmbracelet/crush/internal/tui/util"
@@ -67,6 +69,12 @@ type modelDialogCmp struct {
6769 selectedModelType config.SelectedModelType
6870 isAPIKeyValid bool
6971 apiKeyValue string
72+
73+ // Claude state
74+ claudeAuthMethodChooser *claude.AuthMethodChooser
75+ claudeOAuth2 *claude.OAuth2
76+ showClaudeAuthMethodChooser bool
77+ showClaudeOAuth2 bool
7078}
7179
7280func NewModelDialogCmp() ModelDialog {
@@ -91,11 +99,19 @@ func NewModelDialogCmp() ModelDialog {
9199 width: defaultWidth,
92100 keyMap: DefaultKeyMap(),
93101 help: help,
102+
103+ claudeAuthMethodChooser: claude.NewAuthMethodChooser(),
104+ claudeOAuth2: claude.NewOAuth2(),
94105 }
95106}
96107
97108func (m *modelDialogCmp) Init() tea.Cmd {
98− return tea.Batch(m.modelList.Init(), m.apiKeyInput.Init())
109+ return tea.Batch(
110+ m.modelList.Init(),
111+ m.apiKeyInput.Init(),
112+ m.claudeAuthMethodChooser.Init(),
113+ m.claudeOAuth2.Init(),
114+ )
99115}
100116
101117func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
@@ -105,16 +121,84 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
105121 m.wHeight = msg.Height
106122 m.apiKeyInput.SetWidth(m.width - 2)
107123 m.help.SetWidth(m.width - 2)
124+ m.claudeAuthMethodChooser.SetWidth(m.width - 2)
108125 return m, m.modelList.SetSize(m.listWidth(), m.listHeight())
109126 case APIKeyStateChangeMsg:
110127 u, cmd := m.apiKeyInput.Update(msg)
111128 m.apiKeyInput = u.(*APIKeyInput)
112129 return m, cmd
130+ case claude.ValidationCompletedMsg:
131+ var cmds []tea.Cmd
132+ u, cmd := m.claudeOAuth2.Update(msg)
133+ m.claudeOAuth2 = u.(*claude.OAuth2)
134+ cmds = append(cmds, cmd)
135+
136+ if msg.State == claude.OAuthValidationStateValid {
137+ cmds = append(cmds, m.saveAPIKeyAndContinue(msg.Token, false))
138+ m.keyMap.isClaudeOAuthHelpComplete = true
139+ }
140+
141+ return m, tea.Batch(cmds...)
142+ case claude.AuthenticationCompleteMsg:
143+ return m, util.CmdHandler(dialogs.CloseDialogMsg{})
113144 case tea.KeyPressMsg:
114145 switch {
146+ case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))):
147+ if m.showClaudeOAuth2 && m.claudeOAuth2.State == claude.OAuthStateURL {
148+ return m, tea.Sequence(
149+ tea.SetClipboard(m.claudeOAuth2.URL),
150+ func() tea.Msg {
151+ _ = clipboard.WriteAll(m.claudeOAuth2.URL)
152+ return nil
153+ },
154+ util.ReportInfo("URL copied to clipboard"),
155+ )
156+ }
157+ case key.Matches(msg, m.keyMap.Choose):
158+ if m.showClaudeAuthMethodChooser {
159+ m.claudeAuthMethodChooser.ToggleChoice()
160+ return m, nil
161+ }
115162 case key.Matches(msg, m.keyMap.Select):
163+ selectedItem := m.modelList.SelectedModel()
164+
165+ modelType := config.SelectedModelTypeLarge
166+ if m.modelList.GetModelType() == SmallModelType {
167+ modelType = config.SelectedModelTypeSmall
168+ }
169+
170+ askForApiKey := func() {
171+ m.keyMap.isClaudeAuthChoiseHelp = false
172+ m.keyMap.isClaudeOAuthHelp = false
173+ m.keyMap.isAPIKeyHelp = true
174+ m.showClaudeAuthMethodChooser = false
175+ m.needsAPIKey = true
176+ m.selectedModel = selectedItem
177+ m.selectedModelType = modelType
178+ m.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
179+ }
180+
181+ if m.showClaudeAuthMethodChooser {
182+ switch m.claudeAuthMethodChooser.State {
183+ case claude.AuthMethodAPIKey:
184+ askForApiKey()
185+ case claude.AuthMethodOAuth2:
186+ m.selectedModel = selectedItem
187+ m.selectedModelType = modelType
188+ m.showClaudeAuthMethodChooser = false
189+ m.showClaudeOAuth2 = true
190+ m.keyMap.isClaudeAuthChoiseHelp = false
191+ m.keyMap.isClaudeOAuthHelp = true
192+ }
193+ return m, nil
194+ }
195+ if m.showClaudeOAuth2 {
196+ m2, cmd2 := m.claudeOAuth2.ValidationConfirm()
197+ m.claudeOAuth2 = m2.(*claude.OAuth2)
198+ return m, cmd2
199+ }
116200 if m.isAPIKeyValid {
117− return m, m.saveAPIKeyAndContinue(m.apiKeyValue)
201+ return m, m.saveAPIKeyAndContinue(m.apiKeyValue, true)
118202 }
119203 if m.needsAPIKey {
120204 // Handle API key submission
@@ -154,15 +238,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
154238 },
155239 )
156240 }
157− // Normal model selection
158− selectedItem := m.modelList.SelectedModel()
159−
160− var modelType config.SelectedModelType
161− if m.modelList.GetModelType() == LargeModelType {
162− modelType = config.SelectedModelTypeLarge
163− } else {
164− modelType = config.SelectedModelTypeSmall
165− }
166241
167242 // Check if provider is configured
168243 if m.isProviderConfigured(string(selectedItem.Provider.ID)) {
@@ -179,27 +254,38 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
179254 }),
180255 )
181256 } else {
182− // Provider not configured, show API key input
183− m.needsAPIKey = true
184− m.selectedModel = selectedItem
185− m.selectedModelType = modelType
186− m.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
257+ if selectedItem.Provider.ID == catwalk.InferenceProviderAnthropic {
258+ m.showClaudeAuthMethodChooser = true
259+ m.keyMap.isClaudeAuthChoiseHelp = true
260+ return m, nil
261+ }
262+ askForApiKey()
187263 return m, nil
188264 }
189265 case key.Matches(msg, m.keyMap.Tab):
190− if m.needsAPIKey {
266+ switch {
267+ case m.showClaudeAuthMethodChooser:
268+ m.claudeAuthMethodChooser.ToggleChoice()
269+ return m, nil
270+ case m.needsAPIKey:
191271 u, cmd := m.apiKeyInput.Update(msg)
192272 m.apiKeyInput = u.(*APIKeyInput)
193273 return m, cmd
194− }
195− if m.modelList.GetModelType() == LargeModelType {
274+ case m.modelList.GetModelType() == LargeModelType:
196275 m.modelList.SetInputPlaceholder(smallModelInputPlaceholder)
197276 return m, m.modelList.SetModelType(SmallModelType)
198− } else {
277+ default:
199278 m.modelList.SetInputPlaceholder(largeModelInputPlaceholder)
200279 return m, m.modelList.SetModelType(LargeModelType)
201280 }
202281 case key.Matches(msg, m.keyMap.Close):
282+ if m.showClaudeAuthMethodChooser {
283+ m.claudeAuthMethodChooser.SetDefaults()
284+ m.showClaudeAuthMethodChooser = false
285+ m.keyMap.isClaudeAuthChoiseHelp = false
286+ m.keyMap.isClaudeOAuthHelp = false
287+ return m, nil
288+ }
203289 if m.needsAPIKey {
204290 if m.isAPIKeyValid {
205291 return m, nil
@@ -214,7 +300,15 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
214300 }
215301 return m, util.CmdHandler(dialogs.CloseDialogMsg{})
216302 default:
217− if m.needsAPIKey {
303+ if m.showClaudeAuthMethodChooser {
304+ u, cmd := m.claudeAuthMethodChooser.Update(msg)
305+ m.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
306+ return m, cmd
307+ } else if m.showClaudeOAuth2 {
308+ u, cmd := m.claudeOAuth2.Update(msg)
309+ m.claudeOAuth2 = u.(*claude.OAuth2)
310+ return m, cmd
311+ } else if m.needsAPIKey {
218312 u, cmd := m.apiKeyInput.Update(msg)
219313 m.apiKeyInput = u.(*APIKeyInput)
220314 return m, cmd
@@ -225,7 +319,11 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
225319 }
226320 }
227321 case tea.PasteMsg:
228− if m.needsAPIKey {
322+ if m.showClaudeOAuth2 {
323+ u, cmd := m.claudeOAuth2.Update(msg)
324+ m.claudeOAuth2 = u.(*claude.OAuth2)
325+ return m, cmd
326+ } else if m.needsAPIKey {
229327 u, cmd := m.apiKeyInput.Update(msg)
230328 m.apiKeyInput = u.(*APIKeyInput)
231329 return m, cmd
@@ -235,9 +333,15 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
235333 return m, cmd
236334 }
237335 case spinner.TickMsg:
238− u, cmd := m.apiKeyInput.Update(msg)
239− m.apiKeyInput = u.(*APIKeyInput)
240− return m, cmd
336+ if m.showClaudeOAuth2 {
337+ u, cmd := m.claudeOAuth2.Update(msg)
338+ m.claudeOAuth2 = u.(*claude.OAuth2)
339+ return m, cmd
340+ } else {
341+ u, cmd := m.apiKeyInput.Update(msg)
342+ m.apiKeyInput = u.(*APIKeyInput)
343+ return m, cmd
344+ }
241345 }
242346 return m, nil
243347}
@@ -245,7 +349,29 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
245349func (m *modelDialogCmp) View() string {
246350 t := styles.CurrentTheme()
247351
248− if m.needsAPIKey {
352+ switch {
353+ case m.showClaudeAuthMethodChooser:
354+ chooserView := m.claudeAuthMethodChooser.View()
355+ content := lipgloss.JoinVertical(
356+ lipgloss.Left,
357+ t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Let's Auth Anthropic", m.width-4)),
358+ chooserView,
359+ "",
360+ t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
361+ )
362+ return m.style().Render(content)
363+ case m.showClaudeOAuth2:
364+ m.keyMap.isClaudeOAuthURLState = m.claudeOAuth2.State == claude.OAuthStateURL
365+ oauth2View := m.claudeOAuth2.View()
366+ content := lipgloss.JoinVertical(
367+ lipgloss.Left,
368+ t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Let's Auth Anthropic", m.width-4)),
369+ oauth2View,
370+ "",
371+ t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
372+ )
373+ return m.style().Render(content)
374+ case m.needsAPIKey:
249375 // Show API key input
250376 m.keyMap.isAPIKeyHelp = true
251377 m.keyMap.isAPIKeyValid = m.isAPIKeyValid
@@ -275,6 +401,16 @@ func (m *modelDialogCmp) View() string {
275401}
276402
277403func (m *modelDialogCmp) Cursor() *tea.Cursor {
404+ if m.showClaudeAuthMethodChooser {
405+ return nil
406+ }
407+ if m.showClaudeOAuth2 {
408+ if cursor := m.claudeOAuth2.CodeInput.Cursor(); cursor != nil {
409+ cursor.Y += 2 // FIXME(@andreynering): Why do we need this?
410+ return m.moveCursor(cursor)
411+ }
412+ return nil
413+ }
278414 if m.needsAPIKey {
279415 cursor := m.apiKeyInput.Cursor()
280416 if cursor != nil {
@@ -365,7 +501,7 @@ func (m *modelDialogCmp) getProvider(providerID catwalk.InferenceProvider) (*cat
365501 return nil, nil
366502}
367503
368−func (m *modelDialogCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
504+func (m *modelDialogCmp) saveAPIKeyAndContinue(apiKey any, close bool) tea.Cmd {
369505 if m.selectedModel == nil {
370506 return util.ReportError(fmt.Errorf("no model selected"))
371507 }
@@ -378,8 +514,12 @@ func (m *modelDialogCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
378514
379515 // Reset API key state and continue with model selection
380516 selectedModel := *m.selectedModel
381− return tea.Sequence(
382− util.CmdHandler(dialogs.CloseDialogMsg{}),
517+ var cmds []tea.Cmd
518+ if close {
519+ cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{}))
520+ }
521+ cmds = append(
522+ cmds,
383523 util.CmdHandler(ModelSelectedMsg{
384524 Model: config.SelectedModel{
385525 Model: selectedModel.Model.ID,
@@ -390,4 +530,5 @@ func (m *modelDialogCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
390530 ModelType: m.selectedModelType,
391531 }),
392532 )
533+ return tea.Sequence(cmds...)
393534}
internal/tui/page/chat/chat.go+73
@@ -29,6 +29,7 @@ import (
2929 "github.com/charmbracelet/crush/internal/tui/components/core"
3030 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
3131 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
32+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
3233 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
3334 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
3435 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
@@ -293,6 +294,13 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
293294 cmds = append(cmds, cmd)
294295 return p, tea.Batch(cmds...)
295296
297+ case claude.ValidationCompletedMsg, claude.AuthenticationCompleteMsg:
298+ if p.focusedPane == PanelTypeSplash {
299+ u, cmd := p.splash.Update(msg)
300+ p.splash = u.(splash.Splash)
301+ cmds = append(cmds, cmd)
302+ }
303+ return p, tea.Batch(cmds...)
296304 case models.APIKeyStateChangeMsg:
297305 if p.focusedPane == PanelTypeSplash {
298306 u, cmd := p.splash.Update(msg)
@@ -816,6 +824,71 @@ func (p *chatPage) Help() help.KeyMap {
816824 var shortList []key.Binding
817825 var fullList [][]key.Binding
818826 switch {
827+ case p.isOnboarding && p.splash.IsShowingClaudeAuthMethodChooser():
828+ shortList = append(shortList,
829+ // Choose auth method
830+ key.NewBinding(
831+ key.WithKeys("left", "right", "tab"),
832+ key.WithHelp("←→/tab", "choose"),
833+ ),
834+ // Accept selection
835+ key.NewBinding(
836+ key.WithKeys("enter"),
837+ key.WithHelp("enter", "accept"),
838+ ),
839+ // Go back
840+ key.NewBinding(
841+ key.WithKeys("esc", "alt+esc"),
842+ key.WithHelp("esc", "back"),
843+ ),
844+ // Quit
845+ key.NewBinding(
846+ key.WithKeys("ctrl+c"),
847+ key.WithHelp("ctrl+c", "quit"),
848+ ),
849+ )
850+ // keep them the same
851+ for _, v := range shortList {
852+ fullList = append(fullList, []key.Binding{v})
853+ }
854+ case p.isOnboarding && p.splash.IsShowingClaudeOAuth2():
855+ if p.splash.IsClaudeOAuthURLState() {
856+ shortList = append(shortList,
857+ key.NewBinding(
858+ key.WithKeys("enter"),
859+ key.WithHelp("enter", "open"),
860+ ),
861+ key.NewBinding(
862+ key.WithKeys("c"),
863+ key.WithHelp("c", "copy url"),
864+ ),
865+ )
866+ } else if p.splash.IsClaudeOAuthComplete() {
867+ shortList = append(shortList,
868+ key.NewBinding(
869+ key.WithKeys("enter"),
870+ key.WithHelp("enter", "continue"),
871+ ),
872+ )
873+ } else {
874+ shortList = append(shortList,
875+ key.NewBinding(
876+ key.WithKeys("enter"),
877+ key.WithHelp("enter", "submit"),
878+ ),
879+ )
880+ }
881+ shortList = append(shortList,
882+ // Quit
883+ key.NewBinding(
884+ key.WithKeys("ctrl+c"),
885+ key.WithHelp("ctrl+c", "quit"),
886+ ),
887+ )
888+ // keep them the same
889+ for _, v := range shortList {
890+ fullList = append(fullList, []key.Binding{v})
891+ }
819892 case p.isOnboarding && !p.splash.IsShowingAPIKey():
820893 shortList = append(shortList,
821894 // Choose model
internal/tui/styles/charmtone.go+7
@@ -67,10 +67,17 @@ func NewCharmtoneTheme() *Theme {
6767 t.ItemErrorIcon = t.ItemOfflineIcon.Foreground(charmtone.Coral)
6868 t.ItemOnlineIcon = t.ItemOfflineIcon.Foreground(charmtone.Guac)
6969
70+ // Editor: Yolo Mode.
7071 t.YoloIconFocused = lipgloss.NewStyle().Foreground(charmtone.Oyster).Background(charmtone.Citron).Bold(true).SetString(" ! ")
7172 t.YoloIconBlurred = t.YoloIconFocused.Foreground(charmtone.Pepper).Background(charmtone.Squid)
7273 t.YoloDotsFocused = lipgloss.NewStyle().Foreground(charmtone.Zest).SetString(":::")
7374 t.YoloDotsBlurred = t.YoloDotsFocused.Foreground(charmtone.Squid)
7475
76+ // oAuth Chooser.
77+ t.AuthBorderSelected = lipgloss.NewStyle().BorderForeground(charmtone.Guac)
78+ t.AuthTextSelected = lipgloss.NewStyle().Foreground(charmtone.Julep)
79+ t.AuthBorderUnselected = lipgloss.NewStyle().BorderForeground(charmtone.Iron)
80+ t.AuthTextUnselected = lipgloss.NewStyle().Foreground(charmtone.Squid)
81+
7582 return t
7683}
internal/tui/styles/theme.go+7−1
@@ -85,12 +85,18 @@ type Theme struct {
8585 ItemErrorIcon lipgloss.Style
8686 ItemOnlineIcon lipgloss.Style
8787
88− // Editor: Yolo Mode
88+ // Editor: Yolo Mode.
8989 YoloIconFocused lipgloss.Style
9090 YoloIconBlurred lipgloss.Style
9191 YoloDotsFocused lipgloss.Style
9292 YoloDotsBlurred lipgloss.Style
9393
94+ // oAuth Chooser.
95+ AuthBorderSelected lipgloss.Style
96+ AuthTextSelected lipgloss.Style
97+ AuthBorderUnselected lipgloss.Style
98+ AuthTextUnselected lipgloss.Style
99+
94100 styles *Styles
95101}
96102