@@ -137,6 +137,13 @@ type (
137137 // closeDialogMsg is sent to close the current dialog.
138138 closeDialogMsg struct{}
139139
140+ // hyperRefreshDoneMsg is sent after a silent Hyper OAuth refresh
141+ // finishes. It carries the original model-selection action so the
142+ // selection can be resumed.
143+ hyperRefreshDoneMsg struct {
144+ action dialog.ActionSelectModel
145+ }
146+
140147 // copyChatHighlightMsg is sent to copy the current chat highlight to clipboard.
141148 copyChatHighlightMsg struct{}
142149
@@ -843,6 +850,10 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
843850 m.textarea.SetValue(msg.Text)
844851 m.textarea.MoveToEnd()
845852 cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
853+ case hyperRefreshDoneMsg:
854+ if cmd := m.handleSelectModel(msg.action); cmd != nil {
855+ cmds = append(cmds, cmd)
856+ }
846857 case util.InfoMsg:
847858 if msg.Type == util.InfoTypeError {
848859 slog.Error("Error reported", "error", msg.Msg)
@@ -1429,73 +1440,8 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
14291440 m.dialog.CloseDialog(dialog.CommandsID)
14301441
14311442 case dialog.ActionSelectModel:
1432− if m.isAgentBusy() {
1433− cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1434− break
1435− }
1436−
1437− cfg := m.com.Config()
1438− if cfg == nil {
1439− cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
1440− break
1441− }
1442−
1443− var (
1444− providerID = msg.Model.Provider
1445− isCopilot = providerID == string(catwalk.InferenceProviderCopilot)
1446− isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
1447− )
1448−
1449− // Attempt to import GitHub Copilot tokens from VSCode if available.
1450− if isCopilot && !isConfigured() && !msg.ReAuthenticate {
1451− m.com.Workspace.ImportCopilot()
1452− }
1453−
1454− if !isConfigured() || msg.ReAuthenticate {
1455− m.dialog.CloseDialog(dialog.ModelsID)
1456− if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
1457− cmds = append(cmds, cmd)
1458− }
1459− break
1460− }
1461−
1462− if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
1463− cmds = append(cmds, util.ReportError(err))
1464− } else {
1465− if msg.ModelType == config.SelectedModelTypeLarge {
1466− // Swap the theme live based on the newly selected large
1467− // model's provider.
1468− m.applyTheme(styles.ThemeForProvider(providerID))
1469− }
1470− if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
1471− // Ensure small model is set is unset.
1472− smallModel := m.com.Workspace.GetDefaultSmallModel(providerID)
1473− if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
1474− cmds = append(cmds, util.ReportError(err))
1475− }
1476− }
1477− }
1478−
1479− cmds = append(cmds, func() tea.Msg {
1480− if err := m.com.Workspace.UpdateAgentModel(context.TODO()); err != nil {
1481− return util.ReportError(err)
1482− }
1483−
1484− modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
1485−
1486− return util.NewInfoMsg(modelMsg)
1487− })
1488−
1489− m.dialog.CloseDialog(dialog.APIKeyInputID)
1490− m.dialog.CloseDialog(dialog.OAuthID)
1491− m.dialog.CloseDialog(dialog.ModelsID)
1492−
1493− if isOnboarding {
1494− m.setState(uiLanding, uiFocusEditor)
1495− m.com.Config().SetupAgents()
1496− if err := m.com.Workspace.InitCoderAgent(context.TODO()); err != nil {
1497− cmds = append(cmds, util.ReportError(err))
1498− }
1443+ if cmd := m.handleSelectModel(msg); cmd != nil {
1444+ cmds = append(cmds, cmd)
14991445 }
15001446 case dialog.ActionSelectReasoningEffort:
15011447 if m.isAgentBusy() {
@@ -1601,6 +1547,108 @@ func substituteArgs(content string, args map[string]string) string {
16011547 return content
16021548}
16031549
1550+// refreshHyperAndRetrySelect returns a command that silently refreshes
1551+// the Hyper OAuth token and then re-runs the model selection. If the
1552+// refresh fails, the selection resumes with ReAuthenticate set so the
1553+// OAuth dialog opens.
1554+func (m *UI) refreshHyperAndRetrySelect(msg dialog.ActionSelectModel) tea.Cmd {
1555+ return func() tea.Msg {
1556+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
1557+ defer cancel()
1558+ if err := m.com.Workspace.RefreshOAuthToken(ctx, config.ScopeGlobal, "hyper"); err != nil {
1559+ slog.Warn("Hyper OAuth refresh failed, requesting re-auth", "error", err)
1560+ msg.ReAuthenticate = true
1561+ }
1562+ return hyperRefreshDoneMsg{action: msg}
1563+ }
1564+}
1565+
1566+// handleSelectModel performs the model selection after any provider
1567+// pre-checks (such as a silent Hyper OAuth refresh) have completed.
1568+func (m *UI) handleSelectModel(msg dialog.ActionSelectModel) tea.Cmd {
1569+ var cmds []tea.Cmd
1570+
1571+ if m.isAgentBusy() {
1572+ return util.ReportWarn("Agent is busy, please wait...")
1573+ }
1574+
1575+ cfg := m.com.Config()
1576+ if cfg == nil {
1577+ return util.ReportError(errors.New("configuration not found"))
1578+ }
1579+
1580+ var (
1581+ providerID = msg.Model.Provider
1582+ isCopilot = providerID == string(catwalk.InferenceProviderCopilot)
1583+ isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
1584+ isOnboarding = m.state == uiOnboarding
1585+ )
1586+
1587+ // For Hyper, if the stored OAuth token is expired, try a silent
1588+ // refresh before deciding whether the provider is configured. Keeps
1589+ // users from hitting a 401 on their first message after the
1590+ // short-lived access token ages out.
1591+ if !msg.ReAuthenticate && providerID == "hyper" {
1592+ if pc, ok := cfg.Providers.Get(providerID); ok && pc.OAuthToken != nil && pc.OAuthToken.IsExpired() {
1593+ return m.refreshHyperAndRetrySelect(msg)
1594+ }
1595+ }
1596+
1597+ // Attempt to import GitHub Copilot tokens from VSCode if available.
1598+ if isCopilot && !isConfigured() && !msg.ReAuthenticate {
1599+ m.com.Workspace.ImportCopilot()
1600+ }
1601+
1602+ if !isConfigured() || msg.ReAuthenticate {
1603+ m.dialog.CloseDialog(dialog.ModelsID)
1604+ if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
1605+ cmds = append(cmds, cmd)
1606+ }
1607+ return tea.Batch(cmds...)
1608+ }
1609+
1610+ if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
1611+ cmds = append(cmds, util.ReportError(err))
1612+ } else {
1613+ if msg.ModelType == config.SelectedModelTypeLarge {
1614+ // Swap the theme live based on the newly selected large
1615+ // model's provider.
1616+ m.applyTheme(styles.ThemeForProvider(providerID))
1617+ }
1618+ if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
1619+ // Ensure small model is set is unset.
1620+ smallModel := m.com.Workspace.GetDefaultSmallModel(providerID)
1621+ if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
1622+ cmds = append(cmds, util.ReportError(err))
1623+ }
1624+ }
1625+ }
1626+
1627+ cmds = append(cmds, func() tea.Msg {
1628+ if err := m.com.Workspace.UpdateAgentModel(context.TODO()); err != nil {
1629+ return util.ReportError(err)
1630+ }
1631+
1632+ modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
1633+
1634+ return util.NewInfoMsg(modelMsg)
1635+ })
1636+
1637+ m.dialog.CloseDialog(dialog.APIKeyInputID)
1638+ m.dialog.CloseDialog(dialog.OAuthID)
1639+ m.dialog.CloseDialog(dialog.ModelsID)
1640+
1641+ if isOnboarding {
1642+ m.setState(uiLanding, uiFocusEditor)
1643+ m.com.Config().SetupAgents()
1644+ if err := m.com.Workspace.InitCoderAgent(context.TODO()); err != nil {
1645+ cmds = append(cmds, util.ReportError(err))
1646+ }
1647+ }
1648+
1649+ return tea.Batch(cmds...)
1650+}
1651+
16041652func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
16051653 var (
16061654 dlg dialog.Dialog