diff --git a/src/plugin.ts b/src/plugin.ts index 80becc67..f1db4bb2 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,6 +1,13 @@ import { exec } from "node:child_process"; import { tool } from "@opencode-ai/plugin"; -import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_ENDPOINT_PROD, ANTIGRAVITY_PROVIDER_ID, type HeaderStyle } from "./constants"; +import { + ANTIGRAVITY_DEFAULT_PROJECT_ID, + ANTIGRAVITY_ENDPOINT_FALLBACKS, + ANTIGRAVITY_ENDPOINT_PROD, + ANTIGRAVITY_HEADERS, + ANTIGRAVITY_PROVIDER_ID, + type HeaderStyle, +} from "./constants"; import { authorizeAntigravity, exchangeAntigravity } from "./antigravity/oauth"; import type { AntigravityTokenExchangeResult } from "./antigravity/oauth"; import { accessTokenExpired, isOAuthAuth, parseRefreshParts, formatRefreshParts } from "./plugin/auth"; @@ -260,6 +267,405 @@ async function openBrowser(url: string): Promise { } } +type VerificationProbeResult = { + status: "ok" | "blocked" | "error"; + message: string; + verifyUrl?: string; +}; + +function decodeEscapedText(input: string): string { + return input + .replace(/&/g, "&") + .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16))); +} + +function normalizeGoogleVerificationUrl(rawUrl: string): string | undefined { + const normalized = decodeEscapedText(rawUrl).trim(); + if (!normalized) { + return undefined; + } + try { + const parsed = new URL(normalized); + if (parsed.hostname !== "accounts.google.com") { + return undefined; + } + return parsed.toString(); + } catch { + return undefined; + } +} + +function selectBestVerificationUrl(urls: string[]): string | undefined { + const unique = Array.from(new Set(urls.map((url) => normalizeGoogleVerificationUrl(url)).filter(Boolean) as string[])); + if (unique.length === 0) { + return undefined; + } + unique.sort((a, b) => { + const score = (value: string): number => { + let total = 0; + if (value.includes("plt=")) total += 4; + if (value.includes("/signin/continue")) total += 3; + if (value.includes("continue=")) total += 2; + if (value.includes("service=cloudcode")) total += 1; + return total; + }; + return score(b) - score(a); + }); + return unique[0]; +} + +function extractVerificationErrorDetails(bodyText: string): { + validationRequired: boolean; + message?: string; + verifyUrl?: string; +} { + const decodedBody = decodeEscapedText(bodyText); + const lowerBody = decodedBody.toLowerCase(); + let validationRequired = lowerBody.includes("validation_required"); + let message: string | undefined; + const verificationUrls = new Set(); + + const collectUrlsFromText = (text: string): void => { + for (const match of text.matchAll(/https:\/\/accounts\.google\.com\/[^\s"'<>]+/gi)) { + if (match[0]) { + verificationUrls.add(match[0]); + } + } + }; + + collectUrlsFromText(decodedBody); + + const payloads: unknown[] = []; + const trimmed = decodedBody.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + payloads.push(JSON.parse(trimmed)); + } catch { + } + } + + for (const rawLine of decodedBody.split("\n")) { + const line = rawLine.trim(); + if (!line.startsWith("data:")) { + continue; + } + const payloadText = line.slice(5).trim(); + if (!payloadText || payloadText === "[DONE]") { + continue; + } + try { + payloads.push(JSON.parse(payloadText)); + } catch { + collectUrlsFromText(payloadText); + } + } + + const visited = new Set(); + const walk = (value: unknown, key?: string): void => { + if (typeof value === "string") { + const normalizedValue = decodeEscapedText(value); + const lowerValue = normalizedValue.toLowerCase(); + const lowerKey = key?.toLowerCase() ?? ""; + + if (lowerValue.includes("validation_required")) { + validationRequired = true; + } + if ( + !message && + (lowerKey.includes("message") || lowerKey.includes("detail") || lowerKey.includes("description")) + ) { + message = normalizedValue; + } + if ( + lowerKey.includes("validation_url") || + lowerKey.includes("verify_url") || + lowerKey.includes("verification_url") || + lowerKey === "url" + ) { + verificationUrls.add(normalizedValue); + } + collectUrlsFromText(normalizedValue); + return; + } + + if (!value || typeof value !== "object" || visited.has(value)) { + return; + } + + visited.add(value); + + if (Array.isArray(value)) { + for (const item of value) { + walk(item); + } + return; + } + + for (const [childKey, childValue] of Object.entries(value as Record)) { + walk(childValue, childKey); + } + }; + + for (const payload of payloads) { + walk(payload); + } + + if (!validationRequired) { + validationRequired = + lowerBody.includes("verification required") || + lowerBody.includes("verify your account") || + lowerBody.includes("account verification"); + } + + if (!message) { + const fallback = decodedBody + .split("\n") + .map((line) => line.trim()) + .find((line) => line && !line.startsWith("data:") && /(verify|validation|required)/i.test(line)); + if (fallback) { + message = fallback; + } + } + + return { + validationRequired, + message, + verifyUrl: selectBestVerificationUrl([...verificationUrls]), + }; +} + +async function verifyAccountAccess( + account: { + refreshToken: string; + email?: string; + projectId?: string; + managedProjectId?: string; + }, + client: PluginClient, + providerId: string, +): Promise { + const parsed = parseRefreshParts(account.refreshToken); + if (!parsed.refreshToken) { + return { status: "error", message: "Missing refresh token for selected account." }; + } + + const auth = { + type: "oauth" as const, + refresh: formatRefreshParts({ + refreshToken: parsed.refreshToken, + projectId: parsed.projectId ?? account.projectId, + managedProjectId: parsed.managedProjectId ?? account.managedProjectId, + }), + access: "", + expires: 0, + }; + + let refreshedAuth: Awaited>; + try { + refreshedAuth = await refreshAccessToken(auth, client, providerId); + } catch (error) { + if (error instanceof AntigravityTokenRefreshError) { + return { status: "error", message: error.message }; + } + return { status: "error", message: `Token refresh failed: ${String(error)}` }; + } + + if (!refreshedAuth?.access) { + return { status: "error", message: "Could not refresh access token for this account." }; + } + + const projectId = + parsed.managedProjectId ?? + parsed.projectId ?? + account.managedProjectId ?? + account.projectId ?? + ANTIGRAVITY_DEFAULT_PROJECT_ID; + + const headers: Record = { + ...ANTIGRAVITY_HEADERS, + Authorization: `Bearer ${refreshedAuth.access}`, + "Content-Type": "application/json", + }; + if (projectId) { + headers["x-goog-user-project"] = projectId; + } + + const requestBody = { + model: "gemini-3-flash", + request: { + model: "gemini-3-flash", + contents: [{ role: "user", parts: [{ text: "ping" }] }], + generationConfig: { maxOutputTokens: 1, temperature: 0 }, + }, + }; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 20000); + + let response: Response; + try { + response = await fetch(`${ANTIGRAVITY_ENDPOINT_PROD}/v1internal:streamGenerateContent?alt=sse`, { + method: "POST", + headers, + body: JSON.stringify(requestBody), + signal: controller.signal, + }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + return { status: "error", message: "Verification check timed out." }; + } + return { status: "error", message: `Verification check failed: ${String(error)}` }; + } finally { + clearTimeout(timeoutId); + } + + let responseBody = ""; + try { + responseBody = await response.text(); + } catch { + responseBody = ""; + } + + if (response.ok) { + return { status: "ok", message: "Account verification check passed." }; + } + + const extracted = extractVerificationErrorDetails(responseBody); + if (response.status === 403 && extracted.validationRequired) { + return { + status: "blocked", + message: extracted.message ?? "Google requires additional account verification.", + verifyUrl: extracted.verifyUrl, + }; + } + + const fallbackMessage = extracted.message ?? `Request failed (${response.status} ${response.statusText}).`; + return { + status: "error", + message: fallbackMessage, + }; +} + +async function promptAccountIndexForVerification( + accounts: Array<{ email?: string; index: number }>, +): Promise { + const { createInterface } = await import("node:readline/promises"); + const { stdin, stdout } = await import("node:process"); + const rl = createInterface({ input: stdin, output: stdout }); + try { + console.log("\nSelect an account to verify:"); + for (const account of accounts) { + const label = account.email || `Account ${account.index + 1}`; + console.log(` ${account.index + 1}. ${label}`); + } + console.log(""); + + while (true) { + const answer = (await rl.question("Account number (leave blank to cancel): ")).trim(); + if (!answer) { + return undefined; + } + const parsedIndex = Number(answer); + if (!Number.isInteger(parsedIndex)) { + console.log("Please enter a valid account number."); + continue; + } + const normalizedIndex = parsedIndex - 1; + const selected = accounts.find((account) => account.index === normalizedIndex); + if (!selected) { + console.log("Please enter a number from the list above."); + continue; + } + return selected.index; + } + } finally { + rl.close(); + } +} + +async function promptOpenVerificationUrl(): Promise { + const answer = (await promptOAuthCallbackValue("Open verification URL in your browser now? [Y/n]: ")).trim().toLowerCase(); + return answer === "" || answer === "y" || answer === "yes"; +} + +type VerificationStoredAccount = { + enabled?: boolean; + verificationRequired?: boolean; + verificationRequiredAt?: number; + verificationRequiredReason?: string; + verificationUrl?: string; +}; + +function markStoredAccountVerificationRequired( + account: VerificationStoredAccount, + reason: string, + verifyUrl?: string, +): boolean { + let changed = false; + const wasVerificationRequired = account.verificationRequired === true; + + if (!wasVerificationRequired) { + account.verificationRequired = true; + changed = true; + } + + if (!wasVerificationRequired || account.verificationRequiredAt === undefined) { + account.verificationRequiredAt = Date.now(); + changed = true; + } + + const normalizedReason = reason.trim(); + if (account.verificationRequiredReason !== normalizedReason) { + account.verificationRequiredReason = normalizedReason; + changed = true; + } + + const normalizedUrl = verifyUrl?.trim(); + if (normalizedUrl && account.verificationUrl !== normalizedUrl) { + account.verificationUrl = normalizedUrl; + changed = true; + } + + if (account.enabled !== false) { + account.enabled = false; + changed = true; + } + + return changed; +} + +function clearStoredAccountVerificationRequired( + account: VerificationStoredAccount, + enableIfRequired = false, +): { changed: boolean; wasVerificationRequired: boolean } { + const wasVerificationRequired = account.verificationRequired === true; + let changed = false; + + if (account.verificationRequired !== false) { + account.verificationRequired = false; + changed = true; + } + if (account.verificationRequiredAt !== undefined) { + account.verificationRequiredAt = undefined; + changed = true; + } + if (account.verificationRequiredReason !== undefined) { + account.verificationRequiredReason = undefined; + changed = true; + } + if (account.verificationUrl !== undefined) { + account.verificationUrl = undefined; + changed = true; + } + + if (enableIfRequired && wasVerificationRequired && account.enabled === false) { + account.enabled = true; + changed = true; + } + + return { changed, wasVerificationRequired }; +} + async function promptOAuthCallbackValue(message: string): Promise { const { createInterface } = await import("node:readline/promises"); const { stdin, stdout } = await import("node:process"); @@ -1846,6 +2252,48 @@ export const createAntigravityPlugin = (providerId: string) => async ( resetRateLimitState(account.index, quotaKey); resetAccountFailureState(account.index); + if (response.status === 403) { + const errorBodyText = await response.clone().text().catch(() => ""); + const extracted = extractVerificationErrorDetails(errorBodyText); + + if (extracted.validationRequired) { + const verificationReason = extracted.message ?? "Google requires account verification."; + const cooldownMs = 10 * 60 * 1000; + + accountManager.markAccountVerificationRequired(account.index, verificationReason, extracted.verifyUrl); + accountManager.markAccountCoolingDown(account, cooldownMs, "validation-required"); + accountManager.markRateLimited(account, cooldownMs, family, headerStyle, model); + + const label = account.email || `Account ${account.index + 1}`; + if (accountManager.shouldShowAccountToast(account.index, 60000)) { + await showToast( + `⚠ ${label} needs verification. Run 'opencode auth login' and use Verify accounts.`, + "warning", + ); + accountManager.markToastShown(account.index); + } + + pushDebug(`verification-required: disabled account ${account.index}`); + getHealthTracker().recordFailure(account.index); + + lastFailure = { + response, + streaming: prepared.streaming, + debugContext, + requestedModel: prepared.requestedModel, + projectId: prepared.projectId, + endpoint: prepared.endpoint, + effectiveModel: prepared.effectiveModel, + sessionId: prepared.sessionId, + toolDebugMissing: prepared.toolDebugMissing, + toolDebugSummary: prepared.toolDebugSummary, + toolDebugPayload: prepared.toolDebugPayload, + }; + shouldSwitchAccount = true; + break; + } + } + const shouldRetryEndpoint = ( response.status === 403 || response.status === 404 || @@ -2112,24 +2560,28 @@ export const createAntigravityPlugin = (providerId: string) => async ( while (true) { const now = Date.now(); const existingAccounts = existingStorage.accounts.map((acc, idx) => { - let status: 'active' | 'rate-limited' | 'expired' | 'unknown' = 'unknown'; - - const rateLimits = acc.rateLimitResetTimes; - if (rateLimits) { - const isRateLimited = Object.values(rateLimits).some( - (resetTime) => typeof resetTime === 'number' && resetTime > now - ); - if (isRateLimited) { - status = 'rate-limited'; + let status: 'active' | 'rate-limited' | 'expired' | 'verification-required' | 'unknown' = 'unknown'; + + if (acc.verificationRequired) { + status = 'verification-required'; + } else { + const rateLimits = acc.rateLimitResetTimes; + if (rateLimits) { + const isRateLimited = Object.values(rateLimits).some( + (resetTime) => typeof resetTime === 'number' && resetTime > now + ); + if (isRateLimited) { + status = 'rate-limited'; + } else { + status = 'active'; + } } else { status = 'active'; } - } else { - status = 'active'; - } - if (acc.coolingDownUntil && acc.coolingDownUntil > now) { - status = 'rate-limited'; + if (acc.coolingDownUntil && acc.coolingDownUntil > now) { + status = 'rate-limited'; + } } return { @@ -2290,6 +2742,171 @@ export const createAntigravityPlugin = (providerId: string) => async ( continue; } + if (menuResult.mode === "verify" || menuResult.mode === "verify-all") { + const verifyAll = menuResult.mode === "verify-all" || menuResult.verifyAll === true; + + if (verifyAll) { + if (existingStorage.accounts.length === 0) { + console.log("\nNo accounts available to verify.\n"); + continue; + } + + console.log(`\nChecking verification status for ${existingStorage.accounts.length} account(s)...\n`); + + let okCount = 0; + let blockedCount = 0; + let errorCount = 0; + let storageUpdated = false; + + const blockedResults: Array<{ label: string; message: string; verifyUrl?: string }> = []; + + for (let i = 0; i < existingStorage.accounts.length; i++) { + const account = existingStorage.accounts[i]; + if (!account) continue; + + const label = account.email || `Account ${i + 1}`; + process.stdout.write(`- [${i + 1}/${existingStorage.accounts.length}] ${label} ... `); + + const verification = await verifyAccountAccess(account, client, providerId); + if (verification.status === "ok") { + const { changed, wasVerificationRequired } = clearStoredAccountVerificationRequired(account, true); + if (changed) { + storageUpdated = true; + } + activeAccountManager?.clearAccountVerificationRequired(i, wasVerificationRequired); + okCount += 1; + console.log("ok"); + continue; + } + + if (verification.status === "blocked") { + const changed = markStoredAccountVerificationRequired( + account, + verification.message, + verification.verifyUrl, + ); + if (changed) { + storageUpdated = true; + } + activeAccountManager?.markAccountVerificationRequired(i, verification.message, verification.verifyUrl); + + blockedCount += 1; + console.log("needs verification"); + const verifyUrl = verification.verifyUrl ?? account.verificationUrl; + blockedResults.push({ + label, + message: verification.message, + verifyUrl, + }); + continue; + } + + errorCount += 1; + console.log(`error (${verification.message})`); + } + + if (storageUpdated) { + await saveAccounts(existingStorage); + } + + console.log(`\nVerification summary: ${okCount} ready, ${blockedCount} need verification, ${errorCount} errors.`); + + if (blockedResults.length > 0) { + console.log("\nAccounts needing verification:"); + for (const result of blockedResults) { + console.log(`\n- ${result.label}`); + console.log(` ${result.message}`); + if (result.verifyUrl) { + console.log(` URL: ${result.verifyUrl}`); + } else { + console.log(" URL: not provided by API response"); + } + } + console.log(""); + } else { + console.log(""); + } + + continue; + } + + let verifyAccountIndex = menuResult.verifyAccountIndex; + if (verifyAccountIndex === undefined) { + verifyAccountIndex = await promptAccountIndexForVerification(existingAccounts); + } + + if (verifyAccountIndex === undefined) { + console.log("\nVerification cancelled.\n"); + continue; + } + + const account = existingStorage.accounts[verifyAccountIndex]; + if (!account) { + console.log(`\nAccount ${verifyAccountIndex + 1} not found.\n`); + continue; + } + + const label = account.email || `Account ${verifyAccountIndex + 1}`; + console.log(`\nChecking verification status for ${label}...\n`); + + const verification = await verifyAccountAccess(account, client, providerId); + + if (verification.status === "ok") { + const { changed, wasVerificationRequired } = clearStoredAccountVerificationRequired(account, true); + if (changed) { + await saveAccounts(existingStorage); + } + activeAccountManager?.clearAccountVerificationRequired(verifyAccountIndex, wasVerificationRequired); + + if (wasVerificationRequired) { + console.log(`✓ ${label} is ready for requests and has been re-enabled.\n`); + } else { + console.log(`✓ ${label} is ready for requests.\n`); + } + continue; + } + + if (verification.status === "blocked") { + const changed = markStoredAccountVerificationRequired( + account, + verification.message, + verification.verifyUrl, + ); + if (changed) { + await saveAccounts(existingStorage); + } + activeAccountManager?.markAccountVerificationRequired( + verifyAccountIndex, + verification.message, + verification.verifyUrl, + ); + + const verifyUrl = verification.verifyUrl ?? account.verificationUrl; + console.log(`⚠ ${label} needs Google verification before it can be used.`); + if (verification.message) { + console.log(verification.message); + } + console.log(`${label} has been disabled until verification is completed.`); + if (verifyUrl) { + console.log(`\nVerification URL:\n${verifyUrl}\n`); + if (await promptOpenVerificationUrl()) { + const opened = await openBrowser(verifyUrl); + if (opened) { + console.log("Opened verification URL in your browser.\n"); + } else { + console.log("Could not open browser automatically. Please open the URL manually.\n"); + } + } + } else { + console.log("No verification URL was returned. Try re-authenticating this account.\n"); + } + continue; + } + + console.log(`✗ ${label}: ${verification.message}\n`); + continue; + } + break; } diff --git a/src/plugin/accounts.ts b/src/plugin/accounts.ts index dae9e126..e37805d0 100644 --- a/src/plugin/accounts.ts +++ b/src/plugin/accounts.ts @@ -163,6 +163,10 @@ export interface ManagedAccount { /** Cached quota data from last checkAccountsQuota() call */ cachedQuota?: Partial>; cachedQuotaUpdatedAt?: number; + verificationRequired?: boolean; + verificationRequiredAt?: number; + verificationRequiredReason?: string; + verificationUrl?: string; } function nowMs(): number { @@ -376,6 +380,10 @@ export class AccountManager { : generateFingerprint(), cachedQuota: acc.cachedQuota as Partial> | undefined, cachedQuotaUpdatedAt: acc.cachedQuotaUpdatedAt, + verificationRequired: acc.verificationRequired, + verificationRequiredAt: acc.verificationRequiredAt, + verificationRequiredReason: acc.verificationRequiredReason, + verificationUrl: acc.verificationUrl, }; }) .filter((a): a is ManagedAccount => a !== null); @@ -816,6 +824,57 @@ export class AccountManager { return true; } + markAccountVerificationRequired(accountIndex: number, reason?: string, verifyUrl?: string): boolean { + const account = this.accounts[accountIndex]; + if (!account) { + return false; + } + + account.verificationRequired = true; + account.verificationRequiredAt = nowMs(); + account.verificationRequiredReason = reason?.trim() || undefined; + + const normalizedVerifyUrl = verifyUrl?.trim(); + if (normalizedVerifyUrl) { + account.verificationUrl = normalizedVerifyUrl; + } + + if (account.enabled !== false) { + this.setAccountEnabled(accountIndex, false); + } else { + this.requestSaveToDisk(); + } + + return true; + } + + clearAccountVerificationRequired(accountIndex: number, enableAccount = false): boolean { + const account = this.accounts[accountIndex]; + if (!account) { + return false; + } + + const wasVerificationRequired = account.verificationRequired === true; + const hadMetadata = ( + account.verificationRequiredAt !== undefined || + account.verificationRequiredReason !== undefined || + account.verificationUrl !== undefined + ); + + account.verificationRequired = false; + account.verificationRequiredAt = undefined; + account.verificationRequiredReason = undefined; + account.verificationUrl = undefined; + + if (enableAccount && wasVerificationRequired && account.enabled === false) { + this.setAccountEnabled(accountIndex, true); + } else if (wasVerificationRequired || hadMetadata) { + this.requestSaveToDisk(); + } + + return true; + } + removeAccountByIndex(accountIndex: number): boolean { if (accountIndex < 0 || accountIndex >= this.accounts.length) { return false; @@ -953,6 +1012,10 @@ export class AccountManager { fingerprintHistory: a.fingerprintHistory?.length ? a.fingerprintHistory : undefined, cachedQuota: a.cachedQuota && Object.keys(a.cachedQuota).length > 0 ? a.cachedQuota : undefined, cachedQuotaUpdatedAt: a.cachedQuotaUpdatedAt, + verificationRequired: a.verificationRequired, + verificationRequiredAt: a.verificationRequiredAt, + verificationRequiredReason: a.verificationRequiredReason, + verificationUrl: a.verificationUrl, })), activeIndex: claudeIndex, activeIndexByFamily: { diff --git a/src/plugin/cli.ts b/src/plugin/cli.ts index f16ad6fa..348c1800 100644 --- a/src/plugin/cli.ts +++ b/src/plugin/cli.ts @@ -30,7 +30,7 @@ export async function promptAddAnotherAccount(currentCount: number): Promise; cachedQuotaUpdatedAt?: number; diff --git a/src/plugin/ui/auth-menu.ts b/src/plugin/ui/auth-menu.ts index 54769839..6e3ec88b 100644 --- a/src/plugin/ui/auth-menu.ts +++ b/src/plugin/ui/auth-menu.ts @@ -2,7 +2,7 @@ import { ANSI } from './ansi'; import { select, type MenuItem } from './select'; import { confirm } from './confirm'; -export type AccountStatus = 'active' | 'rate-limited' | 'expired' | 'unknown'; +export type AccountStatus = 'active' | 'rate-limited' | 'expired' | 'verification-required' | 'unknown'; export interface AccountInfo { email?: string; @@ -19,11 +19,12 @@ export type AuthMenuAction = | { type: 'select-account'; account: AccountInfo } | { type: 'delete-all' } | { type: 'check' } - | { type: 'manage' } + | { type: 'verify' } + | { type: 'verify-all' } | { type: 'configure-models' } | { type: 'cancel' }; -export type AccountAction = 'back' | 'delete' | 'refresh' | 'toggle' | 'cancel'; +export type AccountAction = 'back' | 'delete' | 'refresh' | 'toggle' | 'verify' | 'cancel'; function formatRelativeTime(timestamp: number | undefined): string { if (!timestamp) return 'never'; @@ -45,23 +46,32 @@ function getStatusBadge(status: AccountStatus | undefined): string { case 'active': return `${ANSI.green}[active]${ANSI.reset}`; case 'rate-limited': return `${ANSI.yellow}[rate-limited]${ANSI.reset}`; case 'expired': return `${ANSI.red}[expired]${ANSI.reset}`; + case 'verification-required': return `${ANSI.red}[needs verification]${ANSI.reset}`; default: return ''; } } export async function showAuthMenu(accounts: AccountInfo[]): Promise { const items: MenuItem[] = [ - { label: 'Add new account', value: { type: 'add' } }, - { label: 'Check quotas', value: { type: 'check' } }, - { label: 'Manage accounts (enable/disable)', value: { type: 'manage' } }, - { label: 'Configure models in opencode config', value: { type: 'configure-models' } }, + { label: 'Actions', value: { type: 'cancel' }, kind: 'heading' }, + { label: 'Add account', value: { type: 'add' }, color: 'cyan' }, + { label: 'Check quotas', value: { type: 'check' }, color: 'cyan' }, + { label: 'Verify one account', value: { type: 'verify' }, color: 'cyan' }, + { label: 'Verify all accounts', value: { type: 'verify-all' }, color: 'cyan' }, + { label: 'Configure models in opencode.json', value: { type: 'configure-models' }, color: 'cyan' }, + + { label: '', value: { type: 'cancel' }, separator: true }, + + { label: 'Accounts', value: { type: 'cancel' }, kind: 'heading' }, ...accounts.map(account => { - const badge = getStatusBadge(account.status); + const statusBadge = getStatusBadge(account.status); + const currentBadge = account.isCurrentAccount ? ` ${ANSI.cyan}[current]${ANSI.reset}` : ''; const disabledBadge = account.enabled === false ? ` ${ANSI.red}[disabled]${ANSI.reset}` : ''; - const label = account.email || `Account ${account.index + 1}`; - const fullLabel = `${label}${badge ? ' ' + badge : ''}${disabledBadge}`; - + const baseLabel = account.email || `Account ${account.index + 1}`; + const numbered = `${account.index + 1}. ${baseLabel}`; + const fullLabel = `${numbered}${currentBadge}${statusBadge ? ' ' + statusBadge : ''}${disabledBadge}`; + return { label: fullLabel, hint: account.lastUsed ? `used ${formatRelativeTime(account.lastUsed)}` : '', @@ -69,13 +79,17 @@ export async function showAuthMenu(accounts: AccountInfo[]): Promise { hint?: string; disabled?: boolean; separator?: boolean; + /** Non-selectable label row (section heading). */ + kind?: 'heading'; color?: 'red' | 'green' | 'yellow' | 'cyan'; } export interface SelectOptions { message: string; subtitle?: string; + /** Override the help line shown at the bottom of the menu. */ + help?: string; + /** + * Clear the terminal before each render (opt-in). + * Useful for nested flows where previous logs make menus feel cluttered. + */ + clearScreen?: boolean; } const ESCAPE_TIMEOUT_MS = 50; +const ANSI_REGEX = new RegExp("\\x1b\\[[0-9;]*m", "g"); +const ANSI_LEADING_REGEX = new RegExp("^\\x1b\\[[0-9;]*m"); + +function stripAnsi(input: string): string { + return input.replace(ANSI_REGEX, ''); +} + +function truncateAnsi(input: string, maxVisibleChars: number): string { + if (maxVisibleChars <= 0) return ''; + + const visible = stripAnsi(input); + if (visible.length <= maxVisibleChars) return input; + + const suffix = maxVisibleChars >= 3 ? '...' : '.'.repeat(maxVisibleChars); + const keep = Math.max(0, maxVisibleChars - suffix.length); + + let out = ''; + let i = 0; + let kept = 0; + + while (i < input.length && kept < keep) { + // Preserve ANSI sequences without counting them. + if (input[i] === '\x1b') { + const m = input.slice(i).match(ANSI_LEADING_REGEX); + if (m) { + out += m[0]; + i += m[0].length; + continue; + } + } + + out += input[i]; + i += 1; + kept += 1; + } + + if (out.includes('\x1b[')) { + return `${out}${ANSI.reset}${suffix}`; + } + + return out + suffix; +} + function getColorCode(color: MenuItem['color']): string { switch (color) { case 'red': return ANSI.red; @@ -38,7 +90,8 @@ export async function select( throw new Error('No menu items provided'); } - const enabledItems = items.filter(i => !i.disabled && !i.separator); + const isSelectable = (i: MenuItem) => !i.disabled && !i.separator && i.kind !== 'heading'; + const enabledItems = items.filter(isSelectable); if (enabledItems.length === 0) { throw new Error('All items disabled'); } @@ -50,43 +103,75 @@ export async function select( const { message, subtitle } = options; const { stdin, stdout } = process; - let cursor = items.findIndex(i => !i.disabled && !i.separator); + let cursor = items.findIndex(isSelectable); if (cursor === -1) cursor = 0; // Fallback, though validation above should prevent this let escapeTimeout: ReturnType | null = null; let isCleanedUp = false; - let isFirstRender = true; - - const getTotalLines = (): number => { - const subtitleLines = subtitle ? 3 : 0; - return 1 + subtitleLines + items.length + 1 + 1; - }; + let renderedLines = 0; const render = () => { - const totalLines = getTotalLines(); + const columns = stdout.columns ?? 80; + const rows = stdout.rows ?? 24; + const shouldClearScreen = options.clearScreen === true; + const previousRenderedLines = renderedLines; + + if (shouldClearScreen) { + stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1)); + } else if (previousRenderedLines > 0) { + stdout.write(ANSI.up(previousRenderedLines)); + } + + let linesWritten = 0; + const writeLine = (line: string) => { + stdout.write(`${ANSI.clearLine}${line}\n`); + linesWritten += 1; + }; - if (!isFirstRender) { - stdout.write(ANSI.up(totalLines) + '\r'); + // Subtitle renders as 3 lines: + // 1) blank "│" spacer, 2) subtitle line, 3) blank line. Header is counted separately. + const subtitleLines = subtitle ? 3 : 0; + const fixedLines = 1 + subtitleLines + 2; // header + subtitle + (help + bottom) + // Keep a small safety margin so the final newline doesn't scroll the terminal. + const maxVisibleItems = Math.max(1, Math.min(items.length, rows - fixedLines - 1)); + + // If the menu is taller than the viewport, only render a window around the cursor. + // This prevents terminal scrollback spam (e.g. repeated headers when pressing arrows). + let windowStart = 0; + let windowEnd = items.length; + if (items.length > maxVisibleItems) { + windowStart = cursor - Math.floor(maxVisibleItems / 2); + windowStart = Math.max(0, Math.min(windowStart, items.length - maxVisibleItems)); + windowEnd = windowStart + maxVisibleItems; } - isFirstRender = false; - stdout.write(`${ANSI.clearLine}${ANSI.dim}┌ ${ANSI.reset}${message}\n`); + const visibleItems = items.slice(windowStart, windowEnd); + const headerMessage = truncateAnsi(message, Math.max(1, columns - 4)); + writeLine(`${ANSI.dim}┌ ${ANSI.reset}${headerMessage}`); if (subtitle) { - stdout.write(`${ANSI.clearLine}${ANSI.dim}│${ANSI.reset}\n`); - stdout.write(`${ANSI.clearLine}${ANSI.cyan}◆${ANSI.reset} ${subtitle}\n`); - stdout.write(`${ANSI.clearLine}\n`); + writeLine(`${ANSI.dim}│${ANSI.reset}`); + const sub = truncateAnsi(subtitle, Math.max(1, columns - 4)); + writeLine(`${ANSI.cyan}◆${ANSI.reset} ${sub}`); + writeLine(""); } - for (let i = 0; i < items.length; i++) { - const item = items[i]; + for (let i = 0; i < visibleItems.length; i++) { + const itemIndex = windowStart + i; + const item = visibleItems[i]; if (!item) continue; if (item.separator) { - stdout.write(`${ANSI.clearLine}${ANSI.dim}│${ANSI.reset}\n`); + writeLine(`${ANSI.dim}│${ANSI.reset}`); continue; } - const isSelected = i === cursor; + if (item.kind === 'heading') { + const heading = truncateAnsi(`${ANSI.dim}${ANSI.bold}${item.label}${ANSI.reset}`, Math.max(1, columns - 6)); + writeLine(`${ANSI.cyan}│${ANSI.reset} ${heading}`); + continue; + } + + const isSelected = itemIndex === cursor; const colorCode = getColorCode(item.color); let labelText: string; @@ -102,15 +187,32 @@ export async function select( if (item.hint) labelText += ` ${ANSI.dim}${item.hint}${ANSI.reset}`; } + // Prevent wrapping: cursor positioning relies on a fixed line count. + labelText = truncateAnsi(labelText, Math.max(1, columns - 8)); + if (isSelected) { - stdout.write(`${ANSI.clearLine}${ANSI.cyan}│${ANSI.reset} ${ANSI.green}●${ANSI.reset} ${labelText}\n`); + writeLine(`${ANSI.cyan}│${ANSI.reset} ${ANSI.green}●${ANSI.reset} ${labelText}`); } else { - stdout.write(`${ANSI.clearLine}${ANSI.cyan}│${ANSI.reset} ${ANSI.dim}○${ANSI.reset} ${labelText}\n`); + writeLine(`${ANSI.cyan}│${ANSI.reset} ${ANSI.dim}○${ANSI.reset} ${labelText}`); + } + } + + const windowHint = items.length > visibleItems.length + ? ` (${windowStart + 1}-${windowEnd}/${items.length})` + : ''; + const helpText = options.help ?? `Up/Down to select | Enter: confirm | Esc: back${windowHint}`; + const help = truncateAnsi(helpText, Math.max(1, columns - 6)); + writeLine(`${ANSI.cyan}│${ANSI.reset} ${ANSI.dim}${help}${ANSI.reset}`); + writeLine(`${ANSI.cyan}└${ANSI.reset}`); + + if (!shouldClearScreen && previousRenderedLines > linesWritten) { + const extra = previousRenderedLines - linesWritten; + for (let i = 0; i < extra; i++) { + writeLine(""); } } - stdout.write(`${ANSI.clearLine}${ANSI.cyan}│${ANSI.reset} ${ANSI.dim}↑/↓ to select • Enter: confirm${ANSI.reset}\n`); - stdout.write(`${ANSI.clearLine}${ANSI.cyan}└${ANSI.reset}\n`); + renderedLines = linesWritten; }; return new Promise((resolve) => { @@ -154,7 +256,7 @@ export async function select( let next = from; do { next = (next + direction + items.length) % items.length; - } while (items[next]?.disabled || items[next]?.separator); + } while (items[next]?.disabled || items[next]?.separator || items[next]?.kind === 'heading'); return next; };