on main help sign in
Merge branch 'main' into ui
b1919ccAyman Bagabas
.github/cla-signatures.json+16
@@ -879,6 +879,22 @@
879 "created_at": "2025-11-22T05:23:17Z",
879 "created_at": "2025-11-22T05:23:17Z",
880 "repoId": 987670088,
880 "repoId": 987670088,
881 "pullRequestNo": 1496
881 "pullRequestNo": 1496
882+ },
883+ {
884+ "name": "masroor-ahmad",
885+ "id": 75073229,
886+ "comment_id": 3592527587,
887+ "created_at": "2025-11-30T12:55:09Z",
888+ "repoId": 987670088,
889+ "pullRequestNo": 1532
890+ },
891+ {
892+ "name": "thezbm",
893+ "id": 24851999,
894+ "comment_id": 3595049411,
895+ "created_at": "2025-12-01T07:39:02Z",
896+ "repoId": 987670088,
897+ "pullRequestNo": 1534
882 }
898 }
883 ]
899 ]
884}
900}
go.mod+3−3
@@ -4,7 +4,7 @@ go 1.25.0
4
4
5require (
5require (
6 charm.land/bubbles/v2 v2.0.0-rc.1
6 charm.land/bubbles/v2 v2.0.0-rc.1
7− charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251124184313-5de0f1f67562
7+ charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251126220703-2a0096c500a7
8 charm.land/fantasy v0.3.2
8 charm.land/fantasy v0.3.2
9 charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251119143523-0334bb4562ca
9 charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251119143523-0334bb4562ca
10 charm.land/x/vcr v0.1.1
10 charm.land/x/vcr v0.1.1
@@ -22,7 +22,7 @@ require (
22 github.com/charmbracelet/glamour/v2 v2.0.0-20251106195642-800eb8175930
22 github.com/charmbracelet/glamour/v2 v2.0.0-20251106195642-800eb8175930
23 github.com/charmbracelet/log/v2 v2.0.0-20251106192421-eb64aaa963a0
23 github.com/charmbracelet/log/v2 v2.0.0-20251106192421-eb64aaa963a0
24 github.com/charmbracelet/ultraviolet v0.0.0-20251116181749-377898bcce38
24 github.com/charmbracelet/ultraviolet v0.0.0-20251116181749-377898bcce38
25− github.com/charmbracelet/x/ansi v0.11.1
25+ github.com/charmbracelet/x/ansi v0.11.2
26 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
26 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
27 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
27 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
28 github.com/charmbracelet/x/exp/ordered v0.1.0
28 github.com/charmbracelet/x/exp/ordered v0.1.0
@@ -92,7 +92,7 @@ require (
92 github.com/charmbracelet/x/json v0.2.0 // indirect
92 github.com/charmbracelet/x/json v0.2.0 // indirect
93 github.com/charmbracelet/x/termios v0.1.1 // indirect
93 github.com/charmbracelet/x/termios v0.1.1 // indirect
94 github.com/charmbracelet/x/windows v0.2.2 // indirect
94 github.com/charmbracelet/x/windows v0.2.2 // indirect
95− github.com/clipperhouse/displaywidth v0.5.0 // indirect
95+ github.com/clipperhouse/displaywidth v0.6.0 // indirect
96 github.com/clipperhouse/stringish v0.1.1 // indirect
96 github.com/clipperhouse/stringish v0.1.1 // indirect
97 github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
97 github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
98 github.com/davecgh/go-spew v1.1.1 // indirect
98 github.com/davecgh/go-spew v1.1.1 // indirect
go.sum+6−6
@@ -1,7 +1,7 @@
1charm.land/bubbles/v2 v2.0.0-rc.1 h1:EiIFVAc3Zi/yY86td+79mPhHR7AqZ1OxF+6ztpOCRaM=
1charm.land/bubbles/v2 v2.0.0-rc.1 h1:EiIFVAc3Zi/yY86td+79mPhHR7AqZ1OxF+6ztpOCRaM=
2charm.land/bubbles/v2 v2.0.0-rc.1/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4=
2charm.land/bubbles/v2 v2.0.0-rc.1/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4=
3−charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251124184313-5de0f1f67562 h1:61aovinon0nJLTQBCZZhg5dgHhnO/jiqdBTeYwclAkY=
3+charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251126220703-2a0096c500a7 h1:3qsObfEm0WuACFhe3MSTPX8QByjVcjWkZDO4o2VWFpc=
4−charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251124184313-5de0f1f67562/go.mod h1:IXFmnCnMLTWw/KQ9rEatSYqbAPAYi8kA3Yqwa1SFnLk=
4+charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251126220703-2a0096c500a7/go.mod h1:IXFmnCnMLTWw/KQ9rEatSYqbAPAYi8kA3Yqwa1SFnLk=
5charm.land/fantasy v0.3.2 h1:yHTsSZ25LcICMRw3xzdz3OkaZtDQch+B5ljJo17HxgU=
5charm.land/fantasy v0.3.2 h1:yHTsSZ25LcICMRw3xzdz3OkaZtDQch+B5ljJo17HxgU=
6charm.land/fantasy v0.3.2/go.mod h1:sV8Ns/JTJHOaYOHPgVRDugMheAyxsW/nmdpVGrycYEk=
6charm.land/fantasy v0.3.2/go.mod h1:sV8Ns/JTJHOaYOHPgVRDugMheAyxsW/nmdpVGrycYEk=
7charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251119143523-0334bb4562ca h1:6bVc8OFotCS4sS7HKqxTudP7yn8Y0ODR6df2pdlY/+s=
7charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251119143523-0334bb4562ca h1:6bVc8OFotCS4sS7HKqxTudP7yn8Y0ODR6df2pdlY/+s=
@@ -100,8 +100,8 @@ github.com/charmbracelet/log/v2 v2.0.0-20251106192421-eb64aaa963a0 h1:lxHzxsHd4P
100github.com/charmbracelet/log/v2 v2.0.0-20251106192421-eb64aaa963a0/go.mod h1:Q7oMtlboDPnnrYiJDXNwdWmJblOmuOnycPKczlVju6I=
100github.com/charmbracelet/log/v2 v2.0.0-20251106192421-eb64aaa963a0/go.mod h1:Q7oMtlboDPnnrYiJDXNwdWmJblOmuOnycPKczlVju6I=
101github.com/charmbracelet/ultraviolet v0.0.0-20251116181749-377898bcce38 h1:7Rs87fbKJoIIxsQS8YKJYGYa0tlsDwwb0twQjV1KB+g=
101github.com/charmbracelet/ultraviolet v0.0.0-20251116181749-377898bcce38 h1:7Rs87fbKJoIIxsQS8YKJYGYa0tlsDwwb0twQjV1KB+g=
102github.com/charmbracelet/ultraviolet v0.0.0-20251116181749-377898bcce38/go.mod h1:6lfcr3MNP+kZR25sF1nQwJFuQnNYBlFy3PGX5rvslXc=
102github.com/charmbracelet/ultraviolet v0.0.0-20251116181749-377898bcce38/go.mod h1:6lfcr3MNP+kZR25sF1nQwJFuQnNYBlFy3PGX5rvslXc=
103−github.com/charmbracelet/x/ansi v0.11.1 h1:iXAC8SyMQDJgtcz9Jnw+HU8WMEctHzoTAETIeA3JXMk=
103+github.com/charmbracelet/x/ansi v0.11.2 h1:XAG3FSjiVtFvgEgGrNBkCNNYrsucAt8c6bfxHyROLLs=
104−github.com/charmbracelet/x/ansi v0.11.1/go.mod h1:M49wjzpIujwPceJ+t5w3qh2i87+HRtHohgb5iTyepL0=
104+github.com/charmbracelet/x/ansi v0.11.2/go.mod h1:9tY2bzX5SiJCU0iWyskjBeI2BRQfvPqI+J760Mjf+Rg=
105github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1xwHZg6eMZ9Wv5TE1UGub6ARubyOd1Lo5kPUI/6VL50=
105github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1xwHZg6eMZ9Wv5TE1UGub6ARubyOd1Lo5kPUI/6VL50=
106github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
106github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
107github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
107github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
@@ -120,8 +120,8 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8
120github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
120github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
121github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
121github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
122github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
122github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
123−github.com/clipperhouse/displaywidth v0.5.0 h1:AIG5vQaSL2EKqzt0M9JMnvNxOCRTKUc4vUnLWGgP89I=
123+github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s=
124−github.com/clipperhouse/displaywidth v0.5.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
124+github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
125github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
125github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
126github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
126github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
127github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
127github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
internal/agent/coordinator.go+15−1
@@ -130,7 +130,20 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
130
130
131 mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
131 mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
132
132
133+ if providerCfg.OAuthToken != nil && providerCfg.OAuthToken.IsExpired() {
134+ slog.Info("Detected expired OAuth token, attempting refresh", "provider", providerCfg.ID)
135+ if refreshErr := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); refreshErr != nil {
136+ slog.Error("Failed to refresh OAuth token", "provider", providerCfg.ID, "error", refreshErr)
137+ return nil, refreshErr
138+ }
139+
140+ // Rebuild models with refreshed token
141+ if updateErr := c.UpdateModels(ctx); updateErr != nil {
142+ slog.Error("Failed to update models after token refresh", "error", updateErr)
143+ return nil, updateErr
144+ }
145+ }
133− return c.currentAgent.Run(ctx, SessionAgentCall{
146+ result, err := c.currentAgent.Run(ctx, SessionAgentCall{
134 SessionID: sessionID,
147 SessionID: sessionID,
135 Prompt: prompt,
148 Prompt: prompt,
136 Attachments: attachments,
149 Attachments: attachments,
@@ -142,6 +155,7 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
142 FrequencyPenalty: freqPenalty,
155 FrequencyPenalty: freqPenalty,
143 PresencePenalty: presPenalty,
156 PresencePenalty: presPenalty,
144 })
157 })
158+ return result, err
145}
159}
146
160
147func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions {
161func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions {
internal/config/config.go+38
@@ -16,6 +16,7 @@ import (
16 "github.com/charmbracelet/crush/internal/csync"
16 "github.com/charmbracelet/crush/internal/csync"
17 "github.com/charmbracelet/crush/internal/env"
17 "github.com/charmbracelet/crush/internal/env"
18 "github.com/charmbracelet/crush/internal/oauth"
18 "github.com/charmbracelet/crush/internal/oauth"
19+ "github.com/charmbracelet/crush/internal/oauth/claude"
19 "github.com/invopop/jsonschema"
20 "github.com/invopop/jsonschema"
20 "github.com/tidwall/sjson"
21 "github.com/tidwall/sjson"
21)
22)
@@ -468,6 +469,43 @@ func (c *Config) SetConfigField(key string, value any) error {
468 return nil
469 return nil
469}
470}
470
471
472+func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error {
473+ providerConfig, exists := c.Providers.Get(providerID)
474+ if !exists {
475+ return fmt.Errorf("provider %s not found", providerID)
476+ }
477+
478+ if providerConfig.OAuthToken == nil {
479+ return fmt.Errorf("provider %s does not have an OAuth token", providerID)
480+ }
481+
482+ // Only Anthropic provider uses OAuth for now
483+ if providerID != string(catwalk.InferenceProviderAnthropic) {
484+ return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
485+ }
486+
487+ newToken, err := claude.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
488+ if err != nil {
489+ return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, err)
490+ }
491+
492+ slog.Info("Successfully refreshed OAuth token in background", "provider", providerID)
493+ providerConfig.OAuthToken = newToken
494+ providerConfig.APIKey = fmt.Sprintf("Bearer %s", newToken.AccessToken)
495+ providerConfig.SetupClaudeCode()
496+
497+ c.Providers.Set(providerID, providerConfig)
498+
499+ if err := cmp.Or(
500+ c.SetConfigField(fmt.Sprintf("providers.%s.api_key", providerID), newToken.AccessToken),
501+ c.SetConfigField(fmt.Sprintf("providers.%s.oauth", providerID), newToken),
502+ ); err != nil {
503+ return fmt.Errorf("failed to persist refreshed token: %w", err)
504+ }
505+
506+ return nil
507+}
508+
471func (c *Config) SetProviderAPIKey(providerID string, apiKey any) error {
509func (c *Config) SetProviderAPIKey(providerID string, apiKey any) error {
472 var providerConfig ProviderConfig
510 var providerConfig ProviderConfig
473 var exists bool
511 var exists bool
internal/tui/components/chat/editor/editor.go+2−5
@@ -95,7 +95,7 @@ type OpenEditorMsg struct {
95func (m *editorCmp) openEditor(value string) tea.Cmd {
95func (m *editorCmp) openEditor(value string) tea.Cmd {
96 editor := os.Getenv("EDITOR")
96 editor := os.Getenv("EDITOR")
97 if editor == "" {
97 if editor == "" {
98− // Use platform-appropriate default editor.
98+ // Use platform-appropriate default editor
99 if runtime.GOOS == "windows" {
99 if runtime.GOOS == "windows" {
100 editor = "notepad"
100 editor = "notepad"
101 } else {
101 } else {
@@ -111,10 +111,7 @@ func (m *editorCmp) openEditor(value string) tea.Cmd {
111 if _, err := tmpfile.WriteString(value); err != nil {
111 if _, err := tmpfile.WriteString(value); err != nil {
112 return util.ReportError(err)
112 return util.ReportError(err)
113 }
113 }
114−
115− // Build the full shell command with the file argument.
116− cmdStr := fmt.Sprintf("%s %s", editor, tmpfile.Name())
114+ cmdStr := editor + " " + tmpfile.Name()
117−
118 return util.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg {
115 return util.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg {
119 if err != nil {
116 if err != nil {
120 return util.ReportError(err)
117 return util.ReportError(err)
internal/tui/components/dialogs/models/models.go+9−11
@@ -143,17 +143,15 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
143 return m, util.CmdHandler(dialogs.CloseDialogMsg{})
143 return m, util.CmdHandler(dialogs.CloseDialogMsg{})
144 case tea.KeyPressMsg:
144 case tea.KeyPressMsg:
145 switch {
145 switch {
146− case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))):
147− if m.showClaudeOAuth2 && m.claudeOAuth2.State == claude.OAuthStateURL {
146+ case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showClaudeOAuth2 && m.claudeOAuth2.State == claude.OAuthStateURL:
148− return m, tea.Sequence(
147+ return m, tea.Sequence(
149− tea.SetClipboard(m.claudeOAuth2.URL),
148+ tea.SetClipboard(m.claudeOAuth2.URL),
150− func() tea.Msg {
149+ func() tea.Msg {
151− _ = clipboard.WriteAll(m.claudeOAuth2.URL)
150+ _ = clipboard.WriteAll(m.claudeOAuth2.URL)
152− return nil
151+ return nil
153− },
152+ },
154− util.ReportInfo("URL copied to clipboard"),
153+ util.ReportInfo("URL copied to clipboard"),
155− )
154+ )
156− }
157 case key.Matches(msg, m.keyMap.Choose) && m.showClaudeAuthMethodChooser:
155 case key.Matches(msg, m.keyMap.Choose) && m.showClaudeAuthMethodChooser:
158 m.claudeAuthMethodChooser.ToggleChoice()
156 m.claudeAuthMethodChooser.ToggleChoice()
159 return m, nil
157 return m, nil
internal/tui/util/shell.go+12−44
@@ -2,57 +2,25 @@ package util
2
2
3import (
3import (
4 "context"
4 "context"
5− "io"
5+ "errors"
6− "strings"
6+ "os/exec"
7
7
8 tea "charm.land/bubbletea/v2"
8 tea "charm.land/bubbletea/v2"
9− "mvdan.cc/sh/v3/interp"
9+ "mvdan.cc/sh/v3/shell"
10− "mvdan.cc/sh/v3/syntax"
11)
10)
12
11
13−// shellCommand wraps a shell interpreter to implement tea.ExecCommand.
14−type shellCommand struct {
15− ctx context.Context
16− file *syntax.File
17− stdin io.Reader
18− stdout io.Writer
19− stderr io.Writer
20−}
21−
22−func (s *shellCommand) SetStdin(r io.Reader) {
23− s.stdin = r
24−}
25−
26−func (s *shellCommand) SetStdout(w io.Writer) {
27− s.stdout = w
28−}
29−
30−func (s *shellCommand) SetStderr(w io.Writer) {
31− s.stderr = w
32−}
33−
34−func (s *shellCommand) Run() error {
35− runner, err := interp.New(
36− interp.StdIO(s.stdin, s.stdout, s.stderr),
37− )
38− if err != nil {
39− return err
40− }
41− return runner.Run(s.ctx, s.file)
42−}
43−
44−// ExecShell executes a shell command string using tea.Exec.
12+// ExecShell parses a shell command string and executes it with exec.Command.
45−// The command is parsed and executed via mvdan.cc/sh/v3/interp, allowing
46−// proper handling of shell syntax like quotes and arguments.
13+// Uses shell.Fields for proper handling of shell syntax like quotes and
14+// arguments while preserving TTY handling for terminal editors.
47func ExecShell(ctx context.Context, cmdStr string, callback tea.ExecCallback) tea.Cmd {
15func ExecShell(ctx context.Context, cmdStr string, callback tea.ExecCallback) tea.Cmd {
48− parsed, err := syntax.NewParser().Parse(strings.NewReader(cmdStr), "")
16+ fields, err := shell.Fields(cmdStr, nil)
49 if err != nil {
17 if err != nil {
50 return ReportError(err)
18 return ReportError(err)
51 }
19 }
52−
20+ if len(fields) == 0 {
53− cmd := &shellCommand{
21+ return ReportError(errors.New("empty command"))
54− ctx: ctx,
55− file: parsed,
56 }
22 }
23+
24+ cmd := exec.CommandContext(ctx, fields[0], fields[1:]...)
57− return tea.Exec(cmd, callback)
25+ return tea.ExecProcess(cmd, callback)
58}
26}