opencode-copilot-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.
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/plugin.d.ts +2 -0
- package/dist/plugin.js +707 -0
- package/dist/store.d.ts +62 -0
- package/dist/store.js +63 -0
- package/dist/ui/ansi.d.ts +18 -0
- package/dist/ui/ansi.js +32 -0
- package/dist/ui/confirm.d.ts +1 -0
- package/dist/ui/confirm.js +14 -0
- package/dist/ui/menu.d.ts +70 -0
- package/dist/ui/menu.js +153 -0
- package/dist/ui/select.d.ts +17 -0
- package/dist/ui/select.js +237 -0
- package/package.json +49 -0
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export type AccountEntry = {
|
|
2
|
+
name: string;
|
|
3
|
+
refresh: string;
|
|
4
|
+
access: string;
|
|
5
|
+
expires: number;
|
|
6
|
+
enterpriseUrl?: string;
|
|
7
|
+
user?: string;
|
|
8
|
+
email?: string;
|
|
9
|
+
orgs?: string[];
|
|
10
|
+
addedAt?: number;
|
|
11
|
+
lastUsed?: number;
|
|
12
|
+
source?: "auth" | "manual";
|
|
13
|
+
providerId?: string;
|
|
14
|
+
quota?: {
|
|
15
|
+
plan?: string;
|
|
16
|
+
sku?: string;
|
|
17
|
+
reset?: string;
|
|
18
|
+
updatedAt?: number;
|
|
19
|
+
error?: string;
|
|
20
|
+
snapshots?: {
|
|
21
|
+
premium?: {
|
|
22
|
+
entitlement?: number;
|
|
23
|
+
remaining?: number;
|
|
24
|
+
used?: number;
|
|
25
|
+
unlimited?: boolean;
|
|
26
|
+
percentRemaining?: number;
|
|
27
|
+
};
|
|
28
|
+
chat?: {
|
|
29
|
+
entitlement?: number;
|
|
30
|
+
remaining?: number;
|
|
31
|
+
used?: number;
|
|
32
|
+
unlimited?: boolean;
|
|
33
|
+
percentRemaining?: number;
|
|
34
|
+
};
|
|
35
|
+
completions?: {
|
|
36
|
+
entitlement?: number;
|
|
37
|
+
remaining?: number;
|
|
38
|
+
used?: number;
|
|
39
|
+
unlimited?: boolean;
|
|
40
|
+
percentRemaining?: number;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
models?: {
|
|
45
|
+
available: string[];
|
|
46
|
+
disabled: string[];
|
|
47
|
+
updatedAt?: number;
|
|
48
|
+
error?: string;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
export type StoreFile = {
|
|
52
|
+
active?: string;
|
|
53
|
+
accounts: Record<string, AccountEntry>;
|
|
54
|
+
autoRefresh?: boolean;
|
|
55
|
+
refreshMinutes?: number;
|
|
56
|
+
lastQuotaRefresh?: number;
|
|
57
|
+
};
|
|
58
|
+
export declare function storePath(): string;
|
|
59
|
+
export declare function authPath(): string;
|
|
60
|
+
export declare function readStore(): Promise<StoreFile>;
|
|
61
|
+
export declare function readAuth(filePath?: string): Promise<Record<string, AccountEntry>>;
|
|
62
|
+
export declare function writeStore(store: StoreFile): Promise<void>;
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
import { xdgConfig, xdgData } from "xdg-basedir";
|
|
5
|
+
const filename = "copilot-accounts.json";
|
|
6
|
+
const authFile = "auth.json";
|
|
7
|
+
export function storePath() {
|
|
8
|
+
const base = xdgConfig ?? path.join(os.homedir(), ".config");
|
|
9
|
+
return path.join(base, "opencode", filename);
|
|
10
|
+
}
|
|
11
|
+
export function authPath() {
|
|
12
|
+
const dataDir = xdgData ?? path.join(os.homedir(), ".local", "share");
|
|
13
|
+
return path.join(dataDir, "opencode", authFile);
|
|
14
|
+
}
|
|
15
|
+
export async function readStore() {
|
|
16
|
+
const file = storePath();
|
|
17
|
+
const raw = await fs.readFile(file, "utf-8").catch(() => "");
|
|
18
|
+
const data = raw ? JSON.parse(raw) : { accounts: {} };
|
|
19
|
+
if (!data.accounts)
|
|
20
|
+
data.accounts = {};
|
|
21
|
+
for (const [name, entry] of Object.entries(data.accounts)) {
|
|
22
|
+
const info = entry;
|
|
23
|
+
if (!info.name)
|
|
24
|
+
info.name = name;
|
|
25
|
+
}
|
|
26
|
+
return data;
|
|
27
|
+
}
|
|
28
|
+
export async function readAuth(filePath) {
|
|
29
|
+
const dataFile = path.join(xdgData ?? path.join(os.homedir(), ".local", "share"), "opencode", authFile);
|
|
30
|
+
const configFile = path.join(xdgConfig ?? path.join(os.homedir(), ".config"), "opencode", authFile);
|
|
31
|
+
const files = filePath ? [filePath] : [dataFile, configFile];
|
|
32
|
+
let raw = "";
|
|
33
|
+
for (const file of files) {
|
|
34
|
+
raw = await fs.readFile(file, "utf-8").catch(() => "");
|
|
35
|
+
if (raw)
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
if (!raw)
|
|
39
|
+
return {};
|
|
40
|
+
const parsed = JSON.parse(raw);
|
|
41
|
+
return Object.entries(parsed).reduce((acc, [key, value]) => {
|
|
42
|
+
if (!value || typeof value !== "object")
|
|
43
|
+
return acc;
|
|
44
|
+
const info = value;
|
|
45
|
+
if (info.type !== "oauth" || !(info.refresh || info.access))
|
|
46
|
+
return acc;
|
|
47
|
+
acc[key] = {
|
|
48
|
+
name: `auth:${key}`,
|
|
49
|
+
refresh: info.refresh ?? info.access,
|
|
50
|
+
access: info.access ?? info.refresh,
|
|
51
|
+
expires: info.expires ?? 0,
|
|
52
|
+
enterpriseUrl: info.enterpriseUrl,
|
|
53
|
+
source: "auth",
|
|
54
|
+
providerId: key,
|
|
55
|
+
};
|
|
56
|
+
return acc;
|
|
57
|
+
}, {});
|
|
58
|
+
}
|
|
59
|
+
export async function writeStore(store) {
|
|
60
|
+
const file = storePath();
|
|
61
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
62
|
+
await fs.writeFile(file, JSON.stringify(store, null, 2), { mode: 0o600 });
|
|
63
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare const ANSI: {
|
|
2
|
+
readonly hide: "\u001B[?25l";
|
|
3
|
+
readonly show: "\u001B[?25h";
|
|
4
|
+
readonly up: (n?: number) => string;
|
|
5
|
+
readonly clearLine: "\u001B[2K";
|
|
6
|
+
readonly clearScreen: "\u001B[2J";
|
|
7
|
+
readonly moveTo: (row: number, col: number) => string;
|
|
8
|
+
readonly cyan: "\u001B[36m";
|
|
9
|
+
readonly green: "\u001B[32m";
|
|
10
|
+
readonly red: "\u001B[31m";
|
|
11
|
+
readonly yellow: "\u001B[33m";
|
|
12
|
+
readonly dim: "\u001B[2m";
|
|
13
|
+
readonly bold: "\u001B[1m";
|
|
14
|
+
readonly reset: "\u001B[0m";
|
|
15
|
+
};
|
|
16
|
+
export type KeyAction = "up" | "down" | "enter" | "escape" | "escape-start" | null;
|
|
17
|
+
export declare function parseKey(data: Buffer): KeyAction;
|
|
18
|
+
export declare function isTTY(): boolean;
|
package/dist/ui/ansi.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const ANSI = {
|
|
2
|
+
hide: "\x1b[?25l",
|
|
3
|
+
show: "\x1b[?25h",
|
|
4
|
+
up: (n = 1) => `\x1b[${n}A`,
|
|
5
|
+
clearLine: "\x1b[2K",
|
|
6
|
+
clearScreen: "\x1b[2J",
|
|
7
|
+
moveTo: (row, col) => `\x1b[${row};${col}H`,
|
|
8
|
+
cyan: "\x1b[36m",
|
|
9
|
+
green: "\x1b[32m",
|
|
10
|
+
red: "\x1b[31m",
|
|
11
|
+
yellow: "\x1b[33m",
|
|
12
|
+
dim: "\x1b[2m",
|
|
13
|
+
bold: "\x1b[1m",
|
|
14
|
+
reset: "\x1b[0m",
|
|
15
|
+
};
|
|
16
|
+
export function parseKey(data) {
|
|
17
|
+
const s = data.toString();
|
|
18
|
+
if (s === "\x1b[A" || s === "\x1bOA")
|
|
19
|
+
return "up";
|
|
20
|
+
if (s === "\x1b[B" || s === "\x1bOB")
|
|
21
|
+
return "down";
|
|
22
|
+
if (s === "\r" || s === "\n")
|
|
23
|
+
return "enter";
|
|
24
|
+
if (s === "\x03")
|
|
25
|
+
return "escape";
|
|
26
|
+
if (s === "\x1b")
|
|
27
|
+
return "escape-start";
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
export function isTTY() {
|
|
31
|
+
return Boolean(process.stdin.isTTY);
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function confirm(message: string, defaultYes?: boolean): Promise<boolean>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { select } from "./select";
|
|
2
|
+
export async function confirm(message, defaultYes = false) {
|
|
3
|
+
const items = defaultYes
|
|
4
|
+
? [
|
|
5
|
+
{ label: "Yes", value: true },
|
|
6
|
+
{ label: "No", value: false },
|
|
7
|
+
]
|
|
8
|
+
: [
|
|
9
|
+
{ label: "No", value: false },
|
|
10
|
+
{ label: "Yes", value: true },
|
|
11
|
+
];
|
|
12
|
+
const result = await select(items, { message });
|
|
13
|
+
return result ?? false;
|
|
14
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export type AccountStatus = "active" | "expired" | "unknown";
|
|
2
|
+
export interface AccountInfo {
|
|
3
|
+
name: string;
|
|
4
|
+
index: number;
|
|
5
|
+
addedAt?: number;
|
|
6
|
+
lastUsed?: number;
|
|
7
|
+
status?: AccountStatus;
|
|
8
|
+
isCurrent?: boolean;
|
|
9
|
+
source?: "auth" | "manual";
|
|
10
|
+
orgs?: string[];
|
|
11
|
+
plan?: string;
|
|
12
|
+
sku?: string;
|
|
13
|
+
reset?: string;
|
|
14
|
+
models?: {
|
|
15
|
+
enabled: number;
|
|
16
|
+
disabled: number;
|
|
17
|
+
};
|
|
18
|
+
modelsError?: string;
|
|
19
|
+
modelList?: {
|
|
20
|
+
available: string[];
|
|
21
|
+
disabled: string[];
|
|
22
|
+
};
|
|
23
|
+
quota?: {
|
|
24
|
+
premium?: {
|
|
25
|
+
remaining?: number;
|
|
26
|
+
entitlement?: number;
|
|
27
|
+
unlimited?: boolean;
|
|
28
|
+
};
|
|
29
|
+
chat?: {
|
|
30
|
+
remaining?: number;
|
|
31
|
+
entitlement?: number;
|
|
32
|
+
unlimited?: boolean;
|
|
33
|
+
};
|
|
34
|
+
completions?: {
|
|
35
|
+
remaining?: number;
|
|
36
|
+
entitlement?: number;
|
|
37
|
+
unlimited?: boolean;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export type MenuAction = {
|
|
42
|
+
type: "add";
|
|
43
|
+
} | {
|
|
44
|
+
type: "import";
|
|
45
|
+
} | {
|
|
46
|
+
type: "quota";
|
|
47
|
+
} | {
|
|
48
|
+
type: "refresh-identity";
|
|
49
|
+
} | {
|
|
50
|
+
type: "check-models";
|
|
51
|
+
} | {
|
|
52
|
+
type: "toggle-refresh";
|
|
53
|
+
} | {
|
|
54
|
+
type: "set-interval";
|
|
55
|
+
} | {
|
|
56
|
+
type: "switch";
|
|
57
|
+
account: AccountInfo;
|
|
58
|
+
} | {
|
|
59
|
+
type: "remove";
|
|
60
|
+
account: AccountInfo;
|
|
61
|
+
} | {
|
|
62
|
+
type: "remove-all";
|
|
63
|
+
} | {
|
|
64
|
+
type: "cancel";
|
|
65
|
+
};
|
|
66
|
+
export declare function showMenu(accounts: AccountInfo[], refresh?: {
|
|
67
|
+
enabled: boolean;
|
|
68
|
+
minutes: number;
|
|
69
|
+
}, lastQuotaRefresh?: number): Promise<MenuAction>;
|
|
70
|
+
export declare function showAccountActions(account: AccountInfo): Promise<"switch" | "remove" | "back">;
|
package/dist/ui/menu.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { ANSI } from "./ansi";
|
|
2
|
+
import { select } from "./select";
|
|
3
|
+
import { confirm } from "./confirm";
|
|
4
|
+
function formatRelativeTime(timestamp) {
|
|
5
|
+
if (!timestamp)
|
|
6
|
+
return "never";
|
|
7
|
+
const days = Math.floor((Date.now() - timestamp) / 86400000);
|
|
8
|
+
if (days === 0)
|
|
9
|
+
return "today";
|
|
10
|
+
if (days === 1)
|
|
11
|
+
return "yesterday";
|
|
12
|
+
if (days < 7)
|
|
13
|
+
return `${days}d ago`;
|
|
14
|
+
if (days < 30)
|
|
15
|
+
return `${Math.floor(days / 7)}w ago`;
|
|
16
|
+
return new Date(timestamp).toLocaleDateString();
|
|
17
|
+
}
|
|
18
|
+
function formatDate(timestamp) {
|
|
19
|
+
if (!timestamp)
|
|
20
|
+
return "unknown";
|
|
21
|
+
return new Date(timestamp).toLocaleDateString();
|
|
22
|
+
}
|
|
23
|
+
function getStatusBadge(status) {
|
|
24
|
+
if (status === "expired")
|
|
25
|
+
return `${ANSI.red}[expired]${ANSI.reset}`;
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
export async function showMenu(accounts, refresh, lastQuotaRefresh) {
|
|
29
|
+
const quotaHint = lastQuotaRefresh ? `last ${formatRelativeTime(lastQuotaRefresh)}` : undefined;
|
|
30
|
+
const items = [
|
|
31
|
+
{ label: "Actions", value: { type: "cancel" }, kind: "heading" },
|
|
32
|
+
{ label: "Add account", value: { type: "add" }, color: "cyan", hint: "device login or manual" },
|
|
33
|
+
{ label: "Import from auth.json", value: { type: "import" }, color: "cyan" },
|
|
34
|
+
{ label: "Check quotas", value: { type: "quota" }, color: "cyan", hint: quotaHint },
|
|
35
|
+
{ label: "Refresh identity", value: { type: "refresh-identity" }, color: "cyan" },
|
|
36
|
+
{ label: "Check models", value: { type: "check-models" }, color: "cyan" },
|
|
37
|
+
{
|
|
38
|
+
label: refresh?.enabled ? "Disable auto refresh" : "Enable auto refresh",
|
|
39
|
+
value: { type: "toggle-refresh" },
|
|
40
|
+
color: "cyan",
|
|
41
|
+
hint: refresh ? `${refresh.minutes}m` : undefined,
|
|
42
|
+
},
|
|
43
|
+
{ label: "Set refresh interval", value: { type: "set-interval" }, color: "cyan" },
|
|
44
|
+
{ label: "", value: { type: "cancel" }, separator: true },
|
|
45
|
+
{ label: "Accounts", value: { type: "cancel" }, kind: "heading" },
|
|
46
|
+
...accounts.map((account) => {
|
|
47
|
+
const statusBadge = getStatusBadge(account.status);
|
|
48
|
+
const currentBadge = account.isCurrent ? ` ${ANSI.cyan}*${ANSI.reset}` : "";
|
|
49
|
+
const format = (s) => s?.unlimited ? "∞" : s?.remaining !== undefined && s?.entitlement !== undefined ? `${s.remaining}/${s.entitlement}` : "?";
|
|
50
|
+
const quotaBadge = account.quota
|
|
51
|
+
? ` ${ANSI.dim}[${format(account.quota.premium)}|${format(account.quota.chat)}|${format(account.quota.completions)}]${ANSI.reset}`
|
|
52
|
+
: "";
|
|
53
|
+
const numbered = `${account.index + 1}. ${account.name}`;
|
|
54
|
+
const label = `${numbered}${currentBadge}${statusBadge ? " " + statusBadge : ""}${quotaBadge}`;
|
|
55
|
+
const detail = [
|
|
56
|
+
account.lastUsed ? formatRelativeTime(account.lastUsed) : undefined,
|
|
57
|
+
account.plan,
|
|
58
|
+
account.models ? `${account.models.enabled}/${account.models.enabled + account.models.disabled} mods` : undefined,
|
|
59
|
+
]
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.join(" • ");
|
|
62
|
+
return {
|
|
63
|
+
label,
|
|
64
|
+
hint: detail || undefined,
|
|
65
|
+
value: { type: "switch", account },
|
|
66
|
+
};
|
|
67
|
+
}),
|
|
68
|
+
{ label: "", value: { type: "cancel" }, separator: true },
|
|
69
|
+
{ label: "Danger zone", value: { type: "cancel" }, kind: "heading" },
|
|
70
|
+
{ label: "Remove all accounts", value: { type: "remove-all" }, color: "red" },
|
|
71
|
+
];
|
|
72
|
+
while (true) {
|
|
73
|
+
const result = await select(items, {
|
|
74
|
+
message: "GitHub Copilot accounts",
|
|
75
|
+
subtitle: "Select an action or account",
|
|
76
|
+
clearScreen: true,
|
|
77
|
+
});
|
|
78
|
+
if (!result)
|
|
79
|
+
return { type: "cancel" };
|
|
80
|
+
if (result.type === "remove-all") {
|
|
81
|
+
const ok = await confirm("Remove ALL accounts? This cannot be undone.");
|
|
82
|
+
if (!ok)
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export async function showAccountActions(account) {
|
|
89
|
+
const badge = getStatusBadge(account.status);
|
|
90
|
+
const header = `${account.name}${badge ? " " + badge : ""}`;
|
|
91
|
+
const info = [
|
|
92
|
+
`Added: ${formatDate(account.addedAt)} | Last used: ${formatRelativeTime(account.lastUsed)}`,
|
|
93
|
+
account.plan ? `Plan: ${account.plan}` : undefined,
|
|
94
|
+
account.sku ? `SKU: ${account.sku}` : undefined,
|
|
95
|
+
account.reset ? `Reset: ${account.reset}` : undefined,
|
|
96
|
+
account.models ? `Models: ${account.models.enabled}/${account.models.enabled + account.models.disabled}` : undefined,
|
|
97
|
+
account.orgs?.length ? `Orgs: ${account.orgs.slice(0, 2).join(",")}` : undefined,
|
|
98
|
+
account.modelsError ? `Models error: ${account.modelsError}` : undefined,
|
|
99
|
+
]
|
|
100
|
+
.filter(Boolean)
|
|
101
|
+
.join("\n");
|
|
102
|
+
const subtitle = info;
|
|
103
|
+
while (true) {
|
|
104
|
+
const modelAction = account.modelList || account.modelsError
|
|
105
|
+
? [{ label: "View models", value: "models", color: "cyan" }]
|
|
106
|
+
: [];
|
|
107
|
+
const result = await select([
|
|
108
|
+
{ label: "Back", value: "back" },
|
|
109
|
+
...modelAction,
|
|
110
|
+
{ label: "Switch to this account", value: "switch", color: "cyan" },
|
|
111
|
+
{ label: "Remove this account", value: "remove", color: "red" },
|
|
112
|
+
], { message: header, subtitle, clearScreen: true, autoSelectSingle: false });
|
|
113
|
+
if (result === "models") {
|
|
114
|
+
await showModels(account);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (result === "remove") {
|
|
118
|
+
const ok = await confirm(`Remove ${account.name}?`);
|
|
119
|
+
if (!ok)
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
return result ?? "back";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async function showModels(account) {
|
|
126
|
+
const available = account.modelList?.available ?? [];
|
|
127
|
+
const disabled = account.modelList?.disabled ?? [];
|
|
128
|
+
const items = [
|
|
129
|
+
{ label: "Back", value: "back" },
|
|
130
|
+
{ label: "", value: "", separator: true },
|
|
131
|
+
];
|
|
132
|
+
if (account.modelsError) {
|
|
133
|
+
items.push({ label: `Error: ${account.modelsError}`, value: "err", disabled: true, color: "red" });
|
|
134
|
+
items.push({ label: "Run Check models from the main menu", value: "hint", disabled: true });
|
|
135
|
+
}
|
|
136
|
+
else if (!account.modelList) {
|
|
137
|
+
items.push({ label: "Models not checked", value: "hint", disabled: true });
|
|
138
|
+
items.push({ label: "Run Check models from the main menu", value: "hint2", disabled: true });
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
items.push({ label: "Available", value: "", kind: "heading" });
|
|
142
|
+
items.push(...available.map((name) => ({ label: name, value: name, color: "green" })));
|
|
143
|
+
items.push({ label: "", value: "", separator: true });
|
|
144
|
+
items.push({ label: "Disabled", value: "", kind: "heading" });
|
|
145
|
+
items.push(...disabled.map((name) => ({ label: name, value: name, color: "red" })));
|
|
146
|
+
}
|
|
147
|
+
await select(items, {
|
|
148
|
+
message: "Copilot models",
|
|
149
|
+
subtitle: account.name,
|
|
150
|
+
clearScreen: true,
|
|
151
|
+
autoSelectSingle: false,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface MenuItem<T = string> {
|
|
2
|
+
label: string;
|
|
3
|
+
value: T;
|
|
4
|
+
hint?: string;
|
|
5
|
+
disabled?: boolean;
|
|
6
|
+
separator?: boolean;
|
|
7
|
+
kind?: "heading";
|
|
8
|
+
color?: "red" | "green" | "yellow" | "cyan";
|
|
9
|
+
}
|
|
10
|
+
export interface SelectOptions {
|
|
11
|
+
message: string;
|
|
12
|
+
subtitle?: string;
|
|
13
|
+
help?: string;
|
|
14
|
+
clearScreen?: boolean;
|
|
15
|
+
autoSelectSingle?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export declare function select<T>(items: MenuItem<T>[], options: SelectOptions): Promise<T | null>;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { ANSI, isTTY, parseKey } from "./ansi";
|
|
2
|
+
const ESCAPE_TIMEOUT_MS = 50;
|
|
3
|
+
const ANSI_REGEX = new RegExp("\\x1b\\[[0-9;]*m", "g");
|
|
4
|
+
const ANSI_LEADING_REGEX = new RegExp("^\\x1b\\[[0-9;]*m");
|
|
5
|
+
function stripAnsi(input) {
|
|
6
|
+
return input.replace(ANSI_REGEX, "");
|
|
7
|
+
}
|
|
8
|
+
function truncateAnsi(input, maxVisibleChars) {
|
|
9
|
+
if (maxVisibleChars <= 0)
|
|
10
|
+
return "";
|
|
11
|
+
const visible = stripAnsi(input);
|
|
12
|
+
if (visible.length <= maxVisibleChars)
|
|
13
|
+
return input;
|
|
14
|
+
const suffix = maxVisibleChars >= 3 ? "..." : ".".repeat(maxVisibleChars);
|
|
15
|
+
const keep = Math.max(0, maxVisibleChars - suffix.length);
|
|
16
|
+
let out = "";
|
|
17
|
+
let i = 0;
|
|
18
|
+
let kept = 0;
|
|
19
|
+
while (i < input.length && kept < keep) {
|
|
20
|
+
if (input[i] === "\x1b") {
|
|
21
|
+
const m = input.slice(i).match(ANSI_LEADING_REGEX);
|
|
22
|
+
if (m) {
|
|
23
|
+
out += m[0];
|
|
24
|
+
i += m[0].length;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
out += input[i];
|
|
29
|
+
i += 1;
|
|
30
|
+
kept += 1;
|
|
31
|
+
}
|
|
32
|
+
if (out.includes("\x1b["))
|
|
33
|
+
return `${out}${ANSI.reset}${suffix}`;
|
|
34
|
+
return out + suffix;
|
|
35
|
+
}
|
|
36
|
+
function getColorCode(color) {
|
|
37
|
+
if (color === "red")
|
|
38
|
+
return ANSI.red;
|
|
39
|
+
if (color === "green")
|
|
40
|
+
return ANSI.green;
|
|
41
|
+
if (color === "yellow")
|
|
42
|
+
return ANSI.yellow;
|
|
43
|
+
if (color === "cyan")
|
|
44
|
+
return ANSI.cyan;
|
|
45
|
+
return "";
|
|
46
|
+
}
|
|
47
|
+
export async function select(items, options) {
|
|
48
|
+
if (!isTTY())
|
|
49
|
+
throw new Error("Interactive select requires a TTY terminal");
|
|
50
|
+
if (items.length === 0)
|
|
51
|
+
throw new Error("No menu items provided");
|
|
52
|
+
const isSelectable = (i) => !i.disabled && !i.separator && i.kind !== "heading";
|
|
53
|
+
const enabled = items.filter(isSelectable);
|
|
54
|
+
if (enabled.length === 0)
|
|
55
|
+
throw new Error("All items disabled");
|
|
56
|
+
const autoSelectSingle = options.autoSelectSingle ?? true;
|
|
57
|
+
if (enabled.length === 1 && autoSelectSingle)
|
|
58
|
+
return enabled[0]?.value ?? null;
|
|
59
|
+
const { message, subtitle } = options;
|
|
60
|
+
const { stdin, stdout } = process;
|
|
61
|
+
let cursor = items.findIndex(isSelectable);
|
|
62
|
+
if (cursor === -1)
|
|
63
|
+
cursor = 0;
|
|
64
|
+
let escapeTimeout = null;
|
|
65
|
+
let done = false;
|
|
66
|
+
let rendered = 0;
|
|
67
|
+
const render = () => {
|
|
68
|
+
const columns = stdout.columns ?? 80;
|
|
69
|
+
const rows = stdout.rows ?? 24;
|
|
70
|
+
const clearScreen = options.clearScreen === true;
|
|
71
|
+
const prev = rendered;
|
|
72
|
+
if (clearScreen) {
|
|
73
|
+
stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1));
|
|
74
|
+
}
|
|
75
|
+
else if (prev > 0) {
|
|
76
|
+
stdout.write(ANSI.up(prev));
|
|
77
|
+
}
|
|
78
|
+
let lines = 0;
|
|
79
|
+
const write = (line) => {
|
|
80
|
+
stdout.write(`${ANSI.clearLine}${line}\n`);
|
|
81
|
+
lines += 1;
|
|
82
|
+
};
|
|
83
|
+
const subtitleLines = subtitle ? subtitle.split("\n").length + 2 : 0;
|
|
84
|
+
const fixed = 1 + subtitleLines + 2;
|
|
85
|
+
const maxVisible = Math.max(1, Math.min(items.length, rows - fixed - 1));
|
|
86
|
+
let windowStart = 0;
|
|
87
|
+
let windowEnd = items.length;
|
|
88
|
+
if (items.length > maxVisible) {
|
|
89
|
+
windowStart = cursor - Math.floor(maxVisible / 2);
|
|
90
|
+
windowStart = Math.max(0, Math.min(windowStart, items.length - maxVisible));
|
|
91
|
+
windowEnd = windowStart + maxVisible;
|
|
92
|
+
}
|
|
93
|
+
const visibleItems = items.slice(windowStart, windowEnd);
|
|
94
|
+
const header = truncateAnsi(message, Math.max(1, columns - 4));
|
|
95
|
+
write(`${ANSI.dim}┌ ${ANSI.reset}${header}`);
|
|
96
|
+
if (subtitle) {
|
|
97
|
+
write(`${ANSI.dim}│${ANSI.reset}`);
|
|
98
|
+
for (const line of subtitle.split("\n")) {
|
|
99
|
+
const sub = truncateAnsi(line, Math.max(1, columns - 4));
|
|
100
|
+
write(`${ANSI.cyan}◆${ANSI.reset} ${sub}`);
|
|
101
|
+
}
|
|
102
|
+
write("");
|
|
103
|
+
}
|
|
104
|
+
for (let i = 0; i < visibleItems.length; i += 1) {
|
|
105
|
+
const index = windowStart + i;
|
|
106
|
+
const item = visibleItems[i];
|
|
107
|
+
if (!item)
|
|
108
|
+
continue;
|
|
109
|
+
if (item.separator) {
|
|
110
|
+
write(`${ANSI.dim}│${ANSI.reset}`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (item.kind === "heading") {
|
|
114
|
+
const heading = truncateAnsi(`${ANSI.dim}${ANSI.bold}${item.label}${ANSI.reset}`, Math.max(1, columns - 6));
|
|
115
|
+
write(`${ANSI.cyan}│${ANSI.reset} ${heading}`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const selected = index === cursor;
|
|
119
|
+
const color = getColorCode(item.color);
|
|
120
|
+
let label;
|
|
121
|
+
if (item.disabled) {
|
|
122
|
+
label = `${ANSI.dim}${item.label} (unavailable)${ANSI.reset}`;
|
|
123
|
+
}
|
|
124
|
+
else if (selected) {
|
|
125
|
+
label = color ? `${color}${item.label}${ANSI.reset}` : item.label;
|
|
126
|
+
if (item.hint)
|
|
127
|
+
label += ` ${ANSI.dim}${item.hint}${ANSI.reset}`;
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
label = color ? `${ANSI.dim}${color}${item.label}${ANSI.reset}` : `${ANSI.dim}${item.label}${ANSI.reset}`;
|
|
131
|
+
if (item.hint)
|
|
132
|
+
label += ` ${ANSI.dim}${item.hint}${ANSI.reset}`;
|
|
133
|
+
}
|
|
134
|
+
label = truncateAnsi(label, Math.max(1, columns - 8));
|
|
135
|
+
if (selected) {
|
|
136
|
+
write(`${ANSI.cyan}│${ANSI.reset} ${ANSI.green}●${ANSI.reset} ${label}`);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
write(`${ANSI.cyan}│${ANSI.reset} ${ANSI.dim}○${ANSI.reset} ${label}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const windowHint = items.length > visibleItems.length ? ` (${windowStart + 1}-${windowEnd}/${items.length})` : "";
|
|
143
|
+
const helpText = options.help ?? `Up/Down to select | Enter: confirm | Esc: back${windowHint}`;
|
|
144
|
+
const help = truncateAnsi(helpText, Math.max(1, columns - 6));
|
|
145
|
+
write(`${ANSI.cyan}│${ANSI.reset} ${ANSI.dim}${help}${ANSI.reset}`);
|
|
146
|
+
write(`${ANSI.cyan}└${ANSI.reset}`);
|
|
147
|
+
if (!clearScreen && prev > lines) {
|
|
148
|
+
const extra = prev - lines;
|
|
149
|
+
for (let i = 0; i < extra; i += 1)
|
|
150
|
+
write("");
|
|
151
|
+
}
|
|
152
|
+
rendered = lines;
|
|
153
|
+
};
|
|
154
|
+
return new Promise((resolve) => {
|
|
155
|
+
const wasRaw = stdin.isRaw ?? false;
|
|
156
|
+
const cleanup = () => {
|
|
157
|
+
if (done)
|
|
158
|
+
return;
|
|
159
|
+
done = true;
|
|
160
|
+
if (escapeTimeout) {
|
|
161
|
+
clearTimeout(escapeTimeout);
|
|
162
|
+
escapeTimeout = null;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
stdin.removeListener("data", onKey);
|
|
166
|
+
stdin.setRawMode(wasRaw);
|
|
167
|
+
stdin.pause();
|
|
168
|
+
stdout.write(ANSI.show);
|
|
169
|
+
}
|
|
170
|
+
catch { }
|
|
171
|
+
process.removeListener("SIGINT", onSignal);
|
|
172
|
+
process.removeListener("SIGTERM", onSignal);
|
|
173
|
+
};
|
|
174
|
+
const onSignal = () => {
|
|
175
|
+
cleanup();
|
|
176
|
+
resolve(null);
|
|
177
|
+
};
|
|
178
|
+
const finish = (value) => {
|
|
179
|
+
cleanup();
|
|
180
|
+
resolve(value);
|
|
181
|
+
};
|
|
182
|
+
const findNextSelectable = (from, direction) => {
|
|
183
|
+
if (items.length === 0)
|
|
184
|
+
return from;
|
|
185
|
+
let next = from;
|
|
186
|
+
do {
|
|
187
|
+
next = (next + direction + items.length) % items.length;
|
|
188
|
+
} while (items[next]?.disabled || items[next]?.separator || items[next]?.kind === "heading");
|
|
189
|
+
return next;
|
|
190
|
+
};
|
|
191
|
+
const onKey = (data) => {
|
|
192
|
+
if (escapeTimeout) {
|
|
193
|
+
clearTimeout(escapeTimeout);
|
|
194
|
+
escapeTimeout = null;
|
|
195
|
+
}
|
|
196
|
+
const action = parseKey(data);
|
|
197
|
+
if (action === "up") {
|
|
198
|
+
cursor = findNextSelectable(cursor, -1);
|
|
199
|
+
render();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (action === "down") {
|
|
203
|
+
cursor = findNextSelectable(cursor, 1);
|
|
204
|
+
render();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (action === "enter") {
|
|
208
|
+
finish(items[cursor]?.value ?? null);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (action === "escape") {
|
|
212
|
+
finish(null);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (action === "escape-start") {
|
|
216
|
+
escapeTimeout = setTimeout(() => {
|
|
217
|
+
finish(null);
|
|
218
|
+
}, ESCAPE_TIMEOUT_MS);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
process.once("SIGINT", onSignal);
|
|
223
|
+
process.once("SIGTERM", onSignal);
|
|
224
|
+
try {
|
|
225
|
+
stdin.setRawMode(true);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
cleanup();
|
|
229
|
+
resolve(null);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
stdin.resume();
|
|
233
|
+
stdout.write(ANSI.hide);
|
|
234
|
+
render();
|
|
235
|
+
stdin.on("data", onKey);
|
|
236
|
+
});
|
|
237
|
+
}
|