feat: add account verification action to auth login menu

This commit is contained in:
Noe
2026-02-09 23:33:57 +00:00
parent 00ec4e29f3
commit 91b74ceef1
4 changed files with 547 additions and 54 deletions

View File

@@ -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,326 @@ async function openBrowser(url: string): Promise<boolean> {
}
}
type VerificationProbeResult = {
status: "ok" | "blocked" | "error";
message: string;
verifyUrl?: string;
};
function decodeEscapedText(input: string): string {
return input
.replace(/&amp;/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<string>();
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<unknown>();
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<string, unknown>)) {
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<VerificationProbeResult> {
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<ReturnType<typeof refreshAccessToken>>;
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<string, string> = {
...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<number | undefined> {
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;
if (normalizedIndex < 0 || normalizedIndex >= accounts.length) {
console.log("Please enter a number from the list above.");
continue;
}
return normalizedIndex;
}
} finally {
rl.close();
}
}
async function promptOpenVerificationUrl(): Promise<boolean> {
const answer = (await promptOAuthCallbackValue("Open verification URL in your browser now? [Y/n]: ")).trim().toLowerCase();
return answer === "" || answer === "y" || answer === "yes";
}
async function promptOAuthCallbackValue(message: string): Promise<string> {
const { createInterface } = await import("node:readline/promises");
const { stdin, stdout } = await import("node:process");
@@ -2290,6 +2617,58 @@ export const createAntigravityPlugin = (providerId: string) => async (
continue;
}
if (menuResult.mode === "verify") {
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") {
console.log(`${label} is ready for requests.\n`);
continue;
}
if (verification.status === "blocked") {
console.log(`${label} needs Google verification before it can be used.`);
if (verification.message) {
console.log(verification.message);
}
if (verification.verifyUrl) {
console.log(`\nVerification URL:\n${verification.verifyUrl}\n`);
if (await promptOpenVerificationUrl()) {
const opened = await openBrowser(verification.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;
}

View File

@@ -30,7 +30,7 @@ export async function promptAddAnotherAccount(currentCount: number): Promise<boo
}
}
export type LoginMode = "add" | "fresh" | "manage" | "check" | "cancel";
export type LoginMode = "add" | "fresh" | "manage" | "check" | "verify" | "cancel";
export interface ExistingAccountInfo {
email?: string;
@@ -47,6 +47,7 @@ export interface LoginMenuResult {
deleteAccountIndex?: number;
refreshAccountIndex?: number;
toggleAccountIndex?: number;
verifyAccountIndex?: number;
deleteAll?: boolean;
}
@@ -61,7 +62,7 @@ async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]):
console.log("");
while (true) {
const answer = await rl.question("(a)dd new, (f)resh start, (m)anage, (c)heck quotas? [a/f/m/c]: ");
const answer = await rl.question("(a)dd new, (f)resh start, (c)heck quotas, (v)erify account? [a/f/c/v]: ");
const normalized = answer.trim().toLowerCase();
if (normalized === "a" || normalized === "add") {
@@ -70,14 +71,14 @@ async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]):
if (normalized === "f" || normalized === "fresh") {
return { mode: "fresh" };
}
if (normalized === "m" || normalized === "manage") {
return { mode: "manage" };
}
if (normalized === "c" || normalized === "check") {
return { mode: "check" };
}
if (normalized === "v" || normalized === "verify") {
return { mode: "verify" };
}
console.log("Please enter 'a', 'f', 'm', or 'c'.");
console.log("Please enter 'a', 'f', 'c', or 'v'.");
}
} finally {
rl.close();
@@ -111,8 +112,8 @@ export async function promptLoginMode(existingAccounts: ExistingAccountInfo[]):
case "check":
return { mode: "check" };
case "manage":
return { mode: "manage" };
case "verify":
return { mode: "verify" };
case "select-account": {
const accountAction = await showAccountDetails(action.account);
@@ -125,6 +126,9 @@ export async function promptLoginMode(existingAccounts: ExistingAccountInfo[]):
if (accountAction === "toggle") {
return { mode: "manage", toggleAccountIndex: action.account.index };
}
if (accountAction === "verify") {
return { mode: "verify", verifyAccountIndex: action.account.index };
}
continue;
}

View File

@@ -19,11 +19,11 @@ export type AuthMenuAction =
| { type: 'select-account'; account: AccountInfo }
| { type: 'delete-all' }
| { type: 'check' }
| { type: 'manage' }
| { type: 'verify' }
| { 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';
@@ -51,17 +51,24 @@ function getStatusBadge(status: AccountStatus | undefined): string {
export async function showAuthMenu(accounts: AccountInfo[]): Promise<AuthMenuAction> {
const items: MenuItem<AuthMenuAction>[] = [
{ 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 account access', value: { type: 'verify' }, 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 +76,17 @@ export async function showAuthMenu(accounts: AccountInfo[]): Promise<AuthMenuAct
};
}),
{ label: '', value: { type: 'cancel' }, separator: true },
{ label: 'Danger zone', value: { type: 'cancel' }, kind: 'heading' },
{ label: 'Delete all accounts', value: { type: 'delete-all' }, color: 'red' as const },
];
while (true) {
const result = await select(items, {
message: 'Manage accounts',
subtitle: 'Select account'
message: 'Google accounts (Antigravity)',
subtitle: 'Select an action or account',
clearScreen: true,
});
if (!result) return { type: 'cancel' };
@@ -93,22 +104,23 @@ export async function showAccountDetails(account: AccountInfo): Promise<AccountA
const label = account.email || `Account ${account.index + 1}`;
const badge = getStatusBadge(account.status);
const disabledBadge = account.enabled === false ? ` ${ANSI.red}[disabled]${ANSI.reset}` : '';
console.log('');
console.log(`${ANSI.bold}Account: ${label}${badge ? ' ' + badge : ''}${disabledBadge}${ANSI.reset}`);
console.log(`${ANSI.dim}Added: ${formatDate(account.addedAt)}${ANSI.reset}`);
console.log(`${ANSI.dim}Last used: ${formatRelativeTime(account.lastUsed)}${ANSI.reset}`);
console.log('');
const header = `${label}${badge ? ' ' + badge : ''}${disabledBadge}`;
const subtitleParts = [
`Added: ${formatDate(account.addedAt)}`,
`Last used: ${formatRelativeTime(account.lastUsed)}`,
];
while (true) {
const result = await select([
{ label: 'Back', value: 'back' as const },
{ label: 'Verify account access', value: 'verify' as const, color: 'cyan' },
{ label: account.enabled === false ? 'Enable account' : 'Disable account', value: 'toggle' as const, color: account.enabled === false ? 'green' : 'yellow' },
{ label: 'Refresh token', value: 'refresh' as const, color: 'cyan' },
{ label: 'Delete this account', value: 'delete' as const, color: 'red' },
], {
message: 'Account options',
subtitle: 'Select action'
message: header,
subtitle: subtitleParts.join(' | '),
clearScreen: true,
});
if (result === 'delete') {

View File

@@ -6,16 +6,64 @@ export interface MenuItem<T = string> {
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;
}
return out + suffix;
}
function getColorCode(color: MenuItem['color']): string {
switch (color) {
case 'red': return ANSI.red;
@@ -38,7 +86,8 @@ export async function select<T>(
throw new Error('No menu items provided');
}
const enabledItems = items.filter(i => !i.disabled && !i.separator);
const isSelectable = (i: MenuItem<T>) => !i.disabled && !i.separator && i.kind !== 'heading';
const enabledItems = items.filter(isSelectable);
if (enabledItems.length === 0) {
throw new Error('All items disabled');
}
@@ -50,43 +99,75 @@ export async function select<T>(
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<typeof setTimeout> | 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 (!isFirstRender) {
stdout.write(ANSI.up(totalLines) + '\r');
if (shouldClearScreen) {
stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1));
} else if (previousRenderedLines > 0) {
stdout.write(ANSI.up(previousRenderedLines));
}
isFirstRender = false;
stdout.write(`${ANSI.clearLine}${ANSI.dim}${ANSI.reset}${message}\n`);
let linesWritten = 0;
const writeLine = (line: string) => {
stdout.write(`${ANSI.clearLine}${line}\n`);
linesWritten += 1;
};
// 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;
}
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 +183,32 @@ export async function select<T>(
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}`);
}
}
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`);
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("");
}
}
renderedLines = linesWritten;
};
return new Promise((resolve) => {
@@ -154,7 +252,7 @@ export async function select<T>(
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;
};