opencode-openai-account-switcher 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +151 -0
  2. package/README.md +43 -0
  3. package/dist/auth-store.d.ts +13 -0
  4. package/dist/auth-store.js +43 -0
  5. package/dist/codex-auth-source.d.ts +16 -0
  6. package/dist/codex-auth-source.js +63 -0
  7. package/dist/codex-invalid-account.d.ts +32 -0
  8. package/dist/codex-invalid-account.js +106 -0
  9. package/dist/codex-network-retry.d.ts +2 -0
  10. package/dist/codex-network-retry.js +9 -0
  11. package/dist/codex-status-command.d.ts +56 -0
  12. package/dist/codex-status-command.js +341 -0
  13. package/dist/codex-status-fetcher.d.ts +71 -0
  14. package/dist/codex-status-fetcher.js +300 -0
  15. package/dist/codex-store.d.ts +49 -0
  16. package/dist/codex-store.js +267 -0
  17. package/dist/common-settings-actions.d.ts +15 -0
  18. package/dist/common-settings-actions.js +22 -0
  19. package/dist/common-settings-store.d.ts +17 -0
  20. package/dist/common-settings-store.js +72 -0
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.js +1 -0
  23. package/dist/menu-runtime.d.ts +81 -0
  24. package/dist/menu-runtime.js +141 -0
  25. package/dist/network-retry-engine.d.ts +33 -0
  26. package/dist/network-retry-engine.js +62 -0
  27. package/dist/plugin-hooks.d.ts +49 -0
  28. package/dist/plugin-hooks.js +123 -0
  29. package/dist/plugin.d.ts +2 -0
  30. package/dist/plugin.js +127 -0
  31. package/dist/providers/codex-menu-adapter.d.ts +58 -0
  32. package/dist/providers/codex-menu-adapter.js +429 -0
  33. package/dist/providers/descriptor.d.ts +24 -0
  34. package/dist/providers/descriptor.js +16 -0
  35. package/dist/providers/registry.d.ts +15 -0
  36. package/dist/providers/registry.js +49 -0
  37. package/dist/retry/codex-policy.d.ts +5 -0
  38. package/dist/retry/codex-policy.js +75 -0
  39. package/dist/retry/common-policy.d.ts +37 -0
  40. package/dist/retry/common-policy.js +68 -0
  41. package/dist/store-paths.d.ts +4 -0
  42. package/dist/store-paths.js +22 -0
  43. package/dist/ui/ansi.d.ts +18 -0
  44. package/dist/ui/ansi.js +32 -0
  45. package/dist/ui/confirm.d.ts +1 -0
  46. package/dist/ui/confirm.js +14 -0
  47. package/dist/ui/menu.d.ts +168 -0
  48. package/dist/ui/menu.js +305 -0
  49. package/dist/ui/select.d.ts +36 -0
  50. package/dist/ui/select.js +350 -0
  51. package/dist/upstream/codex-loader-adapter.d.ts +99 -0
  52. package/dist/upstream/codex-loader-adapter.js +80 -0
  53. package/dist/upstream/codex-plugin.snapshot.d.ts +32 -0
  54. package/dist/upstream/codex-plugin.snapshot.js +638 -0
  55. package/package.json +40 -0
  56. package/scripts/sync-codex-upstream.mjs +348 -0
@@ -0,0 +1,305 @@
1
+ import { ANSI } from "./ansi.js";
2
+ import { select } from "./select.js";
3
+ import { confirm } from "./confirm.js";
4
+ function defaultMenuCapabilities() {
5
+ return {
6
+ importAuth: false,
7
+ quota: true,
8
+ refreshIdentity: false,
9
+ checkModels: false,
10
+ defaultAccountGroup: false,
11
+ assignModels: false,
12
+ experimentalSlashCommands: true,
13
+ networkRetry: true,
14
+ syntheticAgentInitiator: false,
15
+ };
16
+ }
17
+ export function getMenuCopy(language = "zh", provider = "codex") {
18
+ void provider;
19
+ if (language === "en") {
20
+ return {
21
+ menuTitle: "OpenAI Codex accounts",
22
+ menuSubtitle: "Select an action or account",
23
+ switchLanguageLabel: "切换到中文",
24
+ actionsHeading: "Actions",
25
+ commonSettingsHeading: "Common settings",
26
+ providerSettingsHeading: "Provider settings",
27
+ addAccount: "Add account",
28
+ addAccountHint: "OpenAI OAuth login",
29
+ importAuth: "Import from auth.json",
30
+ checkQuotas: "Refresh snapshots",
31
+ refreshIdentity: "Sync account identity",
32
+ checkModels: "Sync available models",
33
+ defaultAccountGroup: "Default account group",
34
+ assignModels: "Assign account groups per model",
35
+ autoRefreshOn: "Auto refresh: On",
36
+ autoRefreshOff: "Auto refresh: Off",
37
+ setRefresh: "Set refresh interval",
38
+ experimentalSlashCommandsOn: "Experimental slash commands: On",
39
+ experimentalSlashCommandsOff: "Experimental slash commands: Off",
40
+ experimentalSlashCommandsHint: "Experimental provider-specific slash commands",
41
+ retryOn: "Network Retry: On",
42
+ retryOff: "Network Retry: Off",
43
+ retryHint: "Helps recover some requests after retries or malformed responses",
44
+ syntheticInitiatorOn: "Send synthetic messages as agent: On",
45
+ syntheticInitiatorOff: "Send synthetic messages as agent: Off",
46
+ syntheticInitiatorHint: "Changes upstream behavior; misuse may increase billing risk or trigger abuse signals",
47
+ accountsHeading: "Accounts",
48
+ dangerHeading: "Danger zone",
49
+ removeAll: "Remove all accounts",
50
+ };
51
+ }
52
+ return {
53
+ menuTitle: "OpenAI Codex 账号",
54
+ menuSubtitle: "请选择操作或账号",
55
+ switchLanguageLabel: "Switch to English",
56
+ actionsHeading: "操作",
57
+ commonSettingsHeading: "通用设置",
58
+ providerSettingsHeading: "Provider 专属设置",
59
+ addAccount: "添加账号",
60
+ addAccountHint: "OpenAI OAuth 登录",
61
+ importAuth: "从 auth.json 导入",
62
+ checkQuotas: "刷新快照",
63
+ refreshIdentity: "同步账号身份信息",
64
+ checkModels: "同步可用模型列表",
65
+ defaultAccountGroup: "配置默认账号组",
66
+ assignModels: "为模型配置账号组",
67
+ autoRefreshOn: "自动刷新:已开启",
68
+ autoRefreshOff: "自动刷新:已关闭",
69
+ setRefresh: "设置刷新间隔",
70
+ experimentalSlashCommandsOn: "实验性 Slash Commands:已开启",
71
+ experimentalSlashCommandsOff: "实验性 Slash Commands:已关闭",
72
+ experimentalSlashCommandsHint: "当前 provider 的实验性 Slash Commands 开关",
73
+ retryOn: "Network Retry:已开启",
74
+ retryOff: "Network Retry:已关闭",
75
+ retryHint: "请求异常时可自动重试并修复部分请求",
76
+ syntheticInitiatorOn: "synthetic 消息按 agent 身份发送:已开启",
77
+ syntheticInitiatorOff: "synthetic 消息按 agent 身份发送:已关闭",
78
+ syntheticInitiatorHint: "会改变与 upstream 的默认行为;误用可能带来异常计费或 abuse 风险",
79
+ accountsHeading: "账号",
80
+ dangerHeading: "危险操作",
81
+ removeAll: "删除全部账号",
82
+ };
83
+ }
84
+ function formatRelativeTime(timestamp) {
85
+ if (!timestamp)
86
+ return "never";
87
+ const days = Math.floor((Date.now() - timestamp) / 86400000);
88
+ if (days === 0)
89
+ return "today";
90
+ if (days === 1)
91
+ return "yesterday";
92
+ if (days < 7)
93
+ return `${days}d ago`;
94
+ if (days < 30)
95
+ return `${Math.floor(days / 7)}w ago`;
96
+ return new Date(timestamp).toLocaleDateString();
97
+ }
98
+ function formatDate(timestamp) {
99
+ if (!timestamp)
100
+ return "unknown";
101
+ return new Date(timestamp).toLocaleDateString();
102
+ }
103
+ function getStatusBadge(status) {
104
+ if (status === "expired")
105
+ return `${ANSI.red}[expired]${ANSI.reset}`;
106
+ return "";
107
+ }
108
+ export function buildMenuItems(input) {
109
+ const provider = input.provider ?? "codex";
110
+ const copy = getMenuCopy(input.language, provider);
111
+ const capabilities = {
112
+ ...defaultMenuCapabilities(),
113
+ ...input.capabilities,
114
+ };
115
+ const quotaHint = input.lastQuotaRefresh ? `last ${formatRelativeTime(input.lastQuotaRefresh)}` : undefined;
116
+ const experimentalSlashCommandsEnabled = input.experimentalSlashCommandsEnabled !== false;
117
+ const providerActions = [
118
+ { label: copy.actionsHeading, value: { type: "cancel" }, kind: "heading" },
119
+ { label: copy.switchLanguageLabel, value: { type: "toggle-language" }, color: "cyan" },
120
+ { label: copy.addAccount, value: { type: "add" }, color: "cyan", hint: copy.addAccountHint },
121
+ ];
122
+ if (capabilities.importAuth) {
123
+ providerActions.push({ label: copy.importAuth, value: { type: "import" }, color: "cyan" });
124
+ }
125
+ if (capabilities.quota) {
126
+ providerActions.push({ label: copy.checkQuotas, value: { type: "quota" }, color: "cyan", hint: quotaHint });
127
+ }
128
+ if (capabilities.refreshIdentity) {
129
+ providerActions.push({ label: copy.refreshIdentity, value: { type: "refresh-identity" }, color: "cyan" });
130
+ }
131
+ if (capabilities.checkModels) {
132
+ providerActions.push({ label: copy.checkModels, value: { type: "check-models" }, color: "cyan" });
133
+ }
134
+ if (capabilities.defaultAccountGroup) {
135
+ providerActions.push({
136
+ label: copy.defaultAccountGroup,
137
+ value: { type: "configure-default-group" },
138
+ color: "cyan",
139
+ hint: input.defaultAccountGroupCount !== undefined ? `${input.defaultAccountGroupCount} selected` : undefined,
140
+ });
141
+ }
142
+ if (capabilities.assignModels) {
143
+ providerActions.push({
144
+ label: copy.assignModels,
145
+ value: { type: "assign-models" },
146
+ color: "cyan",
147
+ hint: input.modelAccountAssignmentCount ? `${input.modelAccountAssignmentCount} groups` : undefined,
148
+ });
149
+ }
150
+ const commonSettings = [
151
+ { label: copy.commonSettingsHeading, value: { type: "cancel" }, kind: "heading" },
152
+ {
153
+ label: experimentalSlashCommandsEnabled ? copy.experimentalSlashCommandsOn : copy.experimentalSlashCommandsOff,
154
+ value: { type: "toggle-experimental-slash-commands" },
155
+ color: "cyan",
156
+ hint: copy.experimentalSlashCommandsHint,
157
+ disabled: !capabilities.experimentalSlashCommands,
158
+ },
159
+ {
160
+ label: input.networkRetryEnabled ? copy.retryOn : copy.retryOff,
161
+ value: { type: "toggle-network-retry" },
162
+ color: "cyan",
163
+ hint: copy.retryHint,
164
+ disabled: !capabilities.networkRetry,
165
+ },
166
+ ];
167
+ const providerSettings = [
168
+ { label: copy.providerSettingsHeading, value: { type: "cancel" }, kind: "heading" },
169
+ ];
170
+ providerSettings.push({
171
+ label: input.refresh?.enabled ? copy.autoRefreshOn : copy.autoRefreshOff,
172
+ value: { type: "toggle-refresh" },
173
+ color: "cyan",
174
+ hint: input.refresh ? `${input.refresh.minutes}m` : undefined,
175
+ });
176
+ providerSettings.push({ label: copy.setRefresh, value: { type: "set-interval" }, color: "cyan" });
177
+ if (capabilities.syntheticAgentInitiator) {
178
+ providerSettings.push({
179
+ label: input.syntheticAgentInitiatorEnabled ? copy.syntheticInitiatorOn : copy.syntheticInitiatorOff,
180
+ value: { type: "toggle-synthetic-agent-initiator" },
181
+ color: "cyan",
182
+ hint: copy.syntheticInitiatorHint,
183
+ });
184
+ }
185
+ return [
186
+ ...providerActions,
187
+ { label: "", value: { type: "cancel" }, separator: true },
188
+ ...commonSettings,
189
+ { label: "", value: { type: "cancel" }, separator: true },
190
+ ...providerSettings,
191
+ { label: "", value: { type: "cancel" }, separator: true },
192
+ { label: copy.accountsHeading, value: { type: "cancel" }, kind: "heading" },
193
+ ...input.accounts.map((account) => {
194
+ const statusBadge = getStatusBadge(account.status);
195
+ const currentBadge = account.isCurrent ? ` ${ANSI.cyan}*${ANSI.reset}` : "";
196
+ const format = (s) => s?.unlimited ? "∞" : s?.remaining !== undefined && s?.entitlement !== undefined ? `${s.remaining}/${s.entitlement}` : "?";
197
+ const quotaBadge = account.quota
198
+ ? ` ${ANSI.dim}[${format(account.quota.premium)}|${format(account.quota.chat)}|${format(account.quota.completions)}]${ANSI.reset}`
199
+ : "";
200
+ const numbered = `${account.index + 1}. ${account.name}`;
201
+ const label = `${numbered}${currentBadge}${statusBadge ? ` ${statusBadge}` : ""}${quotaBadge}`;
202
+ const detail = [
203
+ account.workspaceName,
204
+ account.lastUsed ? formatRelativeTime(account.lastUsed) : undefined,
205
+ account.plan,
206
+ account.models ? `${account.models.enabled}/${account.models.enabled + account.models.disabled} mods` : undefined,
207
+ ]
208
+ .filter(Boolean)
209
+ .join(" • ");
210
+ return {
211
+ label,
212
+ hint: detail || undefined,
213
+ value: { type: "switch", account },
214
+ };
215
+ }),
216
+ { label: "", value: { type: "cancel" }, separator: true },
217
+ { label: copy.dangerHeading, value: { type: "cancel" }, kind: "heading" },
218
+ { label: copy.removeAll, value: { type: "remove-all" }, color: "red" },
219
+ ];
220
+ }
221
+ export async function showMenu(accounts, input = {}) {
222
+ return showMenuWithDeps(accounts, input);
223
+ }
224
+ export async function showMenuWithDeps(accounts, input = {}, deps = {}) {
225
+ const selectMenu = deps.select ?? select;
226
+ const confirmAction = deps.confirm ?? confirm;
227
+ const showAccountActionMenu = deps.showAccountActions ?? showAccountActions;
228
+ let currentLanguage = input.language ?? "zh";
229
+ while (true) {
230
+ const provider = input.provider ?? "codex";
231
+ const copy = getMenuCopy(currentLanguage, provider);
232
+ const items = buildMenuItems({
233
+ provider,
234
+ accounts,
235
+ refresh: input.refresh,
236
+ lastQuotaRefresh: input.lastQuotaRefresh,
237
+ modelAccountAssignmentCount: input.modelAccountAssignmentCount,
238
+ defaultAccountGroupCount: input.defaultAccountGroupCount,
239
+ networkRetryEnabled: input.networkRetryEnabled === true,
240
+ syntheticAgentInitiatorEnabled: input.syntheticAgentInitiatorEnabled === true,
241
+ experimentalSlashCommandsEnabled: input.experimentalSlashCommandsEnabled,
242
+ capabilities: input.capabilities,
243
+ language: currentLanguage,
244
+ });
245
+ const result = await selectMenu(items, {
246
+ message: copy.menuTitle,
247
+ subtitle: copy.menuSubtitle,
248
+ clearScreen: true,
249
+ });
250
+ if (!result)
251
+ return { type: "cancel" };
252
+ if (result.type === "toggle-language") {
253
+ currentLanguage = currentLanguage === "zh" ? "en" : "zh";
254
+ continue;
255
+ }
256
+ if (result.type === "switch") {
257
+ const next = await showAccountActionMenu(result.account, { provider });
258
+ if (next === "back")
259
+ continue;
260
+ return { type: next, account: result.account };
261
+ }
262
+ if (result.type === "remove-all") {
263
+ const ok = await confirmAction("Remove ALL accounts? This cannot be undone.");
264
+ if (!ok)
265
+ continue;
266
+ }
267
+ return result;
268
+ }
269
+ }
270
+ export function buildAccountActionItems(account, input = {}) {
271
+ void account;
272
+ void input;
273
+ return [
274
+ { label: "Back", value: "back" },
275
+ { label: "Switch to this account", value: "switch", color: "cyan" },
276
+ { label: "Remove this account", value: "remove", color: "red" },
277
+ ];
278
+ }
279
+ export async function showAccountActions(account, input = {}) {
280
+ void input;
281
+ const badge = getStatusBadge(account.status);
282
+ const header = `${account.name}${badge ? " " + badge : ""}`;
283
+ const info = [
284
+ `Added: ${formatDate(account.addedAt)} | Last used: ${formatRelativeTime(account.lastUsed)}`,
285
+ account.plan ? `Plan: ${account.plan}` : undefined,
286
+ account.sku ? `SKU: ${account.sku}` : undefined,
287
+ account.reset ? `Reset: ${account.reset}` : undefined,
288
+ account.orgs?.length ? `Orgs: ${account.orgs.slice(0, 2).join(",")}` : undefined,
289
+ ]
290
+ .filter(Boolean)
291
+ .join("\n");
292
+ const subtitle = info;
293
+ while (true) {
294
+ const result = await select(buildAccountActionItems(account), { message: header, subtitle, clearScreen: true, autoSelectSingle: false });
295
+ if (result === "models") {
296
+ continue;
297
+ }
298
+ if (result === "remove") {
299
+ const ok = await confirm(`Remove ${account.name}?`);
300
+ if (!ok)
301
+ continue;
302
+ }
303
+ return result ?? "back";
304
+ }
305
+ }
@@ -0,0 +1,36 @@
1
+ export declare function buildSelectDebugEvent(input: {
2
+ stage: "key" | "result";
3
+ parsedKey: string | null;
4
+ currentValue?: unknown;
5
+ nextValue?: unknown;
6
+ }): {
7
+ stage: "key" | "result";
8
+ parsedKey: string | null;
9
+ currentActionType: string | null;
10
+ nextActionType: string | null;
11
+ actionType: string;
12
+ } | undefined;
13
+ export interface MenuItem<T = string> {
14
+ label: string;
15
+ value: T;
16
+ hint?: string;
17
+ disabled?: boolean;
18
+ separator?: boolean;
19
+ kind?: "heading";
20
+ color?: "red" | "green" | "yellow" | "cyan";
21
+ }
22
+ export interface SelectOptions {
23
+ message: string;
24
+ subtitle?: string;
25
+ help?: string;
26
+ clearScreen?: boolean;
27
+ autoSelectSingle?: boolean;
28
+ }
29
+ export interface SelectManyOptions extends SelectOptions {
30
+ doneLabel?: string;
31
+ backLabel?: string;
32
+ minSelected?: number;
33
+ initialSelected?: number[];
34
+ }
35
+ export declare function select<T>(items: MenuItem<T>[], options: SelectOptions): Promise<T | null>;
36
+ export declare function selectMany<T>(items: MenuItem<T>[], options: SelectManyOptions): Promise<T[] | null>;
@@ -0,0 +1,350 @@
1
+ import { ANSI, isTTY, parseKey } from "./ansi.js";
2
+ const defaultSelectDebugLogFile = (() => {
3
+ const tmp = process.env.TEMP || process.env.TMP || "/tmp";
4
+ return `${tmp}/opencode-codex-store-debug.log`;
5
+ })();
6
+ function shouldLogSuspiciousAction(actionType) {
7
+ return actionType === "toggle-network-retry";
8
+ }
9
+ function getActionType(value) {
10
+ if (!value || typeof value !== "object")
11
+ return undefined;
12
+ const actionType = value.type;
13
+ return typeof actionType === "string" ? actionType : undefined;
14
+ }
15
+ export function buildSelectDebugEvent(input) {
16
+ const currentActionType = getActionType(input.currentValue);
17
+ const nextActionType = getActionType(input.nextValue);
18
+ const actionType = nextActionType ?? currentActionType;
19
+ if (!shouldLogSuspiciousAction(actionType))
20
+ return undefined;
21
+ return {
22
+ stage: input.stage,
23
+ parsedKey: input.parsedKey,
24
+ currentActionType: currentActionType ?? null,
25
+ nextActionType: nextActionType ?? null,
26
+ actionType,
27
+ };
28
+ }
29
+ async function logSelectDebug(input) {
30
+ const event = buildSelectDebugEvent(input);
31
+ if (!event)
32
+ return;
33
+ const filePath = process.env.OPENCODE_CODEX_STORE_DEBUG_FILE || defaultSelectDebugLogFile;
34
+ try {
35
+ const { promises: fs } = await import("node:fs");
36
+ const { dirname } = await import("node:path");
37
+ await fs.mkdir(dirname(filePath), { recursive: true });
38
+ await fs.appendFile(filePath, `${JSON.stringify({ kind: "select-action", at: new Date().toISOString(), ...event })}\n`, "utf8");
39
+ }
40
+ catch (error) {
41
+ console.warn("[codex-store-debug] failed to write select debug log", error);
42
+ }
43
+ }
44
+ const ESCAPE_TIMEOUT_MS = 50;
45
+ const ANSI_REGEX = new RegExp("\\x1b\\[[0-9;]*m", "g");
46
+ const ANSI_LEADING_REGEX = new RegExp("^\\x1b\\[[0-9;]*m");
47
+ function stripAnsi(input) {
48
+ return input.replace(ANSI_REGEX, "");
49
+ }
50
+ function truncateAnsi(input, maxVisibleChars) {
51
+ if (maxVisibleChars <= 0)
52
+ return "";
53
+ const visible = stripAnsi(input);
54
+ if (visible.length <= maxVisibleChars)
55
+ return input;
56
+ const suffix = maxVisibleChars >= 3 ? "..." : ".".repeat(maxVisibleChars);
57
+ const keep = Math.max(0, maxVisibleChars - suffix.length);
58
+ let out = "";
59
+ let i = 0;
60
+ let kept = 0;
61
+ while (i < input.length && kept < keep) {
62
+ if (input[i] === "\x1b") {
63
+ const m = input.slice(i).match(ANSI_LEADING_REGEX);
64
+ if (m) {
65
+ out += m[0];
66
+ i += m[0].length;
67
+ continue;
68
+ }
69
+ }
70
+ out += input[i];
71
+ i += 1;
72
+ kept += 1;
73
+ }
74
+ if (out.includes("\x1b["))
75
+ return `${out}${ANSI.reset}${suffix}`;
76
+ return out + suffix;
77
+ }
78
+ function getColorCode(color) {
79
+ if (color === "red")
80
+ return ANSI.red;
81
+ if (color === "green")
82
+ return ANSI.green;
83
+ if (color === "yellow")
84
+ return ANSI.yellow;
85
+ if (color === "cyan")
86
+ return ANSI.cyan;
87
+ return "";
88
+ }
89
+ export async function select(items, options) {
90
+ if (!isTTY())
91
+ throw new Error("Interactive select requires a TTY terminal");
92
+ if (items.length === 0)
93
+ throw new Error("No menu items provided");
94
+ const isSelectable = (i) => !i.disabled && !i.separator && i.kind !== "heading";
95
+ const enabled = items.filter(isSelectable);
96
+ if (enabled.length === 0)
97
+ throw new Error("All items disabled");
98
+ const autoSelectSingle = options.autoSelectSingle ?? true;
99
+ if (enabled.length === 1 && autoSelectSingle)
100
+ return enabled[0]?.value ?? null;
101
+ const { message, subtitle } = options;
102
+ const { stdin, stdout } = process;
103
+ let cursor = items.findIndex(isSelectable);
104
+ if (cursor === -1)
105
+ cursor = 0;
106
+ let escapeTimeout = null;
107
+ let done = false;
108
+ let rendered = 0;
109
+ const render = () => {
110
+ const columns = stdout.columns ?? 80;
111
+ const rows = stdout.rows ?? 24;
112
+ const clearScreen = options.clearScreen === true;
113
+ const prev = rendered;
114
+ if (clearScreen) {
115
+ stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1));
116
+ }
117
+ else if (prev > 0) {
118
+ stdout.write(ANSI.up(prev));
119
+ }
120
+ let lines = 0;
121
+ const write = (line) => {
122
+ stdout.write(`${ANSI.clearLine}${line}\n`);
123
+ lines += 1;
124
+ };
125
+ const subtitleLines = subtitle ? subtitle.split("\n").length + 2 : 0;
126
+ const fixed = 1 + subtitleLines + 2;
127
+ const maxVisible = Math.max(1, Math.min(items.length, rows - fixed - 1));
128
+ let windowStart = 0;
129
+ let windowEnd = items.length;
130
+ if (items.length > maxVisible) {
131
+ windowStart = cursor - Math.floor(maxVisible / 2);
132
+ windowStart = Math.max(0, Math.min(windowStart, items.length - maxVisible));
133
+ windowEnd = windowStart + maxVisible;
134
+ }
135
+ const visibleItems = items.slice(windowStart, windowEnd);
136
+ const header = truncateAnsi(message, Math.max(1, columns - 4));
137
+ write(`${ANSI.dim}┌ ${ANSI.reset}${header}`);
138
+ if (subtitle) {
139
+ write(`${ANSI.dim}│${ANSI.reset}`);
140
+ for (const line of subtitle.split("\n")) {
141
+ const sub = truncateAnsi(line, Math.max(1, columns - 4));
142
+ write(`${ANSI.cyan}◆${ANSI.reset} ${sub}`);
143
+ }
144
+ write("");
145
+ }
146
+ for (let i = 0; i < visibleItems.length; i += 1) {
147
+ const index = windowStart + i;
148
+ const item = visibleItems[i];
149
+ if (!item)
150
+ continue;
151
+ if (item.separator) {
152
+ write(`${ANSI.dim}│${ANSI.reset}`);
153
+ continue;
154
+ }
155
+ if (item.kind === "heading") {
156
+ const heading = truncateAnsi(`${ANSI.dim}${ANSI.bold}${item.label}${ANSI.reset}`, Math.max(1, columns - 6));
157
+ write(`${ANSI.cyan}│${ANSI.reset} ${heading}`);
158
+ continue;
159
+ }
160
+ const selected = index === cursor;
161
+ const color = getColorCode(item.color);
162
+ let label;
163
+ if (item.disabled) {
164
+ label = `${ANSI.dim}${item.label} (unavailable)${ANSI.reset}`;
165
+ }
166
+ else if (selected) {
167
+ label = color ? `${color}${item.label}${ANSI.reset}` : item.label;
168
+ if (item.hint)
169
+ label += ` ${ANSI.dim}${item.hint}${ANSI.reset}`;
170
+ }
171
+ else {
172
+ label = color ? `${ANSI.dim}${color}${item.label}${ANSI.reset}` : `${ANSI.dim}${item.label}${ANSI.reset}`;
173
+ if (item.hint)
174
+ label += ` ${ANSI.dim}${item.hint}${ANSI.reset}`;
175
+ }
176
+ label = truncateAnsi(label, Math.max(1, columns - 8));
177
+ if (selected) {
178
+ write(`${ANSI.cyan}│${ANSI.reset} ${ANSI.green}●${ANSI.reset} ${label}`);
179
+ }
180
+ else {
181
+ write(`${ANSI.cyan}│${ANSI.reset} ${ANSI.dim}○${ANSI.reset} ${label}`);
182
+ }
183
+ }
184
+ const windowHint = items.length > visibleItems.length ? ` (${windowStart + 1}-${windowEnd}/${items.length})` : "";
185
+ const helpText = options.help ?? `Up/Down to select | Enter: confirm | Esc: back${windowHint}`;
186
+ const help = truncateAnsi(helpText, Math.max(1, columns - 6));
187
+ write(`${ANSI.cyan}│${ANSI.reset} ${ANSI.dim}${help}${ANSI.reset}`);
188
+ write(`${ANSI.cyan}└${ANSI.reset}`);
189
+ if (!clearScreen && prev > lines) {
190
+ const extra = prev - lines;
191
+ for (let i = 0; i < extra; i += 1)
192
+ write("");
193
+ }
194
+ rendered = lines;
195
+ };
196
+ return new Promise((resolve) => {
197
+ const wasRaw = stdin.isRaw ?? false;
198
+ const cleanup = () => {
199
+ if (done)
200
+ return;
201
+ done = true;
202
+ if (escapeTimeout) {
203
+ clearTimeout(escapeTimeout);
204
+ escapeTimeout = null;
205
+ }
206
+ try {
207
+ stdin.removeListener("data", onKey);
208
+ stdin.setRawMode(wasRaw);
209
+ stdin.pause();
210
+ stdout.write(ANSI.show);
211
+ }
212
+ catch { }
213
+ process.removeListener("SIGINT", onSignal);
214
+ process.removeListener("SIGTERM", onSignal);
215
+ };
216
+ const onSignal = () => {
217
+ cleanup();
218
+ resolve(null);
219
+ };
220
+ const finish = (value) => {
221
+ cleanup();
222
+ resolve(value);
223
+ };
224
+ const findNextSelectable = (from, direction) => {
225
+ if (items.length === 0)
226
+ return from;
227
+ let next = from;
228
+ do {
229
+ next = (next + direction + items.length) % items.length;
230
+ } while (items[next]?.disabled || items[next]?.separator || items[next]?.kind === "heading");
231
+ return next;
232
+ };
233
+ const onKey = (data) => {
234
+ if (escapeTimeout) {
235
+ clearTimeout(escapeTimeout);
236
+ escapeTimeout = null;
237
+ }
238
+ const action = parseKey(data);
239
+ if (action === "up") {
240
+ void logSelectDebug({ stage: "key", parsedKey: action, currentValue: items[cursor]?.value });
241
+ cursor = findNextSelectable(cursor, -1);
242
+ render();
243
+ return;
244
+ }
245
+ if (action === "down") {
246
+ void logSelectDebug({ stage: "key", parsedKey: action, currentValue: items[cursor]?.value });
247
+ cursor = findNextSelectable(cursor, 1);
248
+ render();
249
+ return;
250
+ }
251
+ if (action === "enter") {
252
+ void logSelectDebug({
253
+ stage: "key",
254
+ parsedKey: action,
255
+ currentValue: items[cursor]?.value,
256
+ });
257
+ void logSelectDebug({
258
+ stage: "result",
259
+ parsedKey: action,
260
+ currentValue: items[cursor]?.value,
261
+ nextValue: items[cursor]?.value,
262
+ });
263
+ finish(items[cursor]?.value ?? null);
264
+ return;
265
+ }
266
+ if (action === "escape") {
267
+ finish(null);
268
+ return;
269
+ }
270
+ if (action === "escape-start") {
271
+ escapeTimeout = setTimeout(() => {
272
+ finish(null);
273
+ }, ESCAPE_TIMEOUT_MS);
274
+ return;
275
+ }
276
+ };
277
+ process.once("SIGINT", onSignal);
278
+ process.once("SIGTERM", onSignal);
279
+ try {
280
+ stdin.setRawMode(true);
281
+ }
282
+ catch {
283
+ cleanup();
284
+ resolve(null);
285
+ return;
286
+ }
287
+ stdin.resume();
288
+ stdout.write(ANSI.hide);
289
+ render();
290
+ stdin.on("data", onKey);
291
+ });
292
+ }
293
+ export async function selectMany(items, options) {
294
+ const selectable = items
295
+ .map((item, index) => ({ item, index }))
296
+ .filter(({ item }) => !item.disabled && !item.separator && item.kind !== "heading");
297
+ if (selectable.length === 0)
298
+ return [];
299
+ const selected = new Set();
300
+ for (const index of options.initialSelected ?? []) {
301
+ if (Number.isInteger(index) && index >= 0 && index < selectable.length) {
302
+ selected.add(index);
303
+ }
304
+ }
305
+ const DONE = "__select_many_done__";
306
+ const BACK = "__select_many_back__";
307
+ while (true) {
308
+ const menuItems = [
309
+ { label: options.backLabel ?? "Back", value: BACK },
310
+ {
311
+ label: options.doneLabel ?? "Done",
312
+ value: DONE,
313
+ color: "cyan",
314
+ hint: `${selected.size} selected`,
315
+ },
316
+ { label: "", value: "", separator: true },
317
+ ...selectable.map(({ item }, index) => {
318
+ const marker = selected.has(index) ? "[x]" : "[ ]";
319
+ return {
320
+ label: `${marker} ${item.label}`,
321
+ value: String(index),
322
+ hint: item.hint,
323
+ color: item.color,
324
+ };
325
+ }),
326
+ ];
327
+ const choice = await select(menuItems, {
328
+ ...options,
329
+ autoSelectSingle: false,
330
+ help: options.help ?? "Up/Down to select | Enter: toggle | Choose Done to confirm | Esc: back",
331
+ });
332
+ if (choice === null || choice === BACK)
333
+ return null;
334
+ if (choice === DONE) {
335
+ if ((options.minSelected ?? 0) > selected.size)
336
+ continue;
337
+ return selectable
338
+ .map(({ item }, index) => ({ item, index }))
339
+ .filter(({ index }) => selected.has(index))
340
+ .map(({ item }) => item.value);
341
+ }
342
+ const index = Number(choice);
343
+ if (!Number.isInteger(index) || index < 0 || index >= selectable.length)
344
+ continue;
345
+ if (selected.has(index))
346
+ selected.delete(index);
347
+ else
348
+ selected.add(index);
349
+ }
350
+ }