pi-multikey 1.2.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 +163 -0
- package/README.zh.md +146 -0
- package/config.ts +341 -0
- package/index.ts +181 -0
- package/manage.ts +871 -0
- package/package.json +44 -0
- package/pool.ts +180 -0
- package/presets.ts +143 -0
- package/probe.ts +264 -0
- package/stream.ts +237 -0
- package/tui.ts +349 -0
package/stream.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rotating streamSimple: wraps the underlying pi-ai API implementation, picks a
|
|
3
|
+
* key from the pool for every request, and transparently retries on 429/401/403
|
|
4
|
+
* with the next key (marking a cooldown on the failed key).
|
|
5
|
+
*
|
|
6
|
+
* Events are only relayed to the caller once the attempt is known to be healthy,
|
|
7
|
+
* so a rotated attempt never produces duplicate/partial output.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
type AssistantMessage,
|
|
12
|
+
type AssistantMessageEvent,
|
|
13
|
+
type AssistantMessageEventStream,
|
|
14
|
+
type Api,
|
|
15
|
+
type Context,
|
|
16
|
+
createAssistantMessageEventStream,
|
|
17
|
+
getApiProvider,
|
|
18
|
+
type Model,
|
|
19
|
+
type SimpleStreamOptions,
|
|
20
|
+
} from "@earendil-works/pi-ai";
|
|
21
|
+
import type { KeyOutcome, KeyPool, Lease } from "./pool.ts";
|
|
22
|
+
|
|
23
|
+
const RATE_LIMIT_RE = /\b429\b|rate\s*limit|too many requests|quota\s*(exceed|limit)|requests per minute|requests per day/i;
|
|
24
|
+
const INVALID_KEY_RE = /\b40[13]\b|unauthorized|forbidden|invalid\s*(api\s*)?key|incorrect\s*(api\s*)?key|authentication/i;
|
|
25
|
+
|
|
26
|
+
interface CapturedResponse {
|
|
27
|
+
status: number;
|
|
28
|
+
retryAfterMs?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type Notifier = (message: string) => void;
|
|
32
|
+
|
|
33
|
+
export function createRotatingStreamSimple(pool: KeyPool, apiName: string, notify: Notifier) {
|
|
34
|
+
const impl = getApiProvider(apiName as Api);
|
|
35
|
+
if (!impl) throw new Error(`multikey: no API provider registered for api: ${apiName}`);
|
|
36
|
+
// Auth style: "api-key" providers want the key in x-api-key (some reject
|
|
37
|
+
// Authorization entirely); bearer is the pi-ai default and needs no help.
|
|
38
|
+
const authStyle = pool.config.auth ?? "bearer";
|
|
39
|
+
|
|
40
|
+
return function rotatingStreamSimple(
|
|
41
|
+
model: Model<Api>,
|
|
42
|
+
context: Context,
|
|
43
|
+
options?: SimpleStreamOptions,
|
|
44
|
+
): AssistantMessageEventStream {
|
|
45
|
+
const out = createAssistantMessageEventStream();
|
|
46
|
+
|
|
47
|
+
void (async () => {
|
|
48
|
+
const maxAttempts = Math.max(1, pool.size);
|
|
49
|
+
let lastProblem = "no attempts made";
|
|
50
|
+
|
|
51
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
52
|
+
let lease: Lease;
|
|
53
|
+
try {
|
|
54
|
+
lease = await pool.acquire(options?.signal);
|
|
55
|
+
} catch {
|
|
56
|
+
emitAborted(out, model);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const captured: CapturedResponse = { status: 0 };
|
|
62
|
+
const attemptOptions: SimpleStreamOptions = {
|
|
63
|
+
...options,
|
|
64
|
+
apiKey: lease.key,
|
|
65
|
+
headers: authStyle === "api-key" ? { ...options?.headers, "x-api-key": lease.key } : options?.headers,
|
|
66
|
+
onResponse: (response) => {
|
|
67
|
+
captured.status = response.status;
|
|
68
|
+
const ra = response.headers?.["retry-after"];
|
|
69
|
+
const seconds = typeof ra === "string" ? Number(ra) : Number.NaN;
|
|
70
|
+
if (Number.isFinite(seconds) && seconds > 0) captured.retryAfterMs = seconds * 1000;
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const sub = impl.streamSimple(model, context, attemptOptions);
|
|
75
|
+
const verdict = await pump(sub, out, captured);
|
|
76
|
+
|
|
77
|
+
if (verdict.kind === "completed") {
|
|
78
|
+
pool.report(lease, "ok");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Attempt failed. Only rotate when nothing was relayed yet; if content
|
|
83
|
+
// already streamed, the failure is surfaced as-is (mid-stream 429 is rare).
|
|
84
|
+
if (verdict.kind === "rotate" && !verdict.relayedAny) {
|
|
85
|
+
lastProblem = verdict.problem;
|
|
86
|
+
pool.report(lease, verdict.outcome, verdict.outcome === "rate_limited" ? verdict.cooldownMs : undefined);
|
|
87
|
+
notify(
|
|
88
|
+
`multikey[${pool.config.id}]: ${verdict.outcome === "rate_limited" ? "429" : "auth error"} on ${lease.label} (${pool.mask(lease.key)}), rotating to another key`,
|
|
89
|
+
);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Non-rotatable failure relayed to caller already.
|
|
94
|
+
pool.report(lease, verdict.kind === "rotate" ? verdict.outcome : "error");
|
|
95
|
+
return;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (options?.signal?.aborted) {
|
|
98
|
+
emitAborted(out, model);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
pool.report(lease, "error");
|
|
102
|
+
lastProblem = error instanceof Error ? error.message : String(error);
|
|
103
|
+
notify(`multikey[${pool.config.id}]: request error on ${lease.label}, trying another key`);
|
|
104
|
+
} finally {
|
|
105
|
+
pool.release(lease);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// All keys exhausted — surface a rate-limit-flavored error so pi's own
|
|
110
|
+
// retry/backoff kicks in; by then some cooldowns have expired.
|
|
111
|
+
emitError(out, model, `multikey[${pool.config.id}]: all ${maxAttempts} keys exhausted (last: ${lastProblem})`);
|
|
112
|
+
})();
|
|
113
|
+
|
|
114
|
+
return out;
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
type Verdict =
|
|
119
|
+
| { kind: "completed" }
|
|
120
|
+
| { kind: "rotate"; outcome: KeyOutcome; problem: string; cooldownMs?: number; relayedAny: boolean }
|
|
121
|
+
| { kind: "failed"; relayedAny: boolean };
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Relay events from `sub` into `out`. Buffer the initial "start" event until we
|
|
125
|
+
* know the HTTP status, so a 429 attempt can be dropped without the caller ever
|
|
126
|
+
* seeing it.
|
|
127
|
+
*/
|
|
128
|
+
async function pump(
|
|
129
|
+
sub: AsyncIterable<AssistantMessageEvent>,
|
|
130
|
+
out: AssistantMessageEventStream,
|
|
131
|
+
captured: CapturedResponse,
|
|
132
|
+
): Promise<Verdict> {
|
|
133
|
+
const buffered: AssistantMessageEvent[] = [];
|
|
134
|
+
let relayedAny = false;
|
|
135
|
+
|
|
136
|
+
for await (const event of sub) {
|
|
137
|
+
if (event.type === "done") {
|
|
138
|
+
flush(buffered, out);
|
|
139
|
+
out.push(event);
|
|
140
|
+
out.end();
|
|
141
|
+
return { kind: "completed" };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (event.type === "error") {
|
|
145
|
+
const problem = event.error?.errorMessage ?? "unknown provider error";
|
|
146
|
+
const rotation = classify(problem, captured);
|
|
147
|
+
if (rotation && !relayedAny) {
|
|
148
|
+
return { kind: "rotate", outcome: rotation.outcome, problem, cooldownMs: rotation.cooldownMs, relayedAny };
|
|
149
|
+
}
|
|
150
|
+
flush(buffered, out);
|
|
151
|
+
out.push(event);
|
|
152
|
+
out.end();
|
|
153
|
+
return { kind: "failed", relayedAny };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!relayedAny) {
|
|
157
|
+
if (captured.status !== 0) {
|
|
158
|
+
const rotation = classifyStatus(captured);
|
|
159
|
+
if (rotation) {
|
|
160
|
+
return { kind: "rotate", outcome: rotation.outcome, problem: `HTTP ${captured.status}`, cooldownMs: rotation.cooldownMs, relayedAny };
|
|
161
|
+
}
|
|
162
|
+
flush(buffered, out);
|
|
163
|
+
out.push(event);
|
|
164
|
+
relayedAny = true;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
// Response headers not seen yet: buffer (this can only be "start").
|
|
168
|
+
buffered.push(event);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
out.push(event);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Stream ended without done/error (should not happen) — treat as failure.
|
|
176
|
+
if (!relayedAny && buffered.length === 0) {
|
|
177
|
+
return { kind: "rotate", outcome: "error", problem: "stream ended without events", relayedAny };
|
|
178
|
+
}
|
|
179
|
+
flush(buffered, out);
|
|
180
|
+
out.end();
|
|
181
|
+
return { kind: "failed", relayedAny: true };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function flush(buffered: AssistantMessageEvent[], out: AssistantMessageEventStream): void {
|
|
185
|
+
for (const event of buffered) out.push(event);
|
|
186
|
+
buffered.length = 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function classifyStatus(
|
|
190
|
+
captured: CapturedResponse,
|
|
191
|
+
): { outcome: KeyOutcome; cooldownMs?: number } | undefined {
|
|
192
|
+
if (captured.status === 429) return { outcome: "rate_limited", cooldownMs: captured.retryAfterMs };
|
|
193
|
+
if (captured.status === 401 || captured.status === 403) return { outcome: "invalid" };
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function classify(
|
|
198
|
+
message: string,
|
|
199
|
+
captured: CapturedResponse,
|
|
200
|
+
): { outcome: KeyOutcome; cooldownMs?: number } | undefined {
|
|
201
|
+
const fromStatus = classifyStatus(captured);
|
|
202
|
+
if (fromStatus) return fromStatus;
|
|
203
|
+
if (RATE_LIMIT_RE.test(message)) return { outcome: "rate_limited" };
|
|
204
|
+
if (INVALID_KEY_RE.test(message)) return { outcome: "invalid" };
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function baseMessage(model: Model<Api>, stopReason: "error" | "aborted", errorMessage?: string): AssistantMessage {
|
|
209
|
+
return {
|
|
210
|
+
role: "assistant",
|
|
211
|
+
content: [],
|
|
212
|
+
api: model.api,
|
|
213
|
+
provider: model.provider,
|
|
214
|
+
model: model.id,
|
|
215
|
+
usage: {
|
|
216
|
+
input: 0,
|
|
217
|
+
output: 0,
|
|
218
|
+
cacheRead: 0,
|
|
219
|
+
cacheWrite: 0,
|
|
220
|
+
totalTokens: 0,
|
|
221
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
222
|
+
},
|
|
223
|
+
stopReason,
|
|
224
|
+
errorMessage,
|
|
225
|
+
timestamp: Date.now(),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function emitError(out: AssistantMessageEventStream, model: Model<Api>, message: string): void {
|
|
230
|
+
out.push({ type: "error", reason: "error", error: baseMessage(model, "error", message) });
|
|
231
|
+
out.end();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function emitAborted(out: AssistantMessageEventStream, model: Model<Api>): void {
|
|
235
|
+
out.push({ type: "error", reason: "aborted", error: baseMessage(model, "aborted") });
|
|
236
|
+
out.end();
|
|
237
|
+
}
|
package/tui.ts
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI helpers in the npm:better-custom style: searchable single-select list,
|
|
3
|
+
* read-only info panel, built on ctx.ui.custom + @earendil-works/pi-tui.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
7
|
+
|
|
8
|
+
export interface SelectItem {
|
|
9
|
+
value: string;
|
|
10
|
+
label: string;
|
|
11
|
+
suffix?: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type CommandContext = Parameters<Parameters<import("@earendil-works/pi-coding-agent").ExtensionAPI["registerCommand"]>[1]["handler"]>[1];
|
|
16
|
+
|
|
17
|
+
export async function selectOne(
|
|
18
|
+
ctx: CommandContext,
|
|
19
|
+
title: string,
|
|
20
|
+
items: SelectItem[],
|
|
21
|
+
options?: { initialIndex?: number },
|
|
22
|
+
): Promise<string | null> {
|
|
23
|
+
if (items.length === 0) return null;
|
|
24
|
+
|
|
25
|
+
return await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
26
|
+
let cursor = Math.max(0, Math.min(options?.initialIndex ?? 0, items.length - 1));
|
|
27
|
+
let query = "";
|
|
28
|
+
let cachedLines: string[] | undefined;
|
|
29
|
+
const maxVisible = 12;
|
|
30
|
+
|
|
31
|
+
function visible(): SelectItem[] {
|
|
32
|
+
const q = query.trim().toLowerCase();
|
|
33
|
+
if (!q) return items;
|
|
34
|
+
return items.filter((item) =>
|
|
35
|
+
`${item.label} ${item.suffix ?? ""} ${item.description ?? ""}`.toLowerCase().includes(q),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function refresh() {
|
|
40
|
+
const v = visible();
|
|
41
|
+
if (v.length === 0) cursor = 0;
|
|
42
|
+
else if (cursor >= v.length) cursor = v.length - 1;
|
|
43
|
+
cachedLines = undefined;
|
|
44
|
+
tui.requestRender();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
render(width: number) {
|
|
49
|
+
if (cachedLines) return cachedLines;
|
|
50
|
+
const v = visible();
|
|
51
|
+
const w = Math.max(10, width);
|
|
52
|
+
const lines: string[] = [];
|
|
53
|
+
const add = (line = "") => lines.push(truncateToWidth(line, w));
|
|
54
|
+
const border = theme.fg("accent", "─".repeat(w));
|
|
55
|
+
|
|
56
|
+
add(border);
|
|
57
|
+
add(` ${theme.fg("accent", theme.bold(title))}`);
|
|
58
|
+
add(` ${theme.fg("text", `Search: ${query || "-"}`)}`);
|
|
59
|
+
add();
|
|
60
|
+
|
|
61
|
+
if (v.length === 0) {
|
|
62
|
+
add(theme.fg("warning", " No matches."));
|
|
63
|
+
} else {
|
|
64
|
+
const start = Math.max(0, Math.min(cursor - Math.floor(maxVisible / 2), Math.max(0, v.length - maxVisible)));
|
|
65
|
+
const end = Math.min(v.length, start + maxVisible);
|
|
66
|
+
for (let i = start; i < end; i++) {
|
|
67
|
+
const item = v[i]!;
|
|
68
|
+
const active = i === cursor;
|
|
69
|
+
const prefix = active ? theme.fg("accent", "> ") : " ";
|
|
70
|
+
const label = active ? theme.fg("accent", item.label) : theme.fg("text", item.label);
|
|
71
|
+
const suffix = item.suffix ? theme.fg("dim", item.suffix) : "";
|
|
72
|
+
add(`${prefix}${label}${suffix}`);
|
|
73
|
+
if (item.description) {
|
|
74
|
+
for (const line of item.description.split("\n")) {
|
|
75
|
+
add(` ${theme.fg("muted", line)}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (v.length > maxVisible) {
|
|
80
|
+
add();
|
|
81
|
+
add(theme.fg("dim", ` ${start + 1}-${end} of ${v.length}`));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
add();
|
|
86
|
+
add(theme.fg("dim", " Type to search • ↑↓ move • enter confirm • backspace delete • esc cancel"));
|
|
87
|
+
add(border);
|
|
88
|
+
|
|
89
|
+
cachedLines = lines;
|
|
90
|
+
return lines;
|
|
91
|
+
},
|
|
92
|
+
invalidate() {
|
|
93
|
+
cachedLines = undefined;
|
|
94
|
+
},
|
|
95
|
+
handleInput(data: string) {
|
|
96
|
+
const v = visible();
|
|
97
|
+
if (matchesKey(data, Key.up)) {
|
|
98
|
+
if (v.length === 0) return;
|
|
99
|
+
cursor = cursor === 0 ? v.length - 1 : cursor - 1;
|
|
100
|
+
refresh();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (matchesKey(data, Key.down)) {
|
|
104
|
+
if (v.length === 0) return;
|
|
105
|
+
cursor = cursor === v.length - 1 ? 0 : cursor + 1;
|
|
106
|
+
refresh();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (matchesKey(data, Key.enter)) {
|
|
110
|
+
done(v[cursor]?.value ?? null);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (matchesKey(data, Key.escape)) {
|
|
114
|
+
done(null);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (data === "\u007f" || data === "\b") {
|
|
118
|
+
if (query.length > 0) {
|
|
119
|
+
query = query.slice(0, -1);
|
|
120
|
+
refresh();
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (data >= " " && data !== "\u001b" && data !== "\r" && data !== "\n") {
|
|
125
|
+
query += data;
|
|
126
|
+
cursor = 0;
|
|
127
|
+
refresh();
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function showInfo(ctx: CommandContext, title: string, lines: string[]): Promise<void> {
|
|
135
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
136
|
+
let cachedLines: string[] | undefined;
|
|
137
|
+
return {
|
|
138
|
+
render(width: number) {
|
|
139
|
+
if (cachedLines) return cachedLines;
|
|
140
|
+
const w = Math.max(10, width);
|
|
141
|
+
const out: string[] = [];
|
|
142
|
+
const add = (line = "") => out.push(truncateToWidth(line, w));
|
|
143
|
+
const border = theme.fg("accent", "─".repeat(w));
|
|
144
|
+
add(border);
|
|
145
|
+
add(` ${theme.fg("accent", theme.bold(title))}`);
|
|
146
|
+
add();
|
|
147
|
+
for (const line of lines) add(` ${line}`);
|
|
148
|
+
add();
|
|
149
|
+
add(theme.fg("dim", " esc/enter close"));
|
|
150
|
+
add(border);
|
|
151
|
+
cachedLines = out;
|
|
152
|
+
return out;
|
|
153
|
+
},
|
|
154
|
+
invalidate() {
|
|
155
|
+
cachedLines = undefined;
|
|
156
|
+
},
|
|
157
|
+
handleInput(data: string) {
|
|
158
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.enter)) done();
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Simple toggle list (multi-select), returns selected values or null on cancel. */
|
|
165
|
+
export async function pickMany(
|
|
166
|
+
ctx: CommandContext,
|
|
167
|
+
title: string,
|
|
168
|
+
items: SelectItem[],
|
|
169
|
+
options?: { preselected?: string[] },
|
|
170
|
+
): Promise<string[] | null> {
|
|
171
|
+
return await ctx.ui.custom<string[] | null>((tui, theme, _kb, done) => {
|
|
172
|
+
let cursor = 0;
|
|
173
|
+
let query = "";
|
|
174
|
+
const selected = new Set<string>(options?.preselected ?? []);
|
|
175
|
+
let cachedLines: string[] | undefined;
|
|
176
|
+
const maxVisible = 12;
|
|
177
|
+
|
|
178
|
+
function visible(): SelectItem[] {
|
|
179
|
+
const q = query.trim().toLowerCase();
|
|
180
|
+
if (!q) return items;
|
|
181
|
+
return items.filter((item) => `${item.label} ${item.description ?? ""}`.toLowerCase().includes(q));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function refresh() {
|
|
185
|
+
const v = visible();
|
|
186
|
+
if (v.length === 0) cursor = 0;
|
|
187
|
+
else if (cursor >= v.length) cursor = v.length - 1;
|
|
188
|
+
cachedLines = undefined;
|
|
189
|
+
tui.requestRender();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
render(width: number) {
|
|
194
|
+
if (cachedLines) return cachedLines;
|
|
195
|
+
const v = visible();
|
|
196
|
+
const w = Math.max(10, width);
|
|
197
|
+
const lines: string[] = [];
|
|
198
|
+
const add = (line = "") => lines.push(truncateToWidth(line, w));
|
|
199
|
+
const border = theme.fg("accent", "─".repeat(w));
|
|
200
|
+
add(border);
|
|
201
|
+
add(` ${theme.fg("accent", theme.bold(title))}`);
|
|
202
|
+
add(` ${theme.fg("text", `Search: ${query || "-"}`)}`);
|
|
203
|
+
add();
|
|
204
|
+
if (v.length === 0) {
|
|
205
|
+
add(theme.fg("warning", " No matches."));
|
|
206
|
+
} else {
|
|
207
|
+
for (let i = 0; i < Math.min(v.length, maxVisible); i++) {
|
|
208
|
+
const item = v[i]!;
|
|
209
|
+
const active = i === cursor;
|
|
210
|
+
const check = selected.has(item.value) ? theme.fg("accent", "✓") : " ";
|
|
211
|
+
const prefix = active ? theme.fg("accent", "> ") : " ";
|
|
212
|
+
add(`${prefix}[${check}] ${active ? theme.fg("accent", item.label) : item.label}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
add();
|
|
216
|
+
add(theme.fg("dim", " space toggle • enter confirm • esc cancel"));
|
|
217
|
+
add(border);
|
|
218
|
+
cachedLines = lines;
|
|
219
|
+
return lines;
|
|
220
|
+
},
|
|
221
|
+
invalidate() {
|
|
222
|
+
cachedLines = undefined;
|
|
223
|
+
},
|
|
224
|
+
handleInput(data: string) {
|
|
225
|
+
const v = visible();
|
|
226
|
+
if (matchesKey(data, Key.up)) {
|
|
227
|
+
if (v.length === 0) return;
|
|
228
|
+
cursor = cursor === 0 ? v.length - 1 : cursor - 1;
|
|
229
|
+
refresh();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (matchesKey(data, Key.down)) {
|
|
233
|
+
if (v.length === 0) return;
|
|
234
|
+
cursor = cursor === v.length - 1 ? 0 : cursor + 1;
|
|
235
|
+
refresh();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (data === " ") {
|
|
239
|
+
const item = v[cursor];
|
|
240
|
+
if (item) {
|
|
241
|
+
if (selected.has(item.value)) selected.delete(item.value);
|
|
242
|
+
else selected.add(item.value);
|
|
243
|
+
refresh();
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (matchesKey(data, Key.enter)) {
|
|
248
|
+
done([...selected]);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (matchesKey(data, Key.escape)) {
|
|
252
|
+
done(null);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (data === "\u007f" || data === "\b") {
|
|
256
|
+
if (query.length > 0) {
|
|
257
|
+
query = query.slice(0, -1);
|
|
258
|
+
refresh();
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (data >= " " && data !== "\u001b" && data !== "\r" && data !== "\n") {
|
|
263
|
+
query += data;
|
|
264
|
+
cursor = 0;
|
|
265
|
+
refresh();
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function inputNumber(ctx: CommandContext, title: string, current: number): Promise<number | undefined> {
|
|
273
|
+
const raw = await ctx.ui.input(title, String(current));
|
|
274
|
+
if (raw === undefined || raw.trim() === "") return undefined;
|
|
275
|
+
const value = Number(raw.trim());
|
|
276
|
+
if (!Number.isFinite(value) || value <= 0) return undefined;
|
|
277
|
+
return value;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Run an async task behind a live status panel: `update(line)` appends progress
|
|
284
|
+
* lines while the task runs; the panel closes itself with the task's result.
|
|
285
|
+
*/
|
|
286
|
+
export async function withProgress<T>(
|
|
287
|
+
ctx: CommandContext,
|
|
288
|
+
title: string,
|
|
289
|
+
task: (update: (line: string) => void) => Promise<T>,
|
|
290
|
+
): Promise<T> {
|
|
291
|
+
return await ctx.ui.custom<T>((tui, theme, _kb, done) => {
|
|
292
|
+
const lines: string[] = [];
|
|
293
|
+
let finished = false;
|
|
294
|
+
let cachedLines: string[] | undefined;
|
|
295
|
+
let frame = 0;
|
|
296
|
+
const spinner = setInterval(() => {
|
|
297
|
+
if (finished) return;
|
|
298
|
+
frame = (frame + 1) % SPINNER_FRAMES.length;
|
|
299
|
+
tui.requestRender();
|
|
300
|
+
}, 90);
|
|
301
|
+
|
|
302
|
+
function update(line: string) {
|
|
303
|
+
lines.push(line);
|
|
304
|
+
cachedLines = undefined;
|
|
305
|
+
tui.requestRender();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
void (async () => {
|
|
309
|
+
try {
|
|
310
|
+
const result = await task(update);
|
|
311
|
+
finished = true;
|
|
312
|
+
clearInterval(spinner);
|
|
313
|
+
done(result);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
finished = true;
|
|
316
|
+
clearInterval(spinner);
|
|
317
|
+
update(`error: ${error instanceof Error ? error.message : String(error)}`);
|
|
318
|
+
// Give the user a moment to see the error before the panel closes.
|
|
319
|
+
setTimeout(() => done(undefined as T), 2500);
|
|
320
|
+
}
|
|
321
|
+
})();
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
render(width: number) {
|
|
325
|
+
if (cachedLines) return cachedLines;
|
|
326
|
+
const w = Math.max(10, width);
|
|
327
|
+
const out: string[] = [];
|
|
328
|
+
const add = (line = "") => out.push(truncateToWidth(line, w));
|
|
329
|
+
const border = theme.fg("accent", "─".repeat(w));
|
|
330
|
+
add(border);
|
|
331
|
+
add(` ${theme.fg("accent", theme.bold(title))} ${finished ? "" : theme.fg("accent", SPINNER_FRAMES[frame]!)}`);
|
|
332
|
+
add();
|
|
333
|
+
for (const line of lines) add(` ${theme.fg("dim", line)}`);
|
|
334
|
+
add();
|
|
335
|
+
add(theme.fg("dim", " working — please wait…"));
|
|
336
|
+
add(border);
|
|
337
|
+
cachedLines = out;
|
|
338
|
+
return out;
|
|
339
|
+
},
|
|
340
|
+
invalidate() {
|
|
341
|
+
cachedLines = undefined;
|
|
342
|
+
},
|
|
343
|
+
handleInput() {
|
|
344
|
+
// Input intentionally ignored while the task runs.
|
|
345
|
+
if (finished) done(undefined as T);
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
});
|
|
349
|
+
}
|