Merge branch 'main' into ui

Ayman Bagabas created

Change summary

.github/cla-signatures.json                      | 16 +++++
go.mod                                           |  6 
go.sum                                           | 12 +-
internal/agent/coordinator.go                    | 16 ++++
internal/config/config.go                        | 38 ++++++++++++
internal/tui/components/chat/editor/editor.go    |  7 -
internal/tui/components/dialogs/models/models.go | 20 ++---
internal/tui/util/shell.go                       | 56 +++--------------
8 files changed, 101 insertions(+), 70 deletions(-)

Detailed changes

.github/cla-signatures.json 🔗

@@ -879,6 +879,22 @@
       "created_at": "2025-11-22T05:23:17Z",
       "repoId": 987670088,
       "pullRequestNo": 1496
+    },
+    {
+      "name": "masroor-ahmad",
+      "id": 75073229,
+      "comment_id": 3592527587,
+      "created_at": "2025-11-30T12:55:09Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1532
+    },
+    {
+      "name": "thezbm",
+      "id": 24851999,
+      "comment_id": 3595049411,
+      "created_at": "2025-12-01T07:39:02Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1534
     }
   ]
 }

go.mod 🔗

@@ -4,7 +4,7 @@ go 1.25.0
 
 require (
 	charm.land/bubbles/v2 v2.0.0-rc.1
-	charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251124184313-5de0f1f67562
+	charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251126220703-2a0096c500a7
 	charm.land/fantasy v0.3.2
 	charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251119143523-0334bb4562ca
 	charm.land/x/vcr v0.1.1
@@ -22,7 +22,7 @@ require (
 	github.com/charmbracelet/glamour/v2 v2.0.0-20251106195642-800eb8175930
 	github.com/charmbracelet/log/v2 v2.0.0-20251106192421-eb64aaa963a0
 	github.com/charmbracelet/ultraviolet v0.0.0-20251116181749-377898bcce38
-	github.com/charmbracelet/x/ansi v0.11.1
+	github.com/charmbracelet/x/ansi v0.11.2
 	github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
 	github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
 	github.com/charmbracelet/x/exp/ordered v0.1.0
@@ -92,7 +92,7 @@ require (
 	github.com/charmbracelet/x/json v0.2.0 // indirect
 	github.com/charmbracelet/x/termios v0.1.1 // indirect
 	github.com/charmbracelet/x/windows v0.2.2 // indirect
-	github.com/clipperhouse/displaywidth v0.5.0 // indirect
+	github.com/clipperhouse/displaywidth v0.6.0 // indirect
 	github.com/clipperhouse/stringish v0.1.1 // indirect
 	github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect

go.sum 🔗

@@ -1,7 +1,7 @@
 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.20251124184313-5de0f1f67562 h1:61aovinon0nJLTQBCZZhg5dgHhnO/jiqdBTeYwclAkY=
-charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251124184313-5de0f1f67562/go.mod h1:IXFmnCnMLTWw/KQ9rEatSYqbAPAYi8kA3Yqwa1SFnLk=
+charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251126220703-2a0096c500a7 h1:3qsObfEm0WuACFhe3MSTPX8QByjVcjWkZDO4o2VWFpc=
+charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251126220703-2a0096c500a7/go.mod h1:IXFmnCnMLTWw/KQ9rEatSYqbAPAYi8kA3Yqwa1SFnLk=
 charm.land/fantasy v0.3.2 h1:yHTsSZ25LcICMRw3xzdz3OkaZtDQch+B5ljJo17HxgU=
 charm.land/fantasy v0.3.2/go.mod h1:sV8Ns/JTJHOaYOHPgVRDugMheAyxsW/nmdpVGrycYEk=
 charm.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
 github.com/charmbracelet/log/v2 v2.0.0-20251106192421-eb64aaa963a0/go.mod h1:Q7oMtlboDPnnrYiJDXNwdWmJblOmuOnycPKczlVju6I=
 github.com/charmbracelet/ultraviolet v0.0.0-20251116181749-377898bcce38 h1:7Rs87fbKJoIIxsQS8YKJYGYa0tlsDwwb0twQjV1KB+g=
 github.com/charmbracelet/ultraviolet v0.0.0-20251116181749-377898bcce38/go.mod h1:6lfcr3MNP+kZR25sF1nQwJFuQnNYBlFy3PGX5rvslXc=
-github.com/charmbracelet/x/ansi v0.11.1 h1:iXAC8SyMQDJgtcz9Jnw+HU8WMEctHzoTAETIeA3JXMk=
-github.com/charmbracelet/x/ansi v0.11.1/go.mod h1:M49wjzpIujwPceJ+t5w3qh2i87+HRtHohgb5iTyepL0=
+github.com/charmbracelet/x/ansi v0.11.2 h1:XAG3FSjiVtFvgEgGrNBkCNNYrsucAt8c6bfxHyROLLs=
+github.com/charmbracelet/x/ansi v0.11.2/go.mod h1:9tY2bzX5SiJCU0iWyskjBeI2BRQfvPqI+J760Mjf+Rg=
 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1xwHZg6eMZ9Wv5TE1UGub6ARubyOd1Lo5kPUI/6VL50=
 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
@@ -120,8 +120,8 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8
 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
 github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
 github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
-github.com/clipperhouse/displaywidth v0.5.0 h1:AIG5vQaSL2EKqzt0M9JMnvNxOCRTKUc4vUnLWGgP89I=
-github.com/clipperhouse/displaywidth v0.5.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
+github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s=
+github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
 github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
 github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
 github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=

internal/agent/coordinator.go 🔗

@@ -130,7 +130,20 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
 
 	mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
 
-	return c.currentAgent.Run(ctx, SessionAgentCall{
+	if providerCfg.OAuthToken != nil && providerCfg.OAuthToken.IsExpired() {
+		slog.Info("Detected expired OAuth token, attempting refresh", "provider", providerCfg.ID)
+		if refreshErr := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); refreshErr != nil {
+			slog.Error("Failed to refresh OAuth token", "provider", providerCfg.ID, "error", refreshErr)
+			return nil, refreshErr
+		}
+
+		// Rebuild models with refreshed token
+		if updateErr := c.UpdateModels(ctx); updateErr != nil {
+			slog.Error("Failed to update models after token refresh", "error", updateErr)
+			return nil, updateErr
+		}
+	}
+	result, err := c.currentAgent.Run(ctx, SessionAgentCall{
 		SessionID:        sessionID,
 		Prompt:           prompt,
 		Attachments:      attachments,
@@ -142,6 +155,7 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
 		FrequencyPenalty: freqPenalty,
 		PresencePenalty:  presPenalty,
 	})
+	return result, err
 }
 
 func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions {

internal/config/config.go 🔗

@@ -16,6 +16,7 @@ import (
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/env"
 	"github.com/charmbracelet/crush/internal/oauth"
+	"github.com/charmbracelet/crush/internal/oauth/claude"
 	"github.com/invopop/jsonschema"
 	"github.com/tidwall/sjson"
 )
@@ -468,6 +469,43 @@ func (c *Config) SetConfigField(key string, value any) error {
 	return nil
 }
 
+func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error {
+	providerConfig, exists := c.Providers.Get(providerID)
+	if !exists {
+		return fmt.Errorf("provider %s not found", providerID)
+	}
+
+	if providerConfig.OAuthToken == nil {
+		return fmt.Errorf("provider %s does not have an OAuth token", providerID)
+	}
+
+	// Only Anthropic provider uses OAuth for now
+	if providerID != string(catwalk.InferenceProviderAnthropic) {
+		return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
+	}
+
+	newToken, err := claude.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
+	if err != nil {
+		return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, err)
+	}
+
+	slog.Info("Successfully refreshed OAuth token in background", "provider", providerID)
+	providerConfig.OAuthToken = newToken
+	providerConfig.APIKey = fmt.Sprintf("Bearer %s", newToken.AccessToken)
+	providerConfig.SetupClaudeCode()
+
+	c.Providers.Set(providerID, providerConfig)
+
+	if err := cmp.Or(
+		c.SetConfigField(fmt.Sprintf("providers.%s.api_key", providerID), newToken.AccessToken),
+		c.SetConfigField(fmt.Sprintf("providers.%s.oauth", providerID), newToken),
+	); err != nil {
+		return fmt.Errorf("failed to persist refreshed token: %w", err)
+	}
+
+	return nil
+}
+
 func (c *Config) SetProviderAPIKey(providerID string, apiKey any) error {
 	var providerConfig ProviderConfig
 	var exists bool

internal/tui/components/chat/editor/editor.go 🔗

@@ -95,7 +95,7 @@ type OpenEditorMsg struct {
 func (m *editorCmp) openEditor(value string) tea.Cmd {
 	editor := os.Getenv("EDITOR")
 	if editor == "" {
-		// Use platform-appropriate default editor.
+		// Use platform-appropriate default editor
 		if runtime.GOOS == "windows" {
 			editor = "notepad"
 		} else {
@@ -111,10 +111,7 @@ func (m *editorCmp) openEditor(value string) tea.Cmd {
 	if _, err := tmpfile.WriteString(value); err != nil {
 		return util.ReportError(err)
 	}
-
-	// Build the full shell command with the file argument.
-	cmdStr := fmt.Sprintf("%s %s", editor, tmpfile.Name())
-
+	cmdStr := editor + " " + tmpfile.Name()
 	return util.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg {
 		if err != nil {
 			return util.ReportError(err)

internal/tui/components/dialogs/models/models.go 🔗

@@ -143,17 +143,15 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		return m, util.CmdHandler(dialogs.CloseDialogMsg{})
 	case tea.KeyPressMsg:
 		switch {
-		case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))):
-			if m.showClaudeOAuth2 && m.claudeOAuth2.State == claude.OAuthStateURL {
-				return m, tea.Sequence(
-					tea.SetClipboard(m.claudeOAuth2.URL),
-					func() tea.Msg {
-						_ = clipboard.WriteAll(m.claudeOAuth2.URL)
-						return nil
-					},
-					util.ReportInfo("URL copied to clipboard"),
-				)
-			}
+		case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showClaudeOAuth2 && m.claudeOAuth2.State == claude.OAuthStateURL:
+			return m, tea.Sequence(
+				tea.SetClipboard(m.claudeOAuth2.URL),
+				func() tea.Msg {
+					_ = clipboard.WriteAll(m.claudeOAuth2.URL)
+					return nil
+				},
+				util.ReportInfo("URL copied to clipboard"),
+			)
 		case key.Matches(msg, m.keyMap.Choose) && m.showClaudeAuthMethodChooser:
 			m.claudeAuthMethodChooser.ToggleChoice()
 			return m, nil

internal/tui/util/shell.go 🔗

@@ -2,57 +2,25 @@ package util
 
 import (
 	"context"
-	"io"
-	"strings"
+	"errors"
+	"os/exec"
 
 	tea "charm.land/bubbletea/v2"
-	"mvdan.cc/sh/v3/interp"
-	"mvdan.cc/sh/v3/syntax"
+	"mvdan.cc/sh/v3/shell"
 )
 
-// shellCommand wraps a shell interpreter to implement tea.ExecCommand.
-type shellCommand struct {
-	ctx    context.Context
-	file   *syntax.File
-	stdin  io.Reader
-	stdout io.Writer
-	stderr io.Writer
-}
-
-func (s *shellCommand) SetStdin(r io.Reader) {
-	s.stdin = r
-}
-
-func (s *shellCommand) SetStdout(w io.Writer) {
-	s.stdout = w
-}
-
-func (s *shellCommand) SetStderr(w io.Writer) {
-	s.stderr = w
-}
-
-func (s *shellCommand) Run() error {
-	runner, err := interp.New(
-		interp.StdIO(s.stdin, s.stdout, s.stderr),
-	)
-	if err != nil {
-		return err
-	}
-	return runner.Run(s.ctx, s.file)
-}
-
-// ExecShell executes a shell command string using tea.Exec.
-// The command is parsed and executed via mvdan.cc/sh/v3/interp, allowing
-// proper handling of shell syntax like quotes and arguments.
+// ExecShell parses a shell command string and executes it with exec.Command.
+// Uses shell.Fields for proper handling of shell syntax like quotes and
+// arguments while preserving TTY handling for terminal editors.
 func ExecShell(ctx context.Context, cmdStr string, callback tea.ExecCallback) tea.Cmd {
-	parsed, err := syntax.NewParser().Parse(strings.NewReader(cmdStr), "")
+	fields, err := shell.Fields(cmdStr, nil)
 	if err != nil {
 		return ReportError(err)
 	}
-
-	cmd := &shellCommand{
-		ctx:  ctx,
-		file: parsed,
+	if len(fields) == 0 {
+		return ReportError(errors.New("empty command"))
 	}
-	return tea.Exec(cmd, callback)
+
+	cmd := exec.CommandContext(ctx, fields[0], fields[1:]...)
+	return tea.ExecProcess(cmd, callback)
 }