@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.
Files changed (55) hide show
  1. package/dist/types/autocomplete.d.ts +3 -1
  2. package/dist/types/components/box.d.ts +1 -1
  3. package/dist/types/components/editor.d.ts +35 -2
  4. package/dist/types/components/image.d.ts +22 -3
  5. package/dist/types/components/input.d.ts +6 -1
  6. package/dist/types/components/loader.d.ts +9 -2
  7. package/dist/types/components/markdown.d.ts +3 -1
  8. package/dist/types/components/scroll-view.d.ts +23 -1
  9. package/dist/types/components/select-list.d.ts +19 -1
  10. package/dist/types/components/settings-list.d.ts +87 -7
  11. package/dist/types/components/spacer.d.ts +1 -1
  12. package/dist/types/components/tab-bar.d.ts +37 -4
  13. package/dist/types/components/text.d.ts +2 -2
  14. package/dist/types/components/truncated-text.d.ts +1 -1
  15. package/dist/types/fuzzy.d.ts +10 -1
  16. package/dist/types/index.d.ts +1 -0
  17. package/dist/types/keybindings.d.ts +5 -3
  18. package/dist/types/keys.d.ts +1 -1
  19. package/dist/types/kill-ring.d.ts +0 -7
  20. package/dist/types/kitty-graphics.d.ts +16 -31
  21. package/dist/types/loop-watchdog.d.ts +39 -0
  22. package/dist/types/mouse.d.ts +41 -0
  23. package/dist/types/stdin-buffer.d.ts +17 -0
  24. package/dist/types/terminal-capabilities.d.ts +74 -18
  25. package/dist/types/terminal.d.ts +34 -36
  26. package/dist/types/tui.d.ts +191 -79
  27. package/dist/types/utils.d.ts +5 -2
  28. package/package.json +4 -4
  29. package/src/autocomplete.ts +79 -65
  30. package/src/components/box.ts +43 -63
  31. package/src/components/editor.ts +471 -136
  32. package/src/components/image.ts +85 -9
  33. package/src/components/input.ts +12 -3
  34. package/src/components/loader.ts +35 -21
  35. package/src/components/markdown.ts +174 -53
  36. package/src/components/scroll-view.ts +63 -2
  37. package/src/components/select-list.ts +233 -38
  38. package/src/components/settings-list.ts +626 -64
  39. package/src/components/spacer.ts +9 -5
  40. package/src/components/tab-bar.ts +153 -28
  41. package/src/components/text.ts +6 -2
  42. package/src/components/truncated-text.ts +10 -2
  43. package/src/fuzzy.ts +214 -59
  44. package/src/index.ts +3 -1
  45. package/src/keybindings.ts +72 -14
  46. package/src/keys.ts +1 -1
  47. package/src/kill-ring.ts +5 -0
  48. package/src/kitty-graphics.ts +2 -101
  49. package/src/loop-watchdog.ts +106 -0
  50. package/src/mouse.ts +55 -0
  51. package/src/stdin-buffer.ts +291 -81
  52. package/src/terminal-capabilities.ts +206 -168
  53. package/src/terminal.ts +367 -110
  54. package/src/tui.ts +2102 -1729
  55. 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 + temp-file transmission
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";
@@ -1,4 +1,4 @@
1
- import { type KeyId, matchesKey, parseKey } from "./keys";
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 (!parsed?.startsWith("shift+")) return false;
232
- const keyName = parsed.slice("shift+".length);
233
- if (!SHIFTED_SYMBOL_KEYS.has(keyName)) return false;
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`, `@prometheus`) keep working
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
 
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Kitty graphics: Unicode placeholder placement (`U=1` + U+10EEEE) and temp-file
3
- * (`t=t`) image transmission, with runtime feature state and env overrides.
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
+ }