@prometheus-ai/tui 0.5.3 → 0.5.8
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/dist/types/autocomplete.d.ts +3 -1
- package/dist/types/components/box.d.ts +1 -1
- package/dist/types/components/editor.d.ts +35 -2
- package/dist/types/components/image.d.ts +22 -3
- package/dist/types/components/input.d.ts +6 -1
- package/dist/types/components/loader.d.ts +9 -2
- package/dist/types/components/markdown.d.ts +3 -1
- package/dist/types/components/scroll-view.d.ts +23 -1
- package/dist/types/components/select-list.d.ts +19 -1
- package/dist/types/components/settings-list.d.ts +87 -7
- package/dist/types/components/spacer.d.ts +1 -1
- package/dist/types/components/tab-bar.d.ts +37 -4
- package/dist/types/components/text.d.ts +2 -2
- package/dist/types/components/truncated-text.d.ts +1 -1
- package/dist/types/fuzzy.d.ts +10 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/keybindings.d.ts +5 -3
- package/dist/types/keys.d.ts +1 -1
- package/dist/types/kill-ring.d.ts +0 -7
- package/dist/types/kitty-graphics.d.ts +16 -31
- package/dist/types/loop-watchdog.d.ts +39 -0
- package/dist/types/mouse.d.ts +41 -0
- package/dist/types/stdin-buffer.d.ts +17 -0
- package/dist/types/terminal-capabilities.d.ts +74 -18
- package/dist/types/terminal.d.ts +34 -36
- package/dist/types/tui.d.ts +191 -79
- package/dist/types/utils.d.ts +5 -2
- package/package.json +4 -4
- package/src/autocomplete.ts +79 -65
- package/src/components/box.ts +43 -63
- package/src/components/editor.ts +471 -136
- package/src/components/image.ts +85 -9
- package/src/components/input.ts +12 -3
- package/src/components/loader.ts +35 -21
- package/src/components/markdown.ts +174 -53
- package/src/components/scroll-view.ts +63 -2
- package/src/components/select-list.ts +233 -38
- package/src/components/settings-list.ts +626 -64
- package/src/components/spacer.ts +9 -5
- package/src/components/tab-bar.ts +153 -28
- package/src/components/text.ts +6 -2
- package/src/components/truncated-text.ts +10 -2
- package/src/fuzzy.ts +214 -59
- package/src/index.ts +3 -1
- package/src/keybindings.ts +72 -14
- package/src/keys.ts +1 -1
- package/src/kill-ring.ts +5 -0
- package/src/kitty-graphics.ts +2 -101
- package/src/loop-watchdog.ts +106 -0
- package/src/mouse.ts +55 -0
- package/src/stdin-buffer.ts +291 -81
- package/src/terminal-capabilities.ts +206 -168
- package/src/terminal.ts +367 -110
- package/src/tui.ts +2102 -1729
- package/src/utils.ts +92 -60
package/src/index.ts
CHANGED
|
@@ -27,8 +27,10 @@ export * from "./fuzzy";
|
|
|
27
27
|
export * from "./keybindings";
|
|
28
28
|
// Kitty keyboard protocol helpers
|
|
29
29
|
export * from "./keys";
|
|
30
|
-
// Kitty graphics: Unicode placeholders
|
|
30
|
+
// Kitty graphics: Unicode placeholders
|
|
31
31
|
export * from "./kitty-graphics";
|
|
32
|
+
// SGR mouse report parsing
|
|
33
|
+
export * from "./mouse";
|
|
32
34
|
// Mermaid diagram support
|
|
33
35
|
// Input buffering for batch splitting
|
|
34
36
|
export * from "./stdin-buffer";
|
package/src/keybindings.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type KeyId,
|
|
1
|
+
import { type KeyId, parseKey } from "./keys";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Global keybinding registry.
|
|
@@ -100,11 +100,11 @@ export const TUI_KEYBINDINGS = {
|
|
|
100
100
|
description: "Delete character forward",
|
|
101
101
|
},
|
|
102
102
|
"tui.editor.deleteWordBackward": {
|
|
103
|
-
defaultKeys: ["ctrl+w", "alt+backspace", "ctrl+backspace"],
|
|
103
|
+
defaultKeys: ["ctrl+w", "alt+backspace", "ctrl+backspace", "super+alt+backspace"],
|
|
104
104
|
description: "Delete word backward",
|
|
105
105
|
},
|
|
106
106
|
"tui.editor.deleteWordForward": {
|
|
107
|
-
defaultKeys: ["alt+delete", "alt+d"],
|
|
107
|
+
defaultKeys: ["alt+delete", "alt+d", "super+alt+delete", "super+alt+d"],
|
|
108
108
|
description: "Delete word forward",
|
|
109
109
|
},
|
|
110
110
|
"tui.editor.deleteToLineStart": {
|
|
@@ -118,7 +118,7 @@ export const TUI_KEYBINDINGS = {
|
|
|
118
118
|
"tui.editor.yank": { defaultKeys: "ctrl+y", description: "Yank" },
|
|
119
119
|
"tui.editor.yankPop": { defaultKeys: "alt+y", description: "Yank pop" },
|
|
120
120
|
"tui.editor.undo": { defaultKeys: ["ctrl+-", "ctrl+_"], description: "Undo" },
|
|
121
|
-
"tui.input.newLine": { defaultKeys: "shift+enter", description: "Insert newline" },
|
|
121
|
+
"tui.input.newLine": { defaultKeys: ["shift+enter", "ctrl+j"], description: "Insert newline" },
|
|
122
122
|
"tui.input.submit": { defaultKeys: "enter", description: "Submit input" },
|
|
123
123
|
"tui.input.tab": { defaultKeys: "tab", description: "Tab / autocomplete" },
|
|
124
124
|
"tui.input.copy": { defaultKeys: "ctrl+c", description: "Copy selection" },
|
|
@@ -164,6 +164,64 @@ const SHIFTED_SYMBOL_KEYS = new Set<string>([
|
|
|
164
164
|
"~",
|
|
165
165
|
]);
|
|
166
166
|
|
|
167
|
+
const MODIFIER_ORDER = ["ctrl", "shift", "alt", "super"] as const;
|
|
168
|
+
|
|
169
|
+
function startsWithModifier(key: string, offset: number, modifier: string): boolean {
|
|
170
|
+
if (key.length <= offset + modifier.length || key.charCodeAt(offset + modifier.length) !== 43) return false;
|
|
171
|
+
for (let i = 0; i < modifier.length; i++) {
|
|
172
|
+
const actual = key.charCodeAt(offset + i);
|
|
173
|
+
const expected = modifier.charCodeAt(i);
|
|
174
|
+
if (actual !== expected && actual !== expected - 32) return false;
|
|
175
|
+
}
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isAsciiUppercaseLetter(key: string): boolean {
|
|
180
|
+
if (key.length !== 1) return false;
|
|
181
|
+
const code = key.charCodeAt(0);
|
|
182
|
+
return code >= 65 && code <= 90;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function canonicalKeyId(key: string): string {
|
|
186
|
+
let offset = 0;
|
|
187
|
+
const modifiers: string[] = [];
|
|
188
|
+
let foundModifier = true;
|
|
189
|
+
|
|
190
|
+
while (foundModifier) {
|
|
191
|
+
foundModifier = false;
|
|
192
|
+
for (const modifier of MODIFIER_ORDER) {
|
|
193
|
+
if (startsWithModifier(key, offset, modifier)) {
|
|
194
|
+
modifiers.push(modifier);
|
|
195
|
+
offset += modifier.length + 1;
|
|
196
|
+
foundModifier = true;
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const rawBase = key.slice(offset);
|
|
202
|
+
const lowerBase = rawBase.toLowerCase();
|
|
203
|
+
const base = lowerBase === "esc" ? "escape" : lowerBase === "return" ? "enter" : lowerBase;
|
|
204
|
+
if (isAsciiUppercaseLetter(rawBase) && !modifiers.includes("shift")) {
|
|
205
|
+
modifiers.push("shift");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (modifiers.length === 0) return base;
|
|
209
|
+
modifiers.sort(
|
|
210
|
+
(left, right) =>
|
|
211
|
+
MODIFIER_ORDER.indexOf(left as (typeof MODIFIER_ORDER)[number]) -
|
|
212
|
+
MODIFIER_ORDER.indexOf(right as (typeof MODIFIER_ORDER)[number]),
|
|
213
|
+
);
|
|
214
|
+
return `${modifiers.join("+")}+${base}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function addKeyAliases(keys: Set<string>, key: KeyId): void {
|
|
218
|
+
const canonical = canonicalKeyId(key);
|
|
219
|
+
keys.add(canonical);
|
|
220
|
+
if (SHIFTED_SYMBOL_KEYS.has(canonical)) {
|
|
221
|
+
keys.add(`shift+${canonical}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
167
225
|
const normalizeKeyId = (key: KeyId): KeyId => key.toLowerCase() as KeyId;
|
|
168
226
|
|
|
169
227
|
function normalizeKeys(keys: KeyId | KeyId[] | undefined): KeyId[] {
|
|
@@ -185,6 +243,7 @@ export class KeybindingsManager {
|
|
|
185
243
|
#definitions: KeybindingDefinitions;
|
|
186
244
|
#userBindings: KeybindingsConfig;
|
|
187
245
|
#keysById = new Map<Keybinding, KeyId[]>();
|
|
246
|
+
#matchKeysById = new Map<Keybinding, Set<string>>();
|
|
188
247
|
#conflicts: KeybindingConflict[] = [];
|
|
189
248
|
|
|
190
249
|
constructor(definitions: KeybindingDefinitions, userBindings: KeybindingsConfig = {}) {
|
|
@@ -195,6 +254,7 @@ export class KeybindingsManager {
|
|
|
195
254
|
|
|
196
255
|
#rebuild(): void {
|
|
197
256
|
this.#keysById.clear();
|
|
257
|
+
this.#matchKeysById.clear();
|
|
198
258
|
this.#conflicts = [];
|
|
199
259
|
|
|
200
260
|
const userClaims = new Map<KeyId, Set<Keybinding>>();
|
|
@@ -217,21 +277,19 @@ export class KeybindingsManager {
|
|
|
217
277
|
const userKeys = this.#userBindings[id];
|
|
218
278
|
const keys = userKeys === undefined ? normalizeKeys(definition.defaultKeys) : normalizeKeys(userKeys);
|
|
219
279
|
this.#keysById.set(id as Keybinding, keys);
|
|
280
|
+
const matchKeys = new Set<string>();
|
|
281
|
+
for (const key of keys) {
|
|
282
|
+
addKeyAliases(matchKeys, key);
|
|
283
|
+
}
|
|
284
|
+
this.#matchKeysById.set(id as Keybinding, matchKeys);
|
|
220
285
|
}
|
|
221
286
|
}
|
|
222
287
|
|
|
223
288
|
matches(data: string, keybinding: Keybinding): boolean {
|
|
224
|
-
const keys = this.#keysById.get(keybinding) ?? [];
|
|
225
|
-
for (const key of keys) {
|
|
226
|
-
if (matchesKey(data, key)) return true;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// Handle shifted symbol keys (e.g., shift+- produces _ on US layout)
|
|
230
289
|
const parsed = parseKey(data);
|
|
231
|
-
if (
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
return keys.includes(keyName as KeyId);
|
|
290
|
+
if (parsed === undefined) return false;
|
|
291
|
+
const matchKeys = this.#matchKeysById.get(keybinding);
|
|
292
|
+
return matchKeys?.has(canonicalKeyId(parsed)) ?? false;
|
|
235
293
|
}
|
|
236
294
|
|
|
237
295
|
getKeys(keybinding: Keybinding): KeyId[] {
|
package/src/keys.ts
CHANGED
|
@@ -195,7 +195,7 @@ export type KeyId = BaseKey | ModifiedKeyId<BaseKey>;
|
|
|
195
195
|
* modifier methods return precisely-typed concatenations (e.g. `Key.ctrl("c")`
|
|
196
196
|
* is `"ctrl+c"`, not just `string`). This mirrors the upstream
|
|
197
197
|
* `@mariozechner/pi-tui` `Key` export verbatim so plugins built against any
|
|
198
|
-
* scope alias (`@mariozechner`, `@earendil-works`, `@
|
|
198
|
+
* scope alias (`@mariozechner`, `@earendil-works`, `@Prometheus`) keep working
|
|
199
199
|
* once the specifier shim remaps them to this package.
|
|
200
200
|
*/
|
|
201
201
|
export const Key = {
|
package/src/kill-ring.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* into a single entry. Supports yank (paste most recent) and yank-pop
|
|
6
6
|
* (cycle through older entries).
|
|
7
7
|
*/
|
|
8
|
+
const MAX_ENTRIES = 60;
|
|
9
|
+
|
|
8
10
|
export class KillRing {
|
|
9
11
|
#ring: string[] = [];
|
|
10
12
|
|
|
@@ -24,6 +26,9 @@ export class KillRing {
|
|
|
24
26
|
this.#ring.push(opts.prepend ? text + last : last + text);
|
|
25
27
|
} else {
|
|
26
28
|
this.#ring.push(text);
|
|
29
|
+
if (this.#ring.length > MAX_ENTRIES) {
|
|
30
|
+
this.#ring.shift();
|
|
31
|
+
}
|
|
27
32
|
}
|
|
28
33
|
}
|
|
29
34
|
|
package/src/kitty-graphics.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Kitty graphics: Unicode placeholder placement (`U=1` + U+10EEEE)
|
|
3
|
-
*
|
|
2
|
+
* Kitty graphics: Unicode placeholder placement (`U=1` + U+10EEEE), with
|
|
3
|
+
* runtime feature state and env overrides.
|
|
4
4
|
*
|
|
5
5
|
* Unicode placeholders let a transmitted image be displayed by writing ordinary
|
|
6
6
|
* text cells — the placeholder char U+10EEEE plus row/column combining
|
|
@@ -14,10 +14,6 @@
|
|
|
14
14
|
* dependency stays one-way (capabilities → kitty-graphics) and no import cycle
|
|
15
15
|
* forms. Protocol gating (`imageProtocol === Kitty`) lives in the caller.
|
|
16
16
|
*/
|
|
17
|
-
import * as fs from "node:fs";
|
|
18
|
-
import * as os from "node:os";
|
|
19
|
-
import * as path from "node:path";
|
|
20
|
-
import { $env, logger } from "@prometheus-ai/utils";
|
|
21
17
|
|
|
22
18
|
/** Kitty Unicode placeholder base character (U+10EEEE, Plane 16 PUA). */
|
|
23
19
|
export const KITTY_PLACEHOLDER = "\u{10eeee}";
|
|
@@ -55,25 +51,9 @@ const ROWCOLUMN_DIACRITICS: readonly number[] = [
|
|
|
55
51
|
/** Largest row/column index expressible with the diacritic table (one cell each). */
|
|
56
52
|
export const KITTY_PLACEHOLDER_MAX_CELLS = ROWCOLUMN_DIACRITICS.length;
|
|
57
53
|
|
|
58
|
-
/** A minimal opaque 1x1 PNG (base64) used for the temp-file support probe. */
|
|
59
|
-
const PROBE_PNG_BASE64 =
|
|
60
|
-
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
|
|
61
|
-
|
|
62
|
-
export type KittyTransmissionMedium = "direct" | "temp-file";
|
|
63
|
-
|
|
64
54
|
export interface KittyGraphicsFeatures {
|
|
65
55
|
/** Display images via Unicode placeholders instead of direct `a=p` placement. */
|
|
66
56
|
unicodePlaceholders: boolean;
|
|
67
|
-
/** How image data reaches the terminal: in-band base64 or a temp file. */
|
|
68
|
-
transmissionMedium: KittyTransmissionMedium;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Explicit transmission medium from `PROMETHEUS_KITTY_IMAGE_TRANSMISSION`, else "auto". */
|
|
72
|
-
function transmissionOverride(): KittyTransmissionMedium | "auto" {
|
|
73
|
-
const raw = $env.PROMETHEUS_KITTY_IMAGE_TRANSMISSION?.trim().toLowerCase();
|
|
74
|
-
if (raw === "temp-file") return "temp-file";
|
|
75
|
-
if (raw === "direct") return "direct";
|
|
76
|
-
return "auto";
|
|
77
57
|
}
|
|
78
58
|
|
|
79
59
|
/**
|
|
@@ -106,8 +86,6 @@ let features: KittyGraphicsFeatures = {
|
|
|
106
86
|
// Off until `terminal-capabilities` seeds it from the detected terminal id —
|
|
107
87
|
// the default-on path corrupts wezterm and tmux-passthrough sessions.
|
|
108
88
|
unicodePlaceholders: false,
|
|
109
|
-
// Start direct; a successful probe (or explicit `temp-file` override) promotes.
|
|
110
|
-
transmissionMedium: transmissionOverride() === "temp-file" ? "temp-file" : "direct",
|
|
111
89
|
};
|
|
112
90
|
|
|
113
91
|
export function getKittyGraphics(): Readonly<KittyGraphicsFeatures> {
|
|
@@ -118,29 +96,11 @@ export function setKittyGraphics(partial: Partial<KittyGraphicsFeatures>): void
|
|
|
118
96
|
features = { ...features, ...partial };
|
|
119
97
|
}
|
|
120
98
|
|
|
121
|
-
/**
|
|
122
|
-
* Whether temp-file transmission may be promoted at runtime: forced via env,
|
|
123
|
-
* disabled via env, otherwise auto (local sessions only — a temp file written
|
|
124
|
-
* locally is not readable by a terminal on the far side of an SSH link).
|
|
125
|
-
*/
|
|
126
|
-
export function kittyTempFileAllowed(): boolean {
|
|
127
|
-
const override = transmissionOverride();
|
|
128
|
-
if (override === "temp-file") return true;
|
|
129
|
-
if (override === "direct") return false;
|
|
130
|
-
// Auto: local sessions only — a temp file written here is not on the SSH peer.
|
|
131
|
-
return !$env.SSH_CONNECTION && !$env.SSH_CLIENT && !$env.SSH_TTY;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
99
|
/** Whether a `columns`×`rows` placeholder grid fits within the diacritic table. */
|
|
135
100
|
export function kittyPlaceholdersFit(columns: number, rows: number): boolean {
|
|
136
101
|
return columns >= 1 && rows >= 1 && columns <= KITTY_PLACEHOLDER_MAX_CELLS && rows <= KITTY_PLACEHOLDER_MAX_CELLS;
|
|
137
102
|
}
|
|
138
103
|
|
|
139
|
-
/** True when the base64 payload is a PNG (kitty `f=100` / temp-file path only). */
|
|
140
|
-
export function isPngBase64(base64Data: string): boolean {
|
|
141
|
-
return base64Data.startsWith("iVBORw0KGgo");
|
|
142
|
-
}
|
|
143
|
-
|
|
144
104
|
function diacritic(index: number): string {
|
|
145
105
|
const cp = ROWCOLUMN_DIACRITICS[index];
|
|
146
106
|
return cp === undefined ? "" : String.fromCodePoint(cp);
|
|
@@ -209,62 +169,3 @@ export function renderKittyPlaceholderLines(opts: {
|
|
|
209
169
|
}
|
|
210
170
|
return grid;
|
|
211
171
|
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Path for a temp-file transmission. Kitty deletes a `t=t` file after reading it
|
|
215
|
-
* **only** when the path contains the substring `tty-graphics-protocol`, so it is
|
|
216
|
-
* embedded in the filename to keep the temp dir self-cleaning.
|
|
217
|
-
*/
|
|
218
|
-
function tempGraphicsPath(tag: string): string {
|
|
219
|
-
return path.join(os.tmpdir(), `tty-graphics-protocol-${tag}-${process.pid}-${Date.now()}.png`);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Transmit a PNG via a temp file (`t=t`): decode the base64 to bytes once, write
|
|
224
|
-
* them to a temp file, and send the base64-encoded file path as payload. Returns
|
|
225
|
-
* the APC string, or `null` on any failure (caller falls back to direct base64).
|
|
226
|
-
*
|
|
227
|
-
* Synchronous filesystem writes are mandated by the synchronous render pipeline
|
|
228
|
-
* (`Image.render` → `renderImage` are sync); there is no async seam here.
|
|
229
|
-
*/
|
|
230
|
-
export function encodeKittyTempFileTransmit(base64Png: string, imageId: number): string | null {
|
|
231
|
-
try {
|
|
232
|
-
const bytes = Buffer.from(base64Png, "base64");
|
|
233
|
-
if (bytes.length === 0) return null;
|
|
234
|
-
const file = tempGraphicsPath(`i${imageId}`);
|
|
235
|
-
fs.writeFileSync(file, bytes);
|
|
236
|
-
const encodedPath = Buffer.from(file, "utf8").toString("base64");
|
|
237
|
-
return `\x1b_Ga=t,f=100,t=t,S=${bytes.length},q=2,i=${imageId};${encodedPath}\x1b\\`;
|
|
238
|
-
} catch (err) {
|
|
239
|
-
logger.debug("Kitty temp-file transmit failed; using direct base64", { err: String(err) });
|
|
240
|
-
return null;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Encode a temp-file support probe: write a tiny PNG to a temp file and ask the
|
|
246
|
-
* terminal to query it (`a=q,t=t`). A conforming terminal replies
|
|
247
|
-
* `ESC _ G i=<probeId>;OK ESC \`. Returns the query APC plus a `cleanup` that
|
|
248
|
-
* removes the probe file (best-effort; kitty self-deletes the magic-named file).
|
|
249
|
-
* Returns `null` if the temp file cannot be written.
|
|
250
|
-
*/
|
|
251
|
-
export function encodeKittyTempFileProbe(probeId: number): { sequence: string; cleanup: () => void } | null {
|
|
252
|
-
try {
|
|
253
|
-
const bytes = Buffer.from(PROBE_PNG_BASE64, "base64");
|
|
254
|
-
const file = tempGraphicsPath(`probe${probeId}`);
|
|
255
|
-
fs.writeFileSync(file, bytes);
|
|
256
|
-
const encodedPath = Buffer.from(file, "utf8").toString("base64");
|
|
257
|
-
const sequence = `\x1b_Ga=q,t=t,f=100,S=${bytes.length},q=2,i=${probeId};${encodedPath}\x1b\\`;
|
|
258
|
-
const cleanup = () => {
|
|
259
|
-
try {
|
|
260
|
-
fs.rmSync(file, { force: true });
|
|
261
|
-
} catch {
|
|
262
|
-
// Best effort; kitty deletes tty-graphics-protocol files itself.
|
|
263
|
-
}
|
|
264
|
-
};
|
|
265
|
-
return { sequence, cleanup };
|
|
266
|
-
} catch (err) {
|
|
267
|
-
logger.debug("Kitty temp-file probe setup failed", { err: String(err) });
|
|
268
|
-
return null;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { logger, takeRecentLoopPhase } from "@prometheus-ai/utils";
|
|
3
|
+
|
|
4
|
+
export interface LoopWatchdogOptions {
|
|
5
|
+
/** How far ahead each probe tick is scheduled, in ms. Default 250. */
|
|
6
|
+
intervalMs?: number;
|
|
7
|
+
/** A tick later than this past its deadline counts as a block. Default 250. */
|
|
8
|
+
thresholdMs?: number;
|
|
9
|
+
/** Monotonic clock source; injectable for tests. Default `performance.now`. */
|
|
10
|
+
now?: () => number;
|
|
11
|
+
/** Timer source; injectable for tests. Default `setTimeout`. */
|
|
12
|
+
schedule?: (cb: () => void, ms: number) => LoopWatchdogTimer;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Timer handle the watchdog arms. `cancel`, when present, is invoked on stop()
|
|
17
|
+
* so a stopped watchdog leaves no armed timer to wake the loop even once.
|
|
18
|
+
*/
|
|
19
|
+
interface LoopWatchdogTimer {
|
|
20
|
+
unref?(): void;
|
|
21
|
+
cancel?(): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Always-on event-loop lag probe. Each tick is scheduled `intervalMs` ahead of
|
|
26
|
+
* a recorded deadline; a tick that fires `thresholdMs` past its deadline means
|
|
27
|
+
* the loop was blocked that long. The overshoot is logged once on the rising
|
|
28
|
+
* edge (one block ⇒ one line, deduped via `#wasBlocked`), tagged with the phase
|
|
29
|
+
* active during the elapsed interval via {@link takeRecentLoopPhase} — which
|
|
30
|
+
* survives the synchronous push/pop the instrumented hot paths do before this
|
|
31
|
+
* delayed tick can run — so the stall names its cause instead of "unknown".
|
|
32
|
+
*
|
|
33
|
+
* The handle is `unref`'d so the probe never keeps the process alive, and stop()
|
|
34
|
+
* cancels the armed timer when the handle exposes `cancel` (the default
|
|
35
|
+
* `setTimeout` handle does, via `clearTimeout`). The `#generation` guard remains
|
|
36
|
+
* as a fallback for injected handles that cannot cancel.
|
|
37
|
+
*/
|
|
38
|
+
export class LoopWatchdog {
|
|
39
|
+
#intervalMs: number;
|
|
40
|
+
#thresholdMs: number;
|
|
41
|
+
#now: () => number;
|
|
42
|
+
#schedule: (cb: () => void, ms: number) => LoopWatchdogTimer;
|
|
43
|
+
#expected = 0;
|
|
44
|
+
#wasBlocked = false;
|
|
45
|
+
#running = false;
|
|
46
|
+
// Bumped by stop(); each scheduled tick captures the generation it was armed
|
|
47
|
+
// under and no-ops if it no longer matches, so a start()→stop()→start() cycle
|
|
48
|
+
// cannot leave the pre-stop timer chain rescheduling itself in parallel.
|
|
49
|
+
#generation = 0;
|
|
50
|
+
#handle: LoopWatchdogTimer | undefined;
|
|
51
|
+
|
|
52
|
+
constructor(options: LoopWatchdogOptions = {}) {
|
|
53
|
+
this.#intervalMs = options.intervalMs ?? 250;
|
|
54
|
+
this.#thresholdMs = options.thresholdMs ?? 250;
|
|
55
|
+
this.#now = options.now ?? (() => performance.now());
|
|
56
|
+
this.#schedule =
|
|
57
|
+
options.schedule ??
|
|
58
|
+
((cb, ms) => {
|
|
59
|
+
const timer = setTimeout(cb, ms);
|
|
60
|
+
return { unref: () => timer.unref?.(), cancel: () => clearTimeout(timer) };
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
start(): void {
|
|
65
|
+
if (this.#running) return;
|
|
66
|
+
this.#running = true;
|
|
67
|
+
this.#wasBlocked = false;
|
|
68
|
+
this.#armTick();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
stop(): void {
|
|
72
|
+
this.#running = false;
|
|
73
|
+
this.#wasBlocked = false;
|
|
74
|
+
this.#generation++;
|
|
75
|
+
this.#handle?.cancel?.();
|
|
76
|
+
this.#handle = undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#armTick(): void {
|
|
80
|
+
const generation = this.#generation;
|
|
81
|
+
this.#expected = this.#now() + this.#intervalMs;
|
|
82
|
+
this.#handle = this.#schedule(() => this.#tick(generation), this.#intervalMs);
|
|
83
|
+
this.#handle.unref?.();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#tick(generation: number): void {
|
|
87
|
+
if (!this.#running || generation !== this.#generation) return;
|
|
88
|
+
const blockedMs = this.#now() - this.#expected;
|
|
89
|
+
// Consume the recent phase every tick (block or not) so attribution is
|
|
90
|
+
// scoped to the just-elapsed interval and never carries a stale phase
|
|
91
|
+
// forward to a later, phase-less block.
|
|
92
|
+
const phase = takeRecentLoopPhase();
|
|
93
|
+
if (blockedMs > this.#thresholdMs) {
|
|
94
|
+
if (!this.#wasBlocked) {
|
|
95
|
+
this.#wasBlocked = true;
|
|
96
|
+
logger.warn("ui.loop-blocked", {
|
|
97
|
+
blockedMs: Math.round(blockedMs),
|
|
98
|
+
phase: phase ?? "unknown",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
this.#wasBlocked = false;
|
|
103
|
+
}
|
|
104
|
+
this.#armTick();
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/mouse.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SGR mouse report parsing (`\x1b[<button;col;rowM` / `…m`).
|
|
3
|
+
*
|
|
4
|
+
* Mouse tracking is enabled only while a fullscreen overlay holds the
|
|
5
|
+
* alternate screen (see tui.ts MOUSE_TRACKING_ON), so consumers are
|
|
6
|
+
* fullscreen components hit-testing against their own rendered frame:
|
|
7
|
+
* the frame paints from screen row 0, hence `row`/`col` are exposed
|
|
8
|
+
* 0-based for direct indexing into rendered lines.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** A decoded SGR mouse report. */
|
|
12
|
+
export interface SgrMouseEvent {
|
|
13
|
+
/** Raw button code (bit 32 = motion, bit 64 = wheel, low bits = button). */
|
|
14
|
+
button: number;
|
|
15
|
+
/** 0-based column of the event. */
|
|
16
|
+
col: number;
|
|
17
|
+
/** 0-based row of the event. */
|
|
18
|
+
row: number;
|
|
19
|
+
/** True for a release report (`m` suffix). */
|
|
20
|
+
release: boolean;
|
|
21
|
+
/** Wheel direction: -1 up, 1 down, null when not a wheel event. */
|
|
22
|
+
wheel: -1 | 1 | null;
|
|
23
|
+
/** True when the pointer moved (hover or drag) rather than clicked. */
|
|
24
|
+
motion: boolean;
|
|
25
|
+
/** True for a left-button press (not motion, not release, not wheel). */
|
|
26
|
+
leftClick: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Decode an SGR mouse report, or return null when `data` is not one.
|
|
31
|
+
* Callers on hot keypress paths should pre-check `data.startsWith("\x1b[<")`
|
|
32
|
+
* before paying for the regex.
|
|
33
|
+
*/
|
|
34
|
+
export function parseSgrMouse(data: string): SgrMouseEvent | null {
|
|
35
|
+
const match = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(data);
|
|
36
|
+
if (!match) return null;
|
|
37
|
+
const button = Number(match[1]);
|
|
38
|
+
const col = Number(match[2]) - 1;
|
|
39
|
+
const row = Number(match[3]) - 1;
|
|
40
|
+
const release = match[4] === "m";
|
|
41
|
+
const wheel = button & 64 ? ((button & 1 ? 1 : -1) as 1 | -1) : null;
|
|
42
|
+
const motion = (button & 32) !== 0 && wheel === null;
|
|
43
|
+
const leftClick = !release && wheel === null && !motion && (button & 3) === 0;
|
|
44
|
+
return { button, col, row, release, wheel, motion, leftClick };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Implemented by components that accept routed mouse events at frame-local
|
|
49
|
+
* coordinates. Hosts translate screen coordinates to the component's own
|
|
50
|
+
* rendered lines before forwarding.
|
|
51
|
+
*/
|
|
52
|
+
export interface MouseRoutable {
|
|
53
|
+
/** `line`/`col` are 0-based within the component's rendered output. */
|
|
54
|
+
routeMouse(event: SgrMouseEvent, line: number, col: number): void;
|
|
55
|
+
}
|