feat: open Hyper auth dialog automatically on unauthorized error

Andrey Nering created

When Hyper returns a 401, publish a TypeReAuthenticate notification so the
UI opens the OAuth dialog. Also fix coordinator.isUnauthorized to recognize
hyper.ErrUnauthorized for auto-refresh.

💘 Generated with Crush

Assisted-by: Z.ai: GLM 5.1 via Crush <crush@charm.land>

Change summary

internal/agent/agent.go         |  8 ++++++++
internal/agent/coordinator.go   |  3 ++-
internal/agent/notify/notify.go |  4 ++++
internal/ui/model/ui.go         | 18 ++++++++++++++++++
4 files changed, 32 insertions(+), 1 deletion(-)

Detailed changes

internal/agent/agent.go 🔗

@@ -531,6 +531,14 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 			currentAssistant.AddFinish(message.FinishReasonPermissionDenied, "User denied permission", "")
 		} else if errors.Is(err, hyper.ErrUnauthorized) {
 			currentAssistant.AddFinish(message.FinishReasonError, "Unauthorized", `Authentication with Hyper failed. Please run "crush auth" to re-authenticate.`)
+			if a.notify != nil {
+				a.notify.Publish(pubsub.CreatedEvent, notify.Notification{
+					SessionID:    call.SessionID,
+					SessionTitle: currentSession.Title,
+					Type:         notify.TypeReAuthenticate,
+					ProviderID:   largeModel.ModelCfg.Provider,
+				})
+			}
 		} else if errors.Is(err, hyper.ErrNoCredits) {
 			url := hyper.BaseURL()
 			link := linkStyle.Hyperlink(url, "id=hyper").Render(url)

internal/agent/coordinator.go 🔗

@@ -921,7 +921,8 @@ func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
 
 func (c *coordinator) isUnauthorized(err error) bool {
 	var providerErr *fantasy.ProviderError
-	return errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized
+	return (errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized) ||
+		errors.Is(err, hyper.ErrUnauthorized)
 }
 
 func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {

internal/agent/notify/notify.go 🔗

@@ -9,6 +9,9 @@ type Type string
 const (
 	// TypeAgentFinished indicates the agent has completed its turn.
 	TypeAgentFinished Type = "agent_finished"
+	// TypeReAuthenticate indicates the agent encountered an
+	// authentication error and the user needs to re-authenticate.
+	TypeReAuthenticate Type = "re_authenticate"
 )
 
 // Notification represents a domain event published by the agent.
@@ -16,4 +19,5 @@ type Notification struct {
 	SessionID    string
 	SessionTitle string
 	Type         Type
+	ProviderID   string
 }

internal/ui/model/ui.go 🔗

@@ -3203,11 +3203,29 @@ func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
 			Title:   "Crush is waiting...",
 			Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
 		})
+	case notify.TypeReAuthenticate:
+		return m.handleReAuthenticate(n.ProviderID)
 	default:
 		return nil
 	}
 }
 
+func (m *UI) handleReAuthenticate(providerID string) tea.Cmd {
+	cfg := m.com.Config()
+	if cfg == nil {
+		return nil
+	}
+	providerCfg, ok := cfg.Providers.Get(providerID)
+	if !ok {
+		return nil
+	}
+	agentCfg, ok := cfg.Agents[config.AgentCoder]
+	if !ok {
+		return nil
+	}
+	return m.openAuthenticationDialog(providerCfg.ToProvider(), cfg.Models[agentCfg.Model], agentCfg.Model)
+}
+
 // newSession clears the current session state and prepares for a new session.
 // The actual session creation happens when the user sends their first message.
 // Returns a command to reload prompt history.