@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.
@@ -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
- return {
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
- if (credential.email) return credential.email;
283
- if (credential.accountId) return `${credential.accountId.slice(0, 8)}…`;
284
- if (credential.projectId) return `project ${credential.projectId.slice(0, 8)}…`;
285
- return credential.identity ? `id ${credential.identity.slice(0, 8)}…` : "unknown";
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>
@@ -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
- for (const name of readdirSync(dir)) {
80
- if (!name.startsWith(RECORD_PREFIX) || !name.endsWith(".json")) continue;
81
- if (!isPiRecordId(name.slice(0, -".json".length)) || keep.has(name)) continue;
82
- rmSync(join(dir, name), { force: true });
83
- removed.push(join(dir, name));
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
  }
@@ -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
- accounts.push({ id: redact(label), ok: true, status: "", limits: mapLimits(provider.windows, provider) });
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 = failed
485
- ? describeError(provider.family, provider.error ?? "no usage windows")
486
- : { usageStatusText: "", authHelpText: "" };
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
+ }