@cad0p/pi-usage 0.11.5 → 0.11.6-20261107.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/CHANGELOG.md +22 -0
- package/README.md +94 -44
- package/bin/piquota.js +13 -3
- package/extensions/quota-panel.ts +541 -184
- package/omarchy/cad0p.pi-usage/assets/mistral.svg +18 -0
- package/package.json +3 -2
- package/src/auth/pi-auth.js +2 -0
- package/src/cache.js +128 -31
- package/src/cli/commands/omarchy.js +21 -3
- package/src/cli/explain.js +2 -0
- package/src/engine.js +101 -8
- package/src/local/pi-sessions.js +28 -1
- package/src/local/stats-worker.js +27 -0
- package/src/local/worker.js +157 -0
- package/src/model.js +39 -5
- package/src/omarchy/assets/mistral.svg +18 -0
- package/src/omarchy/publish.js +9 -8
- package/src/omarchy/record.js +14 -5
- package/src/overlay-keys.js +257 -0
- package/src/providers/antigravity-oauth.js +43 -3
- package/src/providers/antigravity.js +44 -5
- package/src/providers/mistral.js +251 -0
- package/src/refresh.js +4 -1
- package/src/render/panel.js +9 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run the potentially large local session scan away from Pi's TUI event loop.
|
|
3
|
+
* The worker runs the plain Node ESM entry point; the extension itself may be
|
|
4
|
+
* loaded through jiti, but this worker never needs that loader.
|
|
5
|
+
*
|
|
6
|
+
* The scan can outlive the surface that asked for it. A cold scan of a
|
|
7
|
+
* multi-gigabyte session store takes seconds, and the user may `/quit` or
|
|
8
|
+
* `/reload` a second later: a thread left behind keeps reading session files
|
|
9
|
+
* after the session is gone and holds `local-sessions.json.lock` until the
|
|
10
|
+
* 120 s stale timeout, so every later scan is read-only with a warning. The
|
|
11
|
+
* handle owns that lifecycle — the host stops it, and the stop always ends with
|
|
12
|
+
* the lock released and no half-written state.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { Worker } from "node:worker_threads";
|
|
16
|
+
|
|
17
|
+
import { releaseHistoryLock } from "./history.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* How long a stopping scan gets to notice the flag and release its own lock
|
|
21
|
+
* before the thread is terminated and the parent releases the lock instead.
|
|
22
|
+
*/
|
|
23
|
+
export const DEFAULT_STOP_GRACE_MS = 2_000;
|
|
24
|
+
|
|
25
|
+
/** A scan the host stopped, told apart from a failure by its name. */
|
|
26
|
+
function scanStoppedError() {
|
|
27
|
+
const error = new Error("local token stats scan stopped");
|
|
28
|
+
error.name = "AbortError";
|
|
29
|
+
return error;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Start the scan in a worker thread.
|
|
34
|
+
*
|
|
35
|
+
* The stop request is a shared flag the scan polls rather than a message it has
|
|
36
|
+
* to be idle to receive: a cold scan spends its time inside synchronous reads
|
|
37
|
+
* and hashing, where a queued message would only be seen after the work it is
|
|
38
|
+
* meant to interrupt.
|
|
39
|
+
*
|
|
40
|
+
* @param {import("./pi-sessions.js").LocalStatsOptions} [options]
|
|
41
|
+
* @param {{
|
|
42
|
+
* graceMs?: number,
|
|
43
|
+
* spawn?: () => { on: Function, once: Function, postMessage: Function, terminate: () => Promise<number> },
|
|
44
|
+
* releaseLock?: (lock: { path: string, token: string | null }) => boolean,
|
|
45
|
+
* }} [lifecycle] Test seams: a fake worker and a zero grace period.
|
|
46
|
+
* @returns {{
|
|
47
|
+
* result: Promise<Awaited<ReturnType<typeof import("./pi-sessions.js").collectLocalStats>>>,
|
|
48
|
+
* stop: () => Promise<void>,
|
|
49
|
+
* }}
|
|
50
|
+
*/
|
|
51
|
+
export function startLocalStatsWorker(options = {}, lifecycle = {}) {
|
|
52
|
+
const graceMs = Number.isFinite(lifecycle.graceMs)
|
|
53
|
+
? Math.max(0, /** @type {number} */ (lifecycle.graceMs))
|
|
54
|
+
: DEFAULT_STOP_GRACE_MS;
|
|
55
|
+
const stopFlag = new Int32Array(new SharedArrayBuffer(4));
|
|
56
|
+
const releaseLock = lifecycle.releaseLock ?? releaseHistoryLock;
|
|
57
|
+
const worker = lifecycle.spawn
|
|
58
|
+
? lifecycle.spawn()
|
|
59
|
+
: new Worker(new URL("./stats-worker.js", import.meta.url), { workerData: { options, stopFlag } });
|
|
60
|
+
|
|
61
|
+
/** The lock the scan told us it holds; only its own token may remove it. */
|
|
62
|
+
/** @type {{ path: string, token: string | null } | null} */
|
|
63
|
+
let lock = null;
|
|
64
|
+
/** @type {{ ok: true, result: any } | { ok: false, aborted: boolean, error: string } | null} */
|
|
65
|
+
let outcome = null;
|
|
66
|
+
let stopping = false;
|
|
67
|
+
let settled = false;
|
|
68
|
+
/** @type {(value: any) => void} */
|
|
69
|
+
let resolveResult = () => {};
|
|
70
|
+
/** @type {(error: Error) => void} */
|
|
71
|
+
let rejectResult = () => {};
|
|
72
|
+
const result = new Promise((resolve, reject) => {
|
|
73
|
+
resolveResult = resolve;
|
|
74
|
+
rejectResult = reject;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const finish = () => {
|
|
78
|
+
if (settled) return;
|
|
79
|
+
settled = true;
|
|
80
|
+
const owned = lock;
|
|
81
|
+
lock = null;
|
|
82
|
+
// A scan that ended on its own already released this in its own `finally`;
|
|
83
|
+
// a thread that had to be killed did not, and its token is the only proof
|
|
84
|
+
// the file is still ours.
|
|
85
|
+
if (owned) releaseLock(owned);
|
|
86
|
+
if (outcome?.ok) resolveResult(outcome.result);
|
|
87
|
+
else rejectResult(outcome?.aborted ? scanStoppedError() : new Error(outcome?.error ?? "the worker thread failed"));
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
worker.on("message", (message) => {
|
|
91
|
+
if (!message || typeof message !== "object") return;
|
|
92
|
+
if (message.type === "lock") {
|
|
93
|
+
if (typeof message.path === "string") {
|
|
94
|
+
lock = { path: message.path, token: typeof message.token === "string" ? message.token : null };
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (message.type !== "result") return;
|
|
99
|
+
outcome = message.ok
|
|
100
|
+
? { ok: true, result: message.result }
|
|
101
|
+
: {
|
|
102
|
+
ok: false,
|
|
103
|
+
aborted: message.aborted === true,
|
|
104
|
+
error: typeof message.error === "string" ? message.error : "the scan failed",
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
worker.once("error", () => {
|
|
108
|
+
// A thread that died (OOM, a broken entry point) never posts a result.
|
|
109
|
+
if (!outcome) outcome = { ok: false, aborted: stopping, error: "the worker thread failed" };
|
|
110
|
+
});
|
|
111
|
+
const exited = new Promise((resolve) => {
|
|
112
|
+
worker.once("exit", (code) => {
|
|
113
|
+
if (!outcome) {
|
|
114
|
+
outcome = stopping
|
|
115
|
+
? { ok: false, aborted: true, error: "local token stats scan stopped" }
|
|
116
|
+
: { ok: false, aborted: false, error: "the worker thread exited without a result" };
|
|
117
|
+
}
|
|
118
|
+
finish();
|
|
119
|
+
resolve(code);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* End the scan: ask it to stop, terminate the thread if it does not, and
|
|
125
|
+
* leave the report cache and the history exactly as they were found.
|
|
126
|
+
*/
|
|
127
|
+
const stop = async () => {
|
|
128
|
+
if (stopping) return;
|
|
129
|
+
stopping = true;
|
|
130
|
+
Atomics.store(stopFlag, 0, 1);
|
|
131
|
+
if (!settled) {
|
|
132
|
+
const stopped = await Promise.race([
|
|
133
|
+
exited.then(() => true),
|
|
134
|
+
new Promise((resolve) => {
|
|
135
|
+
const timer = setTimeout(() => resolve(false), graceMs);
|
|
136
|
+
timer.unref?.();
|
|
137
|
+
}),
|
|
138
|
+
]);
|
|
139
|
+
if (!stopped) await worker.terminate().catch(() => 0);
|
|
140
|
+
}
|
|
141
|
+
await exited;
|
|
142
|
+
finish();
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return { result, stop };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Await one scan to completion. The caller that wants the lifecycle uses
|
|
150
|
+
* {@link startLocalStatsWorker} instead.
|
|
151
|
+
*
|
|
152
|
+
* @param {import("./pi-sessions.js").LocalStatsOptions} [options]
|
|
153
|
+
* @returns {Promise<Awaited<ReturnType<typeof import("./pi-sessions.js").collectLocalStats>>>}
|
|
154
|
+
*/
|
|
155
|
+
export function collectLocalStatsInWorker(options = {}) {
|
|
156
|
+
return startLocalStatsWorker(options).result;
|
|
157
|
+
}
|
package/src/model.js
CHANGED
|
@@ -33,6 +33,10 @@
|
|
|
33
33
|
* @property {string} source Credential source description.
|
|
34
34
|
* @property {"pi" | "claude-code"} [sourceKind] Which kind of store produced it.
|
|
35
35
|
* @property {string | null} expiresInMin Credential freshness, when known.
|
|
36
|
+
* @property {true} [tokenRenewedInMemory] Present only when the provider renewed
|
|
37
|
+
* this token in memory for this run: the
|
|
38
|
+
* stored credential's freshness then no
|
|
39
|
+
* longer describes the request made.
|
|
36
40
|
*/
|
|
37
41
|
|
|
38
42
|
/**
|
|
@@ -247,6 +251,7 @@ export function humanReset(seconds) {
|
|
|
247
251
|
* source: string,
|
|
248
252
|
* now?: number,
|
|
249
253
|
* expiresInMin?: number | null,
|
|
254
|
+
* tokenRenewedInMemory?: boolean,
|
|
250
255
|
* windows?: QuotaWindow[],
|
|
251
256
|
* }} input
|
|
252
257
|
* @returns {QuotaResult}
|
|
@@ -256,7 +261,7 @@ export function degradedResult(input) {
|
|
|
256
261
|
input.notConfigured ??
|
|
257
262
|
(typeof input.error === "string" && input.error.startsWith("no ") && input.error.includes("credential in the Pi store")),
|
|
258
263
|
);
|
|
259
|
-
|
|
264
|
+
const result = {
|
|
260
265
|
family: input.family,
|
|
261
266
|
label: input.label,
|
|
262
267
|
account: input.account ?? "unknown",
|
|
@@ -270,6 +275,27 @@ export function degradedResult(input) {
|
|
|
270
275
|
sourceKind: input.sourceKind,
|
|
271
276
|
expiresInMin: input.expiresInMin ?? null,
|
|
272
277
|
};
|
|
278
|
+
// Only ever present when a provider renewed the token in memory: a field on
|
|
279
|
+
// every result would be one more thing the JSON feed promises to keep.
|
|
280
|
+
if (input.tokenRenewedInMemory === true) result.tokenRenewedInMemory = true;
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Which field `displayIdentity` would print for this credential.
|
|
286
|
+
*
|
|
287
|
+
* A provider that adds a second identifier of its own (Antigravity's Cloud Code
|
|
288
|
+
* project) needs to know whether the identity already *is* that identifier, or
|
|
289
|
+
* it prints the same value twice.
|
|
290
|
+
*
|
|
291
|
+
* @param {{ email?: string | null, accountId?: string | null, projectId?: string | null, identity?: string }} credential
|
|
292
|
+
* @returns {"email" | "accountId" | "projectId" | "identity" | "none"}
|
|
293
|
+
*/
|
|
294
|
+
export function identityKind(credential) {
|
|
295
|
+
if (credential.email) return "email";
|
|
296
|
+
if (credential.accountId) return "accountId";
|
|
297
|
+
if (credential.projectId) return "projectId";
|
|
298
|
+
return credential.identity ? "identity" : "none";
|
|
273
299
|
}
|
|
274
300
|
|
|
275
301
|
/**
|
|
@@ -279,10 +305,18 @@ export function degradedResult(input) {
|
|
|
279
305
|
* @returns {string}
|
|
280
306
|
*/
|
|
281
307
|
export function displayIdentity(credential) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
308
|
+
switch (identityKind(credential)) {
|
|
309
|
+
case "email":
|
|
310
|
+
return /** @type {string} */ (credential.email);
|
|
311
|
+
case "accountId":
|
|
312
|
+
return `${/** @type {string} */ (credential.accountId).slice(0, 8)}…`;
|
|
313
|
+
case "projectId":
|
|
314
|
+
return `project ${/** @type {string} */ (credential.projectId).slice(0, 8)}…`;
|
|
315
|
+
case "identity":
|
|
316
|
+
return `id ${/** @type {string} */ (credential.identity).slice(0, 8)}…`;
|
|
317
|
+
default:
|
|
318
|
+
return "unknown";
|
|
319
|
+
}
|
|
286
320
|
}
|
|
287
321
|
|
|
288
322
|
/**
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<svg viewBox="0 0 10 10" width="24" height="24" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<rect x="0" y="0" width="2" height="2" fill="#FFD800"/>
|
|
3
|
+
<rect x="8" y="0" width="2" height="2" fill="#FFD800"/>
|
|
4
|
+
<rect x="0" y="2" width="2" height="2" fill="#FFAF00"/>
|
|
5
|
+
<rect x="2" y="2" width="2" height="2" fill="#FFAF00"/>
|
|
6
|
+
<rect x="6" y="2" width="2" height="2" fill="#FFAF00"/>
|
|
7
|
+
<rect x="8" y="2" width="2" height="2" fill="#FFAF00"/>
|
|
8
|
+
<rect x="0" y="4" width="2" height="2" fill="#FF8205"/>
|
|
9
|
+
<rect x="2" y="4" width="2" height="2" fill="#FF8205"/>
|
|
10
|
+
<rect x="4" y="4" width="2" height="2" fill="#FF8205"/>
|
|
11
|
+
<rect x="6" y="4" width="2" height="2" fill="#FF8205"/>
|
|
12
|
+
<rect x="8" y="4" width="2" height="2" fill="#FF8205"/>
|
|
13
|
+
<rect x="0" y="6" width="2" height="2" fill="#FA500F"/>
|
|
14
|
+
<rect x="4" y="6" width="2" height="2" fill="#FA500F"/>
|
|
15
|
+
<rect x="8" y="6" width="2" height="2" fill="#FA500F"/>
|
|
16
|
+
<rect x="0" y="8" width="2" height="2" fill="#E10500"/>
|
|
17
|
+
<rect x="8" y="8" width="2" height="2" fill="#E10500"/>
|
|
18
|
+
</svg>
|
package/src/omarchy/publish.js
CHANGED
|
@@ -61,7 +61,7 @@ function writeRecord(dir, id, record) {
|
|
|
61
61
|
* no longer present.
|
|
62
62
|
*
|
|
63
63
|
* @param {Array<{ id: string, record: Record<string, unknown> }>} records
|
|
64
|
-
* @param {{ dir?: string, env?: Record<string, string | undefined>, home?: string }} [options]
|
|
64
|
+
* @param {{ dir?: string, env?: Record<string, string | undefined>, home?: string, prune?: boolean }} [options]
|
|
65
65
|
* @returns {{ dir: string, written: string[], removed: string[] }}
|
|
66
66
|
*/
|
|
67
67
|
export function publishRecords(records, options = {}) {
|
|
@@ -74,15 +74,16 @@ export function publishRecords(records, options = {}) {
|
|
|
74
74
|
written.push(writeRecord(dir, id, record));
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
const keep = new Set(records.map(({ id }) => `${id}.json`));
|
|
78
77
|
const removed = [];
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
78
|
+
if (options.prune !== false) {
|
|
79
|
+
const keep = new Set(records.map(({ id }) => `${id}.json`));
|
|
80
|
+
for (const name of readdirSync(dir)) {
|
|
81
|
+
if (!name.startsWith(RECORD_PREFIX) || !name.endsWith(".json")) continue;
|
|
82
|
+
if (!isPiRecordId(name.slice(0, -".json".length)) || keep.has(name)) continue;
|
|
83
|
+
rmSync(join(dir, name), { force: true });
|
|
84
|
+
removed.push(join(dir, name));
|
|
85
|
+
}
|
|
84
86
|
}
|
|
85
87
|
|
|
86
|
-
|
|
87
88
|
return { dir, written, removed };
|
|
88
89
|
}
|
package/src/omarchy/record.js
CHANGED
|
@@ -38,6 +38,7 @@ const LOGIN_HINT = {
|
|
|
38
38
|
codex: "Run `/login openai-codex` in Pi to restore usage.",
|
|
39
39
|
antigravity: "Run `/login antigravity` in Pi to restore usage.",
|
|
40
40
|
"opencode-go": "Run `piquota auth opencode` to restore usage.",
|
|
41
|
+
mistral: "Run `/login mistral` in Pi to restore usage.",
|
|
41
42
|
};
|
|
42
43
|
|
|
43
44
|
const HELP_MAX = 200;
|
|
@@ -118,9 +119,11 @@ function mapLimits(windows, provider, prefix = "") {
|
|
|
118
119
|
for (const window of windows ?? []) {
|
|
119
120
|
// A window without a percentage would otherwise read as "0% used".
|
|
120
121
|
if (typeof window.usedPercent !== "number" || !Number.isFinite(window.usedPercent)) continue;
|
|
122
|
+
// Spending past the allowance changes what 100% means, so the title says so.
|
|
123
|
+
const payg = typeof window.note === "string" && /pay-as-you-go/i.test(window.note) ? " · pay-as-you-go" : "";
|
|
121
124
|
limits.push({
|
|
122
125
|
label: redact(`${prefix}${window.label || window.id || "Limit"}`),
|
|
123
|
-
title: redact(`${prefix}${windowTitle(window, provider)}`),
|
|
126
|
+
title: redact(`${prefix}${windowTitle(window, provider)}${payg}`),
|
|
124
127
|
percent: Math.min(1, Math.max(0, window.usedPercent / 100)),
|
|
125
128
|
resetsAt: typeof window.resetsAt === "string" ? window.resetsAt : "",
|
|
126
129
|
});
|
|
@@ -384,7 +387,11 @@ function buildFamilyRecord(family, providers, options = {}) {
|
|
|
384
387
|
const window = bindingWindow(provider.windows);
|
|
385
388
|
const mapped = window ? mapLimits([window], provider, label === "" ? "" : `${label} · `)[0] : null;
|
|
386
389
|
if (mapped) limits.push(mapped);
|
|
387
|
-
|
|
390
|
+
const accountLimits = mapLimits(provider.windows, provider);
|
|
391
|
+
// An account that answered without windows (Mistral without the usage
|
|
392
|
+
// permission) says why instead of showing an empty row.
|
|
393
|
+
const quiet = accountLimits.length === 0 && typeof provider.note === "string" ? redact(provider.note).slice(0, HELP_MAX) : "";
|
|
394
|
+
accounts.push({ id: redact(label), ok: true, status: quiet, limits: accountLimits });
|
|
388
395
|
return;
|
|
389
396
|
}
|
|
390
397
|
failures.push({ label, reason: failureReason(provider.error) });
|
|
@@ -481,9 +488,11 @@ export function buildRecord(provider, options = {}) {
|
|
|
481
488
|
|
|
482
489
|
if (failed && errorKind(provider.error) === "missing") return null;
|
|
483
490
|
|
|
484
|
-
const status =
|
|
485
|
-
?
|
|
486
|
-
:
|
|
491
|
+
const status = provider.ok && limits.length === 0 && typeof provider.note === "string" && provider.note !== ""
|
|
492
|
+
? { usageStatusText: "Usage not visible", authHelpText: redact(provider.note).slice(0, HELP_MAX) }
|
|
493
|
+
: failed
|
|
494
|
+
? describeError(provider.family, provider.error ?? "no usage windows")
|
|
495
|
+
: { usageStatusText: "", authHelpText: "" };
|
|
487
496
|
|
|
488
497
|
const id = recordId(provider.family);
|
|
489
498
|
return {
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw key decoding for the /usage overlay.
|
|
3
|
+
*
|
|
4
|
+
* Terminals speak two encodings for the keys the overlay answers. Legacy
|
|
5
|
+
* terminals send the familiar escape sequences (`\x1b[A` for Up, `\x1b[5~` for
|
|
6
|
+
* PgUp), and terminals that negotiate the kitty keyboard protocol — Ghostty
|
|
7
|
+
* among them, because Pi asks for flags 1|2|4 — send CSI sequences carrying a
|
|
8
|
+
* 1-based modifier mask and an event type (`\x1b[1;1:1A` for a press,
|
|
9
|
+
* `\x1b[1;1:2A` for a repeat, `\x1b[1;1:3A` for a release), and may report a
|
|
10
|
+
* printable key as its codepoint (`\x1b[106u` for `j`).
|
|
11
|
+
*
|
|
12
|
+
* This mirrors the subset of pi-tui's `matchesKey`, `isKeyRelease`,
|
|
13
|
+
* `isKeyRepeat` and `decodeKittyPrintable` the overlay needs. It is local on
|
|
14
|
+
* purpose. Pi maps `@earendil-works/pi-tui` for an extension at load time
|
|
15
|
+
* through jiti's virtual modules, so a static import would work inside Pi — but
|
|
16
|
+
* the test suite loads the extension with Node's type stripping, where a bare
|
|
17
|
+
* specifier resolves only from a `node_modules` this package does not have and
|
|
18
|
+
* never installs: it ships zero runtime and dev dependencies, and CI runs
|
|
19
|
+
* `npm test` with no install step. Declaring pi-tui a peer/dev dependency would
|
|
20
|
+
* only move that install into CI and every contributor's checkout, and injecting
|
|
21
|
+
* the matcher would still need a real implementation for the tests. One local
|
|
22
|
+
* decoder is a single code path that the unit table, the overlay tests and the
|
|
23
|
+
* live tmux proof all exercise.
|
|
24
|
+
*
|
|
25
|
+
* A release is never a press: the overlay drops it before acting. A repeat is a
|
|
26
|
+
* press. A held arrow must keep moving, and in a legacy terminal the OS repeat
|
|
27
|
+
* is byte-identical to the first press, so ignoring kitty repeats would make a
|
|
28
|
+
* held key behave differently in Ghostty than in tmux.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const LOCK_MASK = 64 + 128; // Caps Lock and Num Lock ride along in the modifier field
|
|
32
|
+
const MODIFIER_SHIFT = 1;
|
|
33
|
+
|
|
34
|
+
/** Kitty reserves this Private Use Area range for functional keys. */
|
|
35
|
+
const KITTY_FUNCTIONAL_START = 57344;
|
|
36
|
+
const KITTY_FUNCTIONAL_END = 63743;
|
|
37
|
+
|
|
38
|
+
/** Internal codepoints: negative so a reported codepoint can never collide. */
|
|
39
|
+
const ARROW = { up: -1, down: -2, right: -3, left: -4 };
|
|
40
|
+
const FUNCTIONAL = { delete: -10, insert: -11, pageUp: -12, pageDown: -13, home: -14, end: -15 };
|
|
41
|
+
|
|
42
|
+
/** The final letter of a `CSI A/B/C/D` arrow sequence. */
|
|
43
|
+
const ARROW_BY_LETTER = { A: ARROW.up, B: ARROW.down, C: ARROW.right, D: ARROW.left };
|
|
44
|
+
|
|
45
|
+
/** Codepoints of the keys the overlay names. */
|
|
46
|
+
const KEY_CODEPOINTS = {
|
|
47
|
+
escape: 27,
|
|
48
|
+
enter: 13,
|
|
49
|
+
tab: 9,
|
|
50
|
+
space: 32,
|
|
51
|
+
...ARROW,
|
|
52
|
+
...FUNCTIONAL,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Kitty's Private Use Area spellings of the same keys, including the keypad
|
|
57
|
+
* aliases (57417 left … 57424 end, 57414 keypad enter).
|
|
58
|
+
*
|
|
59
|
+
* @type {Map<number, number>}
|
|
60
|
+
*/
|
|
61
|
+
const KITTY_EQUIVALENTS = new Map([
|
|
62
|
+
[57350, ARROW.left],
|
|
63
|
+
[57351, ARROW.right],
|
|
64
|
+
[57352, ARROW.up],
|
|
65
|
+
[57353, ARROW.down],
|
|
66
|
+
[57354, FUNCTIONAL.pageUp],
|
|
67
|
+
[57355, FUNCTIONAL.pageDown],
|
|
68
|
+
[57356, FUNCTIONAL.home],
|
|
69
|
+
[57357, FUNCTIONAL.end],
|
|
70
|
+
[57414, KEY_CODEPOINTS.enter],
|
|
71
|
+
[57417, ARROW.left],
|
|
72
|
+
[57418, ARROW.right],
|
|
73
|
+
[57419, ARROW.up],
|
|
74
|
+
[57420, ARROW.down],
|
|
75
|
+
[57421, FUNCTIONAL.pageUp],
|
|
76
|
+
[57422, FUNCTIONAL.pageDown],
|
|
77
|
+
[57423, FUNCTIONAL.home],
|
|
78
|
+
[57424, FUNCTIONAL.end],
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
/** What a legacy terminal (or tmux, in either cursor mode) sends for each key. */
|
|
82
|
+
const LEGACY = {
|
|
83
|
+
escape: ["\u001b"],
|
|
84
|
+
// SS3 M is how xterm family terminals send the numpad Enter.
|
|
85
|
+
enter: ["\r", "\n", "\u001bOM"],
|
|
86
|
+
tab: ["\t"],
|
|
87
|
+
space: [" "],
|
|
88
|
+
up: ["\u001b[A", "\u001bOA"],
|
|
89
|
+
down: ["\u001b[B", "\u001bOB"],
|
|
90
|
+
left: ["\u001b[D", "\u001bOD"],
|
|
91
|
+
right: ["\u001b[C", "\u001bOC"],
|
|
92
|
+
pageUp: ["\u001b[5~", "\u001b[[5~"],
|
|
93
|
+
pageDown: ["\u001b[6~", "\u001b[[6~"],
|
|
94
|
+
home: ["\u001b[H", "\u001bOH", "\u001b[1~", "\u001b[7~"],
|
|
95
|
+
end: ["\u001b[F", "\u001bOF", "\u001b[4~", "\u001b[8~"],
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** The number in a `CSI <n> ~` functional sequence. */
|
|
99
|
+
const FUNCTIONAL_BY_NUMBER = { 1: FUNCTIONAL.home, 2: FUNCTIONAL.insert, 3: FUNCTIONAL.delete, 4: FUNCTIONAL.end, 5: FUNCTIONAL.pageUp, 6: FUNCTIONAL.pageDown, 7: FUNCTIONAL.home, 8: FUNCTIONAL.end };
|
|
100
|
+
|
|
101
|
+
const CSI_U = /^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/;
|
|
102
|
+
// A modifier field only ever follows "1;" — the bare forms (\x1b[1A, \x1b[1H)
|
|
103
|
+
// are not sequences pi-tui accepts as arrows, so neither do we.
|
|
104
|
+
const CSI_ARROW = /^\x1b\[1;(\d+)(?::(\d+))?([ABCD])$/;
|
|
105
|
+
const CSI_HOME_END = /^\x1b\[1;(\d+)(?::(\d+))?([HF])$/;
|
|
106
|
+
const CSI_FUNCTIONAL = /^\x1b\[(\d+)(?:;(\d+))?(?::(\d+))?~$/;
|
|
107
|
+
const CSI_MODIFY_OTHER_KEYS = /^\x1b\[27;(\d+);(\d+)~$/;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Event type from the trailing field: 1 press, 2 repeat, 3 release.
|
|
111
|
+
*
|
|
112
|
+
* @param {string | undefined} value
|
|
113
|
+
* @returns {"press" | "repeat" | "release"}
|
|
114
|
+
*/
|
|
115
|
+
function eventType(value) {
|
|
116
|
+
if (value === "2") return "repeat";
|
|
117
|
+
if (value === "3") return "release";
|
|
118
|
+
return "press";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Normalize a kitty functional codepoint to the internal one.
|
|
123
|
+
*
|
|
124
|
+
* @param {number} codepoint
|
|
125
|
+
* @returns {number}
|
|
126
|
+
*/
|
|
127
|
+
function normalize(codepoint) {
|
|
128
|
+
return KITTY_EQUIVALENTS.get(codepoint) ?? codepoint;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* One parsed key event, or null when the bytes are neither a key the overlay
|
|
133
|
+
* knows nor a sequence it can read.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} data
|
|
136
|
+
* @returns {{ codepoint: number, shifted?: number, modifier: number, event: "press" | "repeat" | "release" } | null}
|
|
137
|
+
*/
|
|
138
|
+
function parseKey(data) {
|
|
139
|
+
let match = data.match(CSI_MODIFY_OTHER_KEYS);
|
|
140
|
+
if (match) return { codepoint: Number(match[2]), modifier: Number(match[1]) - 1, event: "press" };
|
|
141
|
+
|
|
142
|
+
match = data.match(CSI_U);
|
|
143
|
+
if (match) {
|
|
144
|
+
return {
|
|
145
|
+
codepoint: Number(match[1]),
|
|
146
|
+
shifted: match[2] ? Number(match[2]) : undefined,
|
|
147
|
+
modifier: match[4] ? Number(match[4]) - 1 : 0,
|
|
148
|
+
event: eventType(match[5]),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
match = data.match(CSI_ARROW);
|
|
153
|
+
if (match) return { codepoint: ARROW_BY_LETTER[match[3]], modifier: Number(match[1]) - 1, event: eventType(match[2]) };
|
|
154
|
+
|
|
155
|
+
match = data.match(CSI_HOME_END);
|
|
156
|
+
if (match) {
|
|
157
|
+
return {
|
|
158
|
+
codepoint: match[3] === "H" ? FUNCTIONAL.home : FUNCTIONAL.end,
|
|
159
|
+
modifier: Number(match[1]) - 1,
|
|
160
|
+
event: eventType(match[2]),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
match = data.match(CSI_FUNCTIONAL);
|
|
165
|
+
if (match) {
|
|
166
|
+
const codepoint = FUNCTIONAL_BY_NUMBER[Number(match[1])];
|
|
167
|
+
if (codepoint !== undefined) return { codepoint, modifier: match[2] ? Number(match[2]) - 1 : 0, event: eventType(match[3]) };
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Whether raw input is a kitty key release. Only a kitty-protocol terminal
|
|
174
|
+
* reports releases, so legacy input is always a press.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} data
|
|
177
|
+
* @returns {boolean}
|
|
178
|
+
*/
|
|
179
|
+
export function isKeyRelease(data) {
|
|
180
|
+
return parseKey(data)?.event === "release";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Whether raw input is a kitty key repeat (the terminal auto-repeating a held
|
|
185
|
+
* key). The overlay treats it as a press; see the module comment.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} data
|
|
188
|
+
* @returns {boolean}
|
|
189
|
+
*/
|
|
190
|
+
export function isKeyRepeat(data) {
|
|
191
|
+
return parseKey(data)?.event === "repeat";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Whether raw input is the named key, unmodified.
|
|
196
|
+
*
|
|
197
|
+
* Shift is allowed on enter, space and escape, whose shifted form is the same
|
|
198
|
+
* key in a legacy terminal; Shift+Tab is backtab (`\x1b[Z`), a different key, so
|
|
199
|
+
* it is not tab in either encoding, and any other modifier is a different key
|
|
200
|
+
* (shift+up, ctrl+page-up, …).
|
|
201
|
+
*
|
|
202
|
+
* @param {string} data raw bytes from the terminal
|
|
203
|
+
* @param {"escape" | "enter" | "tab" | "space" | "up" | "down" | "left" | "right" | "pageUp" | "pageDown" | "home" | "end"} name
|
|
204
|
+
* @returns {boolean}
|
|
205
|
+
*/
|
|
206
|
+
export function matchesKey(data, name) {
|
|
207
|
+
if ((LEGACY[name] ?? []).includes(data)) return true;
|
|
208
|
+
const expected = KEY_CODEPOINTS[name];
|
|
209
|
+
if (expected === undefined) return false;
|
|
210
|
+
const parsed = parseKey(data);
|
|
211
|
+
if (!parsed) return false;
|
|
212
|
+
const modifier = parsed.modifier & ~LOCK_MASK;
|
|
213
|
+
const allowed = name === "enter" || name === "space" || name === "escape" ? MODIFIER_SHIFT : 0;
|
|
214
|
+
if ((modifier & ~allowed) !== 0) return false;
|
|
215
|
+
return normalize(parsed.codepoint) === expected;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The printable character a kitty CSI-u or xterm modifyOtherKeys sequence
|
|
220
|
+
* carries, if it is one. Plain and Shift-modified text only: ctrl/alt/super
|
|
221
|
+
* combinations are key bindings, not text.
|
|
222
|
+
*
|
|
223
|
+
* @param {string} data
|
|
224
|
+
* @returns {string | undefined}
|
|
225
|
+
*/
|
|
226
|
+
function decodePrintable(data) {
|
|
227
|
+
const parsed = parseKey(data);
|
|
228
|
+
if (!parsed) return undefined;
|
|
229
|
+
const modifier = parsed.modifier & ~LOCK_MASK;
|
|
230
|
+
if ((modifier & ~MODIFIER_SHIFT) !== 0) return undefined;
|
|
231
|
+
let codepoint = parsed.codepoint;
|
|
232
|
+
if (modifier & MODIFIER_SHIFT) {
|
|
233
|
+
if (parsed.shifted !== undefined) codepoint = parsed.shifted;
|
|
234
|
+
else if (codepoint >= 97 && codepoint <= 122) codepoint -= 32;
|
|
235
|
+
}
|
|
236
|
+
codepoint = normalize(codepoint);
|
|
237
|
+
if (!Number.isFinite(codepoint) || codepoint < 32) return undefined;
|
|
238
|
+
if (codepoint >= KITTY_FUNCTIONAL_START && codepoint <= KITTY_FUNCTIONAL_END) return undefined;
|
|
239
|
+
try {
|
|
240
|
+
return String.fromCodePoint(codepoint);
|
|
241
|
+
} catch {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Whether raw input is the printable character, in any encoding: the byte
|
|
248
|
+
* itself (legacy), or a codepoint sequence with no modifier or Shift.
|
|
249
|
+
*
|
|
250
|
+
* @param {string} data raw bytes from the terminal
|
|
251
|
+
* @param {string} char one printable character
|
|
252
|
+
* @returns {boolean}
|
|
253
|
+
*/
|
|
254
|
+
export function matchesPrintable(data, char) {
|
|
255
|
+
if (data === char) return true;
|
|
256
|
+
return decodePrintable(data) === char;
|
|
257
|
+
}
|