package tui import ( "fmt" "strings" "time" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // oauthProvider represents an OAuth provider option. type oauthProvider struct { name string apiPath string // management API path emoji string } var oauthProviders = []oauthProvider{ {"Gemini CLI", "gemini-cli-auth-url", "🟦"}, {"Claude (Anthropic)", "anthropic-auth-url", "🟧"}, {"Codex (OpenAI)", "codex-auth-url", "🟩"}, {"Antigravity", "antigravity-auth-url", "🟪"}, {"Qwen", "qwen-auth-url", "🟨"}, {"Kimi", "kimi-auth-url", "🟫"}, {"IFlow", "iflow-auth-url", "⬜"}, } // oauthTabModel handles OAuth login flows. type oauthTabModel struct { client *Client viewport viewport.Model cursor int state oauthState message string err error width int height int ready bool // Remote browser mode authURL string // auth URL to display authState string // OAuth state parameter providerName string // current provider name callbackInput textinput.Model inputActive bool // true when user is typing callback URL } type oauthState int const ( oauthIdle oauthState = iota oauthPending oauthRemote // remote browser mode: waiting for manual callback oauthSuccess oauthError ) // Messages type oauthStartMsg struct { url string state string providerName string err error } type oauthPollMsg struct { done bool message string err error } type oauthCallbackSubmitMsg struct { err error } func newOAuthTabModel(client *Client) oauthTabModel { ti := textinput.New() ti.Placeholder = "http://localhost:.../auth/callback?code=...&state=..." ti.CharLimit = 2048 ti.Prompt = " 回调 URL: " return oauthTabModel{ client: client, callbackInput: ti, } } func (m oauthTabModel) Init() tea.Cmd { return nil } func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) { switch msg := msg.(type) { case localeChangedMsg: m.viewport.SetContent(m.renderContent()) return m, nil case oauthStartMsg: if msg.err != nil { m.state = oauthError m.err = msg.err m.message = errorStyle.Render("✗ " + msg.err.Error()) m.viewport.SetContent(m.renderContent()) return m, nil } m.authURL = msg.url m.authState = msg.state m.providerName = msg.providerName m.state = oauthRemote m.callbackInput.SetValue("") m.callbackInput.Focus() m.inputActive = true m.message = "" m.viewport.SetContent(m.renderContent()) // Also start polling in the background return m, tea.Batch(textinput.Blink, m.pollOAuthStatus(msg.state)) case oauthPollMsg: if msg.err != nil { m.state = oauthError m.err = msg.err m.message = errorStyle.Render("✗ " + msg.err.Error()) m.inputActive = false m.callbackInput.Blur() } else if msg.done { m.state = oauthSuccess m.message = successStyle.Render("✓ " + msg.message) m.inputActive = false m.callbackInput.Blur() } else { m.message = warningStyle.Render("⏳ " + msg.message) } m.viewport.SetContent(m.renderContent()) return m, nil case oauthCallbackSubmitMsg: if msg.err != nil { m.message = errorStyle.Render(T("oauth_submit_fail") + ": " + msg.err.Error()) } else { m.message = successStyle.Render(T("oauth_submit_ok")) } m.viewport.SetContent(m.renderContent()) return m, nil case tea.KeyMsg: // ---- Input active: typing callback URL ---- if m.inputActive { switch msg.String() { case "enter": callbackURL := m.callbackInput.Value() if callbackURL == "" { return m, nil } m.inputActive = false m.callbackInput.Blur() m.message = warningStyle.Render(T("oauth_submitting")) m.viewport.SetContent(m.renderContent()) return m, m.submitCallback(callbackURL) case "esc": m.inputActive = false m.callbackInput.Blur() m.viewport.SetContent(m.renderContent()) return m, nil default: var cmd tea.Cmd m.callbackInput, cmd = m.callbackInput.Update(msg) m.viewport.SetContent(m.renderContent()) return m, cmd } } // ---- Remote mode but not typing ---- if m.state == oauthRemote { switch msg.String() { case "c", "C": // Re-activate input m.inputActive = true m.callbackInput.Focus() m.viewport.SetContent(m.renderContent()) return m, textinput.Blink case "esc": m.state = oauthIdle m.message = "" m.authURL = "" m.authState = "" m.viewport.SetContent(m.renderContent()) return m, nil } var cmd tea.Cmd m.viewport, cmd = m.viewport.Update(msg) return m, cmd } // ---- Pending (auto polling) ---- if m.state == oauthPending { if msg.String() == "esc" { m.state = oauthIdle m.message = "" m.viewport.SetContent(m.renderContent()) } return m, nil } // ---- Idle ---- switch msg.String() { case "up", "k": if m.cursor > 0 { m.cursor-- m.viewport.SetContent(m.renderContent()) } return m, nil case "down", "j": if m.cursor < len(oauthProviders)-1 { m.cursor++ m.viewport.SetContent(m.renderContent()) } return m, nil case "enter": if m.cursor >= 0 && m.cursor < len(oauthProviders) { provider := oauthProviders[m.cursor] m.state = oauthPending m.message = warningStyle.Render(fmt.Sprintf(T("oauth_initiating"), provider.name)) m.viewport.SetContent(m.renderContent()) return m, m.startOAuth(provider) } return m, nil case "esc": m.state = oauthIdle m.message = "" m.err = nil m.viewport.SetContent(m.renderContent()) return m, nil } var cmd tea.Cmd m.viewport, cmd = m.viewport.Update(msg) return m, cmd } var cmd tea.Cmd m.viewport, cmd = m.viewport.Update(msg) return m, cmd } func (m oauthTabModel) startOAuth(provider oauthProvider) tea.Cmd { return func() tea.Msg { // Call the auth URL endpoint with is_webui=true data, err := m.client.getJSON("/v0/management/" + provider.apiPath + "?is_webui=true") if err != nil { return oauthStartMsg{err: fmt.Errorf("failed to start %s login: %w", provider.name, err)} } authURL := getString(data, "url") state := getString(data, "state") if authURL == "" { return oauthStartMsg{err: fmt.Errorf("no auth URL returned for %s", provider.name)} } // Try to open browser (best effort) _ = openBrowser(authURL) return oauthStartMsg{url: authURL, state: state, providerName: provider.name} } } func (m oauthTabModel) submitCallback(callbackURL string) tea.Cmd { return func() tea.Msg { // Determine provider from current context providerKey := "" for _, p := range oauthProviders { if p.name == m.providerName { // Map provider name to the canonical key the API expects switch p.apiPath { case "gemini-cli-auth-url": providerKey = "gemini" case "anthropic-auth-url": providerKey = "anthropic" case "codex-auth-url": providerKey = "codex" case "antigravity-auth-url": providerKey = "antigravity" case "qwen-auth-url": providerKey = "qwen" case "kimi-auth-url": providerKey = "kimi" case "iflow-auth-url": providerKey = "iflow" } break } } body := map[string]string{ "provider": providerKey, "redirect_url": callbackURL, "state": m.authState, } err := m.client.postJSON("/v0/management/oauth-callback", body) if err != nil { return oauthCallbackSubmitMsg{err: err} } return oauthCallbackSubmitMsg{} } } func (m oauthTabModel) pollOAuthStatus(state string) tea.Cmd { return func() tea.Msg { // Poll session status for up to 5 minutes deadline := time.Now().Add(5 * time.Minute) for { if time.Now().After(deadline) { return oauthPollMsg{done: false, err: fmt.Errorf("%s", T("oauth_timeout"))} } time.Sleep(2 * time.Second) status, errMsg, err := m.client.GetAuthStatus(state) if err != nil { continue // Ignore transient errors } switch status { case "ok": return oauthPollMsg{ done: true, message: T("oauth_success"), } case "error": return oauthPollMsg{ done: false, err: fmt.Errorf("%s: %s", T("oauth_failed"), errMsg), } case "wait": continue default: return oauthPollMsg{ done: true, message: T("oauth_completed"), } } } } } func (m *oauthTabModel) SetSize(w, h int) { m.width = w m.height = h m.callbackInput.Width = w - 16 if !m.ready { m.viewport = viewport.New(w, h) m.viewport.SetContent(m.renderContent()) m.ready = true } else { m.viewport.Width = w m.viewport.Height = h } } func (m oauthTabModel) View() string { if !m.ready { return T("loading") } return m.viewport.View() } func (m oauthTabModel) renderContent() string { var sb strings.Builder sb.WriteString(titleStyle.Render(T("oauth_title"))) sb.WriteString("\n\n") if m.message != "" { sb.WriteString(" " + m.message) sb.WriteString("\n\n") } // ---- Remote browser mode ---- if m.state == oauthRemote { sb.WriteString(m.renderRemoteMode()) return sb.String() } if m.state == oauthPending { sb.WriteString(helpStyle.Render(T("oauth_press_esc"))) return sb.String() } sb.WriteString(helpStyle.Render(T("oauth_select"))) sb.WriteString("\n\n") for i, p := range oauthProviders { isSelected := i == m.cursor prefix := " " if isSelected { prefix = "▸ " } label := fmt.Sprintf("%s %s", p.emoji, p.name) if isSelected { label = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Background(colorPrimary).Padding(0, 1).Render(label) } else { label = lipgloss.NewStyle().Foreground(colorText).Padding(0, 1).Render(label) } sb.WriteString(prefix + label + "\n") } sb.WriteString("\n") sb.WriteString(helpStyle.Render(T("oauth_help"))) return sb.String() } func (m oauthTabModel) renderRemoteMode() string { var sb strings.Builder providerStyle := lipgloss.NewStyle().Bold(true).Foreground(colorHighlight) sb.WriteString(providerStyle.Render(fmt.Sprintf(" ✦ %s OAuth", m.providerName))) sb.WriteString("\n\n") // Auth URL section sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(T("oauth_auth_url"))) sb.WriteString("\n") // Wrap URL to fit terminal width urlStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("252")) maxURLWidth := m.width - 6 if maxURLWidth < 40 { maxURLWidth = 40 } wrappedURL := wrapText(m.authURL, maxURLWidth) for _, line := range wrappedURL { sb.WriteString(" " + urlStyle.Render(line) + "\n") } sb.WriteString("\n") sb.WriteString(helpStyle.Render(T("oauth_remote_hint"))) sb.WriteString("\n\n") // Callback URL input sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(T("oauth_callback_url"))) sb.WriteString("\n") if m.inputActive { sb.WriteString(m.callbackInput.View()) sb.WriteString("\n") sb.WriteString(helpStyle.Render(" " + T("enter_submit") + " • " + T("esc_cancel"))) } else { sb.WriteString(helpStyle.Render(T("oauth_press_c"))) } sb.WriteString("\n\n") sb.WriteString(warningStyle.Render(T("oauth_waiting"))) return sb.String() } // wrapText splits a long string into lines of at most maxWidth characters. func wrapText(s string, maxWidth int) []string { if maxWidth <= 0 { return []string{s} } var lines []string for len(s) > maxWidth { lines = append(lines, s[:maxWidth]) s = s[maxWidth:] } if len(s) > 0 { lines = append(lines, s) } return lines }