@prometheus-ai/tui 0.5.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +704 -0
  3. package/dist/types/autocomplete.d.ts +76 -0
  4. package/dist/types/bracketed-paste.d.ts +26 -0
  5. package/dist/types/components/box.d.ts +17 -0
  6. package/dist/types/components/cancellable-loader.d.ts +21 -0
  7. package/dist/types/components/editor.d.ts +105 -0
  8. package/dist/types/components/image.d.ts +84 -0
  9. package/dist/types/components/input.d.ts +18 -0
  10. package/dist/types/components/loader.d.ts +13 -0
  11. package/dist/types/components/markdown.d.ts +61 -0
  12. package/dist/types/components/scroll-view.d.ts +40 -0
  13. package/dist/types/components/select-list.d.ts +48 -0
  14. package/dist/types/components/settings-list.d.ts +41 -0
  15. package/dist/types/components/spacer.d.ts +11 -0
  16. package/dist/types/components/tab-bar.d.ts +56 -0
  17. package/dist/types/components/text.d.ts +13 -0
  18. package/dist/types/components/truncated-text.d.ts +10 -0
  19. package/dist/types/deccara.d.ts +49 -0
  20. package/dist/types/editor-component.d.ts +36 -0
  21. package/dist/types/fuzzy.d.ts +15 -0
  22. package/dist/types/index.d.ts +28 -0
  23. package/dist/types/keybindings.d.ts +189 -0
  24. package/dist/types/keys.d.ts +208 -0
  25. package/dist/types/kill-ring.d.ts +27 -0
  26. package/dist/types/kitty-graphics.d.ts +94 -0
  27. package/dist/types/stdin-buffer.d.ts +43 -0
  28. package/dist/types/symbols.d.ts +25 -0
  29. package/dist/types/terminal-capabilities.d.ts +196 -0
  30. package/dist/types/terminal.d.ts +103 -0
  31. package/dist/types/ttyid.d.ts +9 -0
  32. package/dist/types/tui.d.ts +275 -0
  33. package/dist/types/utils.d.ts +89 -0
  34. package/package.json +73 -0
  35. package/src/autocomplete.ts +871 -0
  36. package/src/bracketed-paste.ts +47 -0
  37. package/src/components/box.ts +156 -0
  38. package/src/components/cancellable-loader.ts +40 -0
  39. package/src/components/editor.ts +2695 -0
  40. package/src/components/image.ts +318 -0
  41. package/src/components/input.ts +459 -0
  42. package/src/components/loader.ts +86 -0
  43. package/src/components/markdown.ts +1189 -0
  44. package/src/components/scroll-view.ts +166 -0
  45. package/src/components/select-list.ts +331 -0
  46. package/src/components/settings-list.ts +212 -0
  47. package/src/components/spacer.ts +28 -0
  48. package/src/components/tab-bar.ts +175 -0
  49. package/src/components/text.ts +110 -0
  50. package/src/components/truncated-text.ts +61 -0
  51. package/src/deccara.ts +314 -0
  52. package/src/editor-component.ts +71 -0
  53. package/src/fuzzy.ts +143 -0
  54. package/src/index.ts +44 -0
  55. package/src/keybindings.ts +279 -0
  56. package/src/keys.ts +537 -0
  57. package/src/kill-ring.ts +46 -0
  58. package/src/kitty-graphics.ts +270 -0
  59. package/src/stdin-buffer.ts +423 -0
  60. package/src/symbols.ts +26 -0
  61. package/src/terminal-capabilities.ts +1009 -0
  62. package/src/terminal.ts +1114 -0
  63. package/src/ttyid.ts +70 -0
  64. package/src/tui.ts +2988 -0
  65. package/src/utils.ts +452 -0
@@ -0,0 +1,1114 @@
1
+ import { dlopen, FFIType, ptr } from "bun:ffi";
2
+ import * as fs from "node:fs";
3
+ import { $env, isBunTestRuntime, logger } from "@prometheus-ai/utils";
4
+ import { setKittyProtocolActive } from "./keys";
5
+ import { encodeKittyTempFileProbe, getKittyGraphics, kittyTempFileAllowed, setKittyGraphics } from "./kitty-graphics";
6
+ import { StdinBuffer } from "./stdin-buffer";
7
+ import { ImageProtocol, NotifyProtocol, setCellDimensions, setOsc99Supported, TERMINAL } from "./terminal-capabilities";
8
+
9
+ const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
10
+ const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
11
+ const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07";
12
+
13
+ /**
14
+ * Minimal terminal interface for TUI
15
+ */
16
+
17
+ // Track active terminal for emergency cleanup on crash
18
+ let activeTerminal: ProcessTerminal | null = null;
19
+ // Track if a terminal was ever started (for emergency restore logic)
20
+ let terminalEverStarted = false;
21
+
22
+ const STD_INPUT_HANDLE = -10;
23
+ const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200;
24
+ /**
25
+ * Emergency terminal restore - call this from signal/crash handlers
26
+ * Resets terminal state without requiring access to the ProcessTerminal instance
27
+ */
28
+ export function emergencyTerminalRestore(): void {
29
+ try {
30
+ const terminal = activeTerminal;
31
+ if (terminal) {
32
+ terminal.stop();
33
+ terminal.showCursor();
34
+ } else if (terminalEverStarted) {
35
+ // Blind restore only if we know a terminal was started but lost track of it
36
+ // This avoids writing escape sequences for non-TUI commands (grep, commit, etc.)
37
+ process.stdout.write(
38
+ "\x1b[?2026l" + // End synchronized output
39
+ "\x1b[?7h" + // Restore autowrap
40
+ "\x1b[?2004l" + // Disable bracketed paste
41
+ "\x1b[?2031l" + // Disable Mode 2031 appearance notifications
42
+ "\x1b[?2048l" + // Disable in-band resize notifications
43
+ "\x1b[<u" + // Pop kitty keyboard protocol
44
+ "\x1b[>4;0m" + // Disable modifyOtherKeys fallback
45
+ "\x1b[?25h", // Show cursor
46
+ );
47
+ if (process.stdin.setRawMode) {
48
+ process.stdin.setRawMode(false);
49
+ }
50
+ }
51
+ } catch {
52
+ // Terminal may already be dead during crash cleanup - ignore errors
53
+ }
54
+ }
55
+ /** Terminal-reported appearance (dark/light mode). */
56
+ export type TerminalAppearance = "dark" | "light";
57
+ export interface Terminal {
58
+ // Start the terminal with input and resize handlers
59
+ start(onInput: (data: string) => void, onResize: () => void): void;
60
+
61
+ // Stop the terminal and restore state
62
+ stop(): void;
63
+
64
+ /**
65
+ * Drain stdin before exiting to prevent Kitty key release events from
66
+ * leaking to the parent shell over slow SSH connections.
67
+ * @param maxMs - Maximum time to drain (default: 1000ms)
68
+ * @param idleMs - Exit early if no input arrives within this time (default: 50ms)
69
+ */
70
+ drainInput(maxMs?: number, idleMs?: number): Promise<void>;
71
+
72
+ // Write output to terminal
73
+ write(data: string): void;
74
+
75
+ // Get terminal dimensions
76
+ get columns(): number;
77
+ get rows(): number;
78
+
79
+ // Whether Kitty keyboard protocol is active
80
+ get kittyProtocolActive(): boolean;
81
+
82
+ // Cursor positioning (relative to current position)
83
+ moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines
84
+
85
+ // Cursor visibility
86
+ hideCursor(): void; // Hide the cursor
87
+ showCursor(): void; // Show the cursor
88
+
89
+ // Clear operations
90
+ clearLine(): void; // Clear current line
91
+ clearFromCursor(): void; // Clear from cursor to end of screen
92
+ clearScreen(): void; // Clear entire screen and move cursor to (0,0)
93
+
94
+ // Title operations
95
+ setTitle(title: string): void; // Set terminal window title
96
+
97
+ // Progress indicator (OSC 9;4)
98
+ setProgress(active: boolean): void;
99
+
100
+ /**
101
+ * Returns whether the native terminal viewport is at the scrollback tail when
102
+ * the host exposes that state. `undefined` means the terminal cannot report it.
103
+ *
104
+ * `ProcessTerminal` deliberately does not implement this — no real terminal
105
+ * can answer it truthfully:
106
+ *
107
+ * - POSIX terminals expose no scrollback-position API at all.
108
+ * - Every modern Windows terminal host (Windows Terminal, VS Code, Tabby,
109
+ * Hyper, Alacritty, WezTerm, JetBrains, …) fronts console apps through
110
+ * ConPTY, where kernel32's `GetConsoleScreenBufferInfo` describes the
111
+ * pseudo-console buffer. That buffer is pinned to the visible grid —
112
+ * scrollback lives in the host UI, invisible to console APIs
113
+ * (microsoft/terminal#10191) — so a probe reads "at bottom" no matter
114
+ * where the user scrolled. Trusting it let streaming-time rebuilds emit
115
+ * `\x1b[3J` and yank scrolled readers: #1635 (Windows Terminal), #1746
116
+ * (Tabby and other ConPTY hosts). No env var distinguishes these hosts
117
+ * (Tabby sets none), so trust cannot be conditional on the environment.
118
+ * - Legacy conhost (the only non-ConPTY host) keeps a real scrollback
119
+ * buffer, but its window follows the output cursor: a probe comparing
120
+ * `srWindow.Bottom` against `dwSize.Y - 1` reads "scrolled up" for a user
121
+ * following live output until all ~9001 buffer rows fill, permanently
122
+ * blocking checkpoint scrollback reconciliation.
123
+ *
124
+ * The renderer treats a missing implementation / `undefined` as "unknown":
125
+ * live mutations defer destructive rebuilds and reconcile native scrollback
126
+ * at explicit checkpoints (prompt submit), where the user's keystroke has
127
+ * already pinned the host viewport to the bottom. Only test terminals
128
+ * (xterm.js-backed) implement this with a real answer.
129
+ */
130
+ isNativeViewportAtBottom?(): boolean | undefined;
131
+
132
+ /**
133
+ * Override the global terminal-profile ED3 risk decision for custom/test
134
+ * terminals. `undefined` falls back to the resolved `TERMINAL` profile.
135
+ */
136
+ hasEagerEraseScrollbackRisk?(): boolean | undefined;
137
+
138
+ /**
139
+ * Register a callback for terminal appearance (dark/light) changes.
140
+ * Detection uses OSC 11 background color query with Mode 2031 as a change trigger.
141
+ * Fires when the detected appearance changes, including the initial detection.
142
+ */
143
+ onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
144
+ /** The last detected terminal appearance, or undefined if not yet known. */
145
+ get appearance(): TerminalAppearance | undefined;
146
+ /**
147
+ * Register a callback fired once per DEC private mode when its DECRQM support
148
+ * status resolves. Optional: only real terminals implement capability probing.
149
+ */
150
+ onPrivateModeReport?(callback: (mode: number, supported: boolean) => void): void;
151
+ }
152
+
153
+ function isWindowsSubsystemForLinux(): boolean {
154
+ return process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
155
+ }
156
+
157
+ /** Discriminated owner of an outstanding DA1 sentinel in the unified probe FIFO. */
158
+ type Da1SentinelOwner =
159
+ | { kind: "keyboard" }
160
+ | { kind: "osc11" }
161
+ | { kind: "privateMode"; mode: number }
162
+ | { kind: "kittyGraphicsProbe"; id: number }
163
+ | { kind: "osc99Probe"; id: string };
164
+
165
+ let nextOsc99ProbeId = 1;
166
+ let nextKittyGraphicsProbeId = 1;
167
+
168
+ function parseOsc99KeyValues(section: string): Map<string, string> {
169
+ const values = new Map<string, string>();
170
+ for (const part of section.split(":")) {
171
+ const eq = part.indexOf("=");
172
+ if (eq !== 1) continue;
173
+ values.set(part.slice(0, eq), part.slice(eq + 1));
174
+ }
175
+ return values;
176
+ }
177
+ /**
178
+ * Real terminal using process.stdin/stdout
179
+ */
180
+ export class ProcessTerminal implements Terminal {
181
+ #wasRaw = false;
182
+ #inputHandler?: (data: string) => void;
183
+ #resizeHandler?: () => void;
184
+ #stdoutResizeListener?: () => void;
185
+ #kittyProtocolActive = false;
186
+ #modifyOtherKeysActive = false;
187
+ #modifyOtherKeysTimeout?: Timer;
188
+ #stdinBuffer?: StdinBuffer;
189
+ #stdinDataHandler?: (data: string) => void;
190
+ #dead = false;
191
+ #writeLogPath = $env.PROMETHEUS_TUI_WRITE_LOG || "";
192
+ #windowsVTInputRestore?: () => void;
193
+ #appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
194
+ #appearance: TerminalAppearance | undefined;
195
+ #osc11Pending = false;
196
+ #osc11QueryQueued = false;
197
+ #osc11ResponseBuffer = "";
198
+ #osc99PendingId: string | undefined;
199
+ #osc99ResponseBuffer = "";
200
+ #osc99Capabilities = new Map<string, string>();
201
+ #kittyGraphicsPendingId: number | undefined;
202
+ #kittyGraphicsProbeCleanup: (() => void) | undefined;
203
+ #privateCsiResponseBuffer = "";
204
+ #da1SentinelOwners: Da1SentinelOwner[] = [];
205
+ /** Resolved DECRQM support per private mode (mode → supported). */
206
+ #privateModeSupport = new Map<number, boolean>();
207
+ #privateModeCallbacks: Array<(mode: number, supported: boolean) => void> = [];
208
+ /** Whether DEC 2048 in-band resize notifications are currently enabled. */
209
+ #inBandResizeActive = false;
210
+ #reportedColumns?: number;
211
+ #reportedRows?: number;
212
+ #osc11PollTimer?: Timer;
213
+ #mode2031DebounceTimer?: Timer;
214
+ #progressTimer?: ReturnType<typeof setInterval>;
215
+
216
+ get kittyProtocolActive(): boolean {
217
+ return this.#kittyProtocolActive;
218
+ }
219
+
220
+ get appearance(): TerminalAppearance | undefined {
221
+ return this.#appearance;
222
+ }
223
+
224
+ onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void {
225
+ this.#appearanceCallbacks.push(callback);
226
+ }
227
+
228
+ onPrivateModeReport(callback: (mode: number, supported: boolean) => void): void {
229
+ this.#privateModeCallbacks.push(callback);
230
+ }
231
+
232
+ start(onInput: (data: string) => void, onResize: () => void): void {
233
+ this.#inputHandler = onInput;
234
+ this.#resizeHandler = onResize;
235
+
236
+ // Register for emergency cleanup
237
+ activeTerminal = this;
238
+ terminalEverStarted = true;
239
+
240
+ // Save previous state and enable raw mode
241
+ this.#wasRaw = process.stdin.isRaw || false;
242
+ if (process.stdin.setRawMode) {
243
+ process.stdin.setRawMode(true);
244
+ }
245
+ process.stdin.setEncoding("utf8");
246
+ process.stdin.resume();
247
+
248
+ // Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
249
+ this.#safeWrite("\x1b[?2004h");
250
+
251
+ // Set up resize handler immediately. The OS refreshes process.stdout
252
+ // dimensions before firing `resize`, so it is authoritative for geometry:
253
+ // reconcile any stale cached DEC 2048 report before notifying the renderer.
254
+ this.#stdoutResizeListener = () => {
255
+ this.#reconcileInBandGeometryOnResize();
256
+ this.#resizeHandler?.();
257
+ };
258
+ process.stdout.on("resize", this.#stdoutResizeListener);
259
+
260
+ // Refresh terminal dimensions - they may be stale after suspend/resume
261
+ // (SIGWINCH is lost while process is stopped). Unix only.
262
+ if (process.platform !== "win32") {
263
+ process.kill(process.pid, "SIGWINCH");
264
+ }
265
+
266
+ // On Windows, enable ENABLE_VIRTUAL_TERMINAL_INPUT so the console sends
267
+ // VT escape sequences (e.g. \x1b[Z for Shift+Tab) instead of raw console
268
+ // events that lose modifier information. Must run after setRawMode(true)
269
+ // since that resets console mode flags.
270
+ this.#enableWindowsVTInput();
271
+ // Query and enable Kitty keyboard protocol
272
+ // The query handler intercepts input temporarily, then installs the user's handler
273
+ // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
274
+ this.#queryAndEnableKittyProtocol();
275
+
276
+ // Query terminal background color via OSC 11 for dark/light detection.
277
+ // Uses DA1 (Primary Device Attributes) as a sentinel: terminals process
278
+ // sequences in order, so if DA1 arrives before OSC 11 response,
279
+ // the terminal does not support OSC 11. This avoids indefinite hangs.
280
+ // Technique used by Neovim, bat, fish, and terminal-colorsaurus.
281
+ this.#queryBackgroundColor();
282
+
283
+ // Query OSC 99 notification capabilities for Kitty. The query uses the
284
+ // same DA1 sentinel FIFO as OSC 11/DECRQM so unsupported terminals resolve
285
+ // without leaking probe bytes to application input.
286
+ this.#queryOsc99Support();
287
+
288
+ // Probe Kitty temp-file (`t=t`) graphics transmission support. Rides the
289
+ // same DA1 sentinel FIFO; promotes the transmission medium to temp-file
290
+ // only on an explicit `OK`, so unsupported terminals stay on direct base64.
291
+ this.#queryKittyGraphicsTempFile();
292
+
293
+ // Subscribe to Mode 2031 appearance change notifications.
294
+ // When the terminal reports a change, we re-query OSC 11 to get the
295
+ // actual background color (following Neovim convention) with 100ms debounce.
296
+ this.#safeWrite("\x1b[?2031h");
297
+
298
+ // Start periodic OSC 11 re-query for terminals without Mode 2031
299
+ // (Warp, Alacritty, older WezTerm). Stops once Mode 2031 support is
300
+ // confirmed via DECRQM (probed below) or a Mode 2031 change notification
301
+ // fires — push notifications supersede polling, and the poll's repeated
302
+ // OSC 11/DA1 writes clear the user's active text selection on some
303
+ // terminals (copy breaks every 2s).
304
+ // Windows Terminal under WSL has been observed to close the hosting tab
305
+ // after repeated OSC 11/DA1 probes. Keep the initial/event-driven probes,
306
+ // but avoid background polling there.
307
+ if (!isWindowsSubsystemForLinux()) {
308
+ this.#startOsc11Poll();
309
+ }
310
+
311
+ // Probe DEC private-mode support via DECRQM. 2026 (synchronized output)
312
+ // gates the renderer's begin/end markers; 2048 (in-band resize) is enabled
313
+ // only after the terminal confirms support; 2031 (appearance change
314
+ // notifications) stops the OSC 11 poll once confirmed, since push
315
+ // notifications make polling redundant. Each probe rides the shared DA1
316
+ // sentinel FIFO, so a terminal that ignores DECRQM still resolves (as
317
+ // unsupported) when the DA1 reply arrives.
318
+ this.#queryPrivateMode(2026);
319
+ this.#queryPrivateMode(2048);
320
+ this.#queryPrivateMode(2031);
321
+ }
322
+
323
+ /**
324
+ * On Windows, add ENABLE_VIRTUAL_TERMINAL_INPUT to the stdin console mode
325
+ * so modified keys (for example Shift+Tab) arrive as VT escape sequences.
326
+ */
327
+ #enableWindowsVTInput(): void {
328
+ if (process.platform !== "win32") return;
329
+ this.#restoreWindowsVTInput();
330
+ try {
331
+ const kernel32 = dlopen("kernel32.dll", {
332
+ GetStdHandle: { args: [FFIType.i32], returns: FFIType.ptr },
333
+ GetConsoleMode: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.bool },
334
+ SetConsoleMode: { args: [FFIType.ptr, FFIType.u32], returns: FFIType.bool },
335
+ });
336
+ const handle = kernel32.symbols.GetStdHandle(STD_INPUT_HANDLE);
337
+ const mode = new Uint32Array(1);
338
+ const modePtr = ptr(mode);
339
+ if (!modePtr || !kernel32.symbols.GetConsoleMode(handle, modePtr)) {
340
+ kernel32.close();
341
+ return;
342
+ }
343
+ const originalMode = mode[0]!;
344
+ const vtMode = originalMode | ENABLE_VIRTUAL_TERMINAL_INPUT;
345
+ if (vtMode !== originalMode && !kernel32.symbols.SetConsoleMode(handle, vtMode)) {
346
+ kernel32.close();
347
+ return;
348
+ }
349
+ this.#windowsVTInputRestore = () => {
350
+ try {
351
+ kernel32.symbols.SetConsoleMode(handle, originalMode);
352
+ } finally {
353
+ kernel32.close();
354
+ }
355
+ };
356
+ } catch {
357
+ // bun:ffi unavailable or console API unsupported; keep startup non-fatal.
358
+ }
359
+ }
360
+
361
+ #restoreWindowsVTInput(): void {
362
+ if (process.platform !== "win32") return;
363
+ const restore = this.#windowsVTInputRestore;
364
+ this.#windowsVTInputRestore = undefined;
365
+ if (!restore) return;
366
+ try {
367
+ restore();
368
+ } catch {
369
+ // Ignore restore errors during terminal teardown.
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Set up StdinBuffer to split batched input into individual sequences.
375
+ * This ensures components receive single events, making matchesKey/isKeyRelease work correctly.
376
+ *
377
+ * Also watches for Kitty protocol response and enables it when detected.
378
+ * This is done here (after stdinBuffer parsing) rather than on raw stdin
379
+ * to handle the case where the response arrives split across multiple events.
380
+ */
381
+ #setupStdinBuffer(): void {
382
+ this.#stdinBuffer = new StdinBuffer({ timeout: 10 });
383
+
384
+ // Kitty protocol response pattern: \x1b[?<flags>u
385
+ const kittyResponsePattern = /^\x1b\[\?(\d+)u$/;
386
+
387
+ // Mode 2031 DSR response: \x1b[?997;{1=dark,2=light}n
388
+ const appearanceDsrPattern = /^\x1b\[\?997;([12])n$/;
389
+
390
+ // OSC 11 response: \x1b]11;rgb:RR/GG/BB or rgba:RR/GG/BB, terminated by BEL or ST.
391
+ const osc11ResponsePattern =
392
+ /^\x1b\]11;rgba?:([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})(?:\x07|\x1b\\)$/;
393
+
394
+ // DA1 (Primary Device Attributes) response: \x1b[?...c
395
+ const da1ResponsePattern = /^\x1b\[\?[\d;]*c$/;
396
+
397
+ // Private CSI partial: \x1b[?<digits/semicolons>... — incomplete probe response
398
+ // that the StdinBuffer flushed before the terminator arrived (split across
399
+ // stdin reads). Used to reassemble DA1, kitty, and Mode 2031 replies.
400
+ const privateCsiPartialPattern = /^\x1b\[\?[\d;]*[\x20-\x2f]*$/;
401
+
402
+ // DECRPM private-mode report (DECRQM reply): \x1b[?<mode>;<status>$y
403
+ const decrpmResponsePattern = /^\x1b\[\?(\d+);(\d+)\$y$/;
404
+
405
+ // In-band resize report (DEC mode 2048): \x1b[48;rows;cols;yPixels;xPixels t
406
+ const inBandResizePattern = /^\x1b\[48;(\d+);(\d+);(\d+);(\d+)t$/;
407
+
408
+ // Forward individual sequences to the input handler
409
+ this.#stdinBuffer.on("data", (sequence: string) => {
410
+ // Reassemble split private CSI responses (DA1, kitty keyboard, Mode 2031).
411
+ // When the terminal writes the response slowly enough that the StdinBuffer's
412
+ // flush timeout elapses mid-sequence, the prefix `\x1b[?<digits>` arrives as
413
+ // one event and the tail `;...<terminator>` arrives as individual character
414
+ // events that would otherwise leak into the prompt as keystrokes. See #1238.
415
+ if (
416
+ this.#privateCsiResponseBuffer ||
417
+ (privateCsiPartialPattern.test(sequence) && this.#da1SentinelOwners.length > 0)
418
+ ) {
419
+ if (this.#privateCsiResponseBuffer && sequence.startsWith("\x1b")) {
420
+ // New escape arrived mid-reassembly — abandon partial and re-process the new sequence.
421
+ this.#privateCsiResponseBuffer = "";
422
+ } else {
423
+ this.#privateCsiResponseBuffer += sequence;
424
+ // Cap accumulator to defend against runaway partials if the terminator never arrives.
425
+ if (this.#privateCsiResponseBuffer.length > 256) {
426
+ this.#privateCsiResponseBuffer = "";
427
+ return;
428
+ }
429
+ const lastChar = this.#privateCsiResponseBuffer.at(-1)!;
430
+ const lastCode = lastChar.charCodeAt(0);
431
+ if (lastCode >= 0x40 && lastCode <= 0x7e) {
432
+ // Terminator byte arrived. Fall through to the pattern checks with the
433
+ // reassembled sequence so the existing DA1/kitty/Mode 2031 handlers run.
434
+ sequence = this.#privateCsiResponseBuffer;
435
+ this.#privateCsiResponseBuffer = "";
436
+ } else if (!privateCsiPartialPattern.test(this.#privateCsiResponseBuffer)) {
437
+ // Diverged from a valid private CSI prefix (unexpected byte). Drop the
438
+ // probe noise we ate; do not forward to the input handler.
439
+ this.#privateCsiResponseBuffer = "";
440
+ return;
441
+ } else {
442
+ // Still accumulating.
443
+ return;
444
+ }
445
+ }
446
+ }
447
+
448
+ // In-band resize report (DEC mode 2048). Unsolicited and not tied to a
449
+ // sentinel: update reported geometry + cell size, then drive the resize
450
+ // handler so the renderer reflows.
451
+ const resizeMatch = sequence.match(inBandResizePattern);
452
+ if (resizeMatch) {
453
+ this.#handleInBandResizeReport(resizeMatch[1]!, resizeMatch[2]!, resizeMatch[3]!, resizeMatch[4]!);
454
+ return;
455
+ }
456
+
457
+ // DECRPM private-mode report. Resolves the matching probe by mode; the
458
+ // owner stays in the FIFO and is drained by its DA1 sentinel (a no-op
459
+ // once resolved). Per DECRPM, status 0 = unrecognized, 1/2 =
460
+ // set/reset, 3 = permanently set, and 4 = permanently reset. Only
461
+ // settable or permanently-set modes are useful for features we enable.
462
+ const decrpmMatch = sequence.match(decrpmResponsePattern);
463
+ if (decrpmMatch) {
464
+ this.#resolvePrivateMode(parseInt(decrpmMatch[1]!, 10), decrpmMatch[2] !== "0" && decrpmMatch[2] !== "4");
465
+ return;
466
+ }
467
+
468
+ // DA1 response: swallow our sentinel reply regardless of whether an
469
+ // earlier capability-specific response already succeeded. Other terminal
470
+ // probes should never see these replies.
471
+ if (da1ResponsePattern.test(sequence) && this.#da1SentinelOwners.length > 0) {
472
+ const owner = this.#da1SentinelOwners.shift()!;
473
+ switch (owner.kind) {
474
+ case "osc11": {
475
+ if (this.#osc11Pending) {
476
+ // DA1 arrived before the OSC 11 reply: terminal does not support OSC 11.
477
+ this.#osc11Pending = false;
478
+ this.#osc11ResponseBuffer = "";
479
+ }
480
+ // Start a queued OSC 11 query once the prior cycle is fully drained.
481
+ if (
482
+ this.#osc11QueryQueued &&
483
+ !this.#osc11Pending &&
484
+ !this.#da1SentinelOwners.some(o => o.kind === "osc11") &&
485
+ !this.#dead
486
+ ) {
487
+ this.#osc11QueryQueued = false;
488
+ this.#startOsc11Query();
489
+ }
490
+ break;
491
+ }
492
+ case "privateMode": {
493
+ // DA1 beat the DECRPM reply for this mode → treat as unsupported.
494
+ this.#resolvePrivateMode(owner.mode, false);
495
+ break;
496
+ }
497
+ case "keyboard": {
498
+ // Keyboard probe sentinel: kitty reply never arrived → fall back to modifyOtherKeys.
499
+ if (!this.#kittyProtocolActive && !this.#modifyOtherKeysActive && this.#modifyOtherKeysTimeout) {
500
+ clearTimeout(this.#modifyOtherKeysTimeout);
501
+ this.#modifyOtherKeysTimeout = undefined;
502
+ this.#safeWrite("\x1b[>4;2m");
503
+ this.#modifyOtherKeysActive = true;
504
+ }
505
+ break;
506
+ }
507
+ case "osc99Probe": {
508
+ this.#resolveOsc99Support(owner.id, false);
509
+ break;
510
+ }
511
+ case "kittyGraphicsProbe":
512
+ this.#resolveKittyGraphicsTempFile(owner.id, false);
513
+ break;
514
+ }
515
+ return;
516
+ }
517
+
518
+ const match = sequence.match(kittyResponsePattern);
519
+ if (match && !this.#modifyOtherKeysActive) {
520
+ if (this.#modifyOtherKeysTimeout) {
521
+ clearTimeout(this.#modifyOtherKeysTimeout);
522
+ this.#modifyOtherKeysTimeout = undefined;
523
+ }
524
+ // Any reply to `\x1b[?u` means the terminal speaks the kitty keyboard
525
+ // protocol. The reported flag value is the *current* stack-top — fresh
526
+ // terminals report 0 — so support is implied by the reply itself, not by
527
+ // the flag value. Pick the level we want; `\x1b[>Nu` pushes one frame
528
+ // that shutdown's single `\x1b[<u` pop balances.
529
+ const reportedFlags = parseInt(match[1]!, 10);
530
+ this.#kittyProtocolActive = true;
531
+ setKittyProtocolActive(true);
532
+ if (reportedFlags >= 3) {
533
+ // Already enriched (Ghostty/foot may keep flags from a parent app).
534
+ // Push level-2 to lock in event reporting.
535
+ this.#safeWrite("\x1b[>7u");
536
+ } else {
537
+ // Level 1 (disambiguate escape codes) — enough for Shift+Enter
538
+ // without the modifyOtherKeys fallback that caused regression #3259.
539
+ this.#safeWrite("\x1b[>1u");
540
+ }
541
+ return;
542
+ }
543
+
544
+ // OSC 11 replies can be split if the stdin buffer flushes a partial sequence.
545
+ // Accumulate fragments until the BEL/ST terminator arrives, then parse once.
546
+ // If a new escape sequence arrives (not the ST terminator), abort buffering
547
+ // and forward it as normal input so user keystrokes are never swallowed.
548
+ if (this.#osc11Pending && (this.#osc11ResponseBuffer || sequence.startsWith("\x1b]11;"))) {
549
+ if (this.#osc11ResponseBuffer && sequence.startsWith("\x1b") && sequence !== "\x1b\\") {
550
+ // New escape sequence arrived mid-buffer — not an OSC 11 continuation.
551
+ this.#osc11ResponseBuffer = "";
552
+ // Fall through to normal input handling below.
553
+ } else {
554
+ this.#osc11ResponseBuffer += sequence;
555
+ const osc11Match = this.#osc11ResponseBuffer.match(osc11ResponsePattern);
556
+ if (!osc11Match) return;
557
+ const [, rHex, gHex, bHex] = osc11Match;
558
+ this.#osc11Pending = false;
559
+ this.#osc11ResponseBuffer = "";
560
+ this.#handleOsc11Response(rHex!, gHex!, bHex!);
561
+ return;
562
+ }
563
+ }
564
+
565
+ if (this.#osc99PendingId && (this.#osc99ResponseBuffer || sequence.startsWith("\x1b]99;"))) {
566
+ if (this.#osc99ResponseBuffer && sequence.startsWith("\x1b") && sequence !== "\x1b\\") {
567
+ this.#osc99ResponseBuffer = "";
568
+ } else {
569
+ this.#osc99ResponseBuffer += sequence;
570
+ const osc99Match = this.#osc99ResponseBuffer.match(/^\x1b\]99;([^;]*);([\s\S]*?)(?:\x07|\x1b\\)$/u);
571
+ if (!osc99Match) return;
572
+ const [, meta, payload] = osc99Match;
573
+ this.#osc99ResponseBuffer = "";
574
+ this.#handleOsc99CapabilityResponse(meta!, payload!);
575
+ return;
576
+ }
577
+ }
578
+
579
+ // Kitty graphics temp-file probe reply: ESC _ G i=<id>;OK ESC \. The
580
+ // owner remains in the FIFO and is drained by its DA1 sentinel (no-op
581
+ // once resolved here).
582
+ if (this.#kittyGraphicsPendingId !== undefined && sequence.startsWith("\x1b_G")) {
583
+ const graphicsMatch = sequence.match(/^\x1b_G([^;]*);([\s\S]*?)\x1b\\$/u);
584
+ if (graphicsMatch) {
585
+ const idMatch = graphicsMatch[1]!.match(/(?:^|,)i=(\d+)(?:,|$)/);
586
+ const replyId = idMatch ? parseInt(idMatch[1]!, 10) : undefined;
587
+ if (replyId === this.#kittyGraphicsPendingId) {
588
+ this.#resolveKittyGraphicsTempFile(replyId, graphicsMatch[2]!.trim() === "OK");
589
+ return;
590
+ }
591
+ }
592
+ }
593
+
594
+ // Mode 2031 change notification: re-query OSC 11 with 100ms debounce
595
+ // (Neovim convention — coalesces rapid notifications during transitions)
596
+ const appearanceMatch = sequence.match(appearanceDsrPattern);
597
+ if (appearanceMatch) {
598
+ this.#stopOsc11Poll();
599
+ if (this.#mode2031DebounceTimer) clearTimeout(this.#mode2031DebounceTimer);
600
+ this.#mode2031DebounceTimer = setTimeout(() => {
601
+ this.#mode2031DebounceTimer = undefined;
602
+ this.#queryBackgroundColor();
603
+ }, 100);
604
+ return;
605
+ }
606
+ if (this.#inputHandler) {
607
+ this.#inputHandler(sequence);
608
+ }
609
+ });
610
+
611
+ // Re-wrap paste content with bracketed paste markers for existing editor handling
612
+ this.#stdinBuffer.on("paste", (content: string) => {
613
+ if (this.#inputHandler) {
614
+ this.#inputHandler(`\x1b[200~${content}\x1b[201~`);
615
+ }
616
+ });
617
+
618
+ // Handler that pipes stdin data through the buffer
619
+ this.#stdinDataHandler = (data: string) => {
620
+ this.#stdinBuffer!.process(data);
621
+ };
622
+ }
623
+
624
+ /**
625
+ * Send OSC 11 background color query followed by DA1 sentinel.
626
+ * DA1 avoids indefinite hangs: if DA1 response arrives before OSC 11,
627
+ * the terminal does not support OSC 11.
628
+ */
629
+ #queryBackgroundColor(): void {
630
+ if (this.#dead) return;
631
+ // Queue if an OSC 11 query is in flight or its DA1 sentinel hasn't been
632
+ // consumed yet. Starting a new query while a DA1 is outstanding would
633
+ // increment the sentinel counter, and the old DA1 arrival would then
634
+ // prematurely clear the new query's pending state.
635
+ if (this.#osc11Pending || this.#da1SentinelOwners.some(o => o.kind === "osc11")) {
636
+ this.#osc11QueryQueued = true;
637
+ return;
638
+ }
639
+ this.#startOsc11Query();
640
+ }
641
+
642
+ #startOsc11Query(): void {
643
+ this.#osc11Pending = true;
644
+ this.#osc11ResponseBuffer = "";
645
+ this.#da1SentinelOwners.push({ kind: "osc11" });
646
+ this.#safeWrite("\x1b]11;?\x07"); // OSC 11 query (BEL terminated)
647
+ this.#safeWrite("\x1b[c"); // DA1 sentinel
648
+ }
649
+
650
+ #shouldQueryOsc99Support(): boolean {
651
+ if (TERMINAL.notifyProtocol !== NotifyProtocol.Osc99) return false;
652
+ return !isBunTestRuntime() || $env.PROMETHEUS_TUI_OSC99_PROBE === "1";
653
+ }
654
+
655
+ #queryOsc99Support(): void {
656
+ setOsc99Supported(false);
657
+ this.#osc99Capabilities.clear();
658
+ this.#osc99PendingId = undefined;
659
+ this.#osc99ResponseBuffer = "";
660
+ if (this.#dead || !this.#shouldQueryOsc99Support()) return;
661
+
662
+ const id = `prometheus-probe-${nextOsc99ProbeId++}`;
663
+ this.#osc99PendingId = id;
664
+ this.#da1SentinelOwners.push({ kind: "osc99Probe", id });
665
+ this.#safeWrite(`\x1b]99;i=${id}:p=?;\x1b\\\x1b[c`);
666
+ }
667
+
668
+ #handleOsc99CapabilityResponse(metaRaw: string, payload: string): boolean {
669
+ const pendingId = this.#osc99PendingId;
670
+ if (!pendingId) return false;
671
+ const meta = parseOsc99KeyValues(metaRaw);
672
+ if (meta.get("i") !== pendingId || meta.get("p") !== "?") return false;
673
+
674
+ const capabilities = parseOsc99KeyValues(payload);
675
+ this.#osc99Capabilities = capabilities;
676
+ const payloadTypes = capabilities.get("p")?.split(",") ?? [];
677
+ this.#resolveOsc99Support(pendingId, payloadTypes.includes("title"));
678
+ return true;
679
+ }
680
+
681
+ #resolveOsc99Support(id: string, supported: boolean): void {
682
+ if (this.#osc99PendingId !== id) return;
683
+ this.#osc99PendingId = undefined;
684
+ this.#osc99ResponseBuffer = "";
685
+ if (!supported) this.#osc99Capabilities.clear();
686
+ setOsc99Supported(supported);
687
+ }
688
+
689
+ #shouldQueryKittyGraphicsTempFile(): boolean {
690
+ if (TERMINAL.imageProtocol !== ImageProtocol.Kitty) return false;
691
+ // Honor the remote/explicit env gate, and skip when temp-file is already on.
692
+ if (!kittyTempFileAllowed() || getKittyGraphics().transmissionMedium === "temp-file") return false;
693
+ return !isBunTestRuntime() || $env.PROMETHEUS_TUI_KITTY_GRAPHICS_PROBE === "1";
694
+ }
695
+
696
+ #queryKittyGraphicsTempFile(): void {
697
+ this.#clearKittyGraphicsProbe();
698
+ if (this.#dead || !this.#shouldQueryKittyGraphicsTempFile()) return;
699
+
700
+ const id = nextKittyGraphicsProbeId++;
701
+ const probe = encodeKittyTempFileProbe(id);
702
+ if (!probe) return;
703
+ this.#kittyGraphicsPendingId = id;
704
+ this.#kittyGraphicsProbeCleanup = probe.cleanup;
705
+ this.#da1SentinelOwners.push({ kind: "kittyGraphicsProbe", id });
706
+ this.#safeWrite(`${probe.sequence}\x1b[c`);
707
+ }
708
+
709
+ #resolveKittyGraphicsTempFile(id: number, supported: boolean): void {
710
+ if (this.#kittyGraphicsPendingId !== id) return;
711
+ if (supported) setKittyGraphics({ transmissionMedium: "temp-file" });
712
+ this.#clearKittyGraphicsProbe();
713
+ }
714
+
715
+ #clearKittyGraphicsProbe(): void {
716
+ this.#kittyGraphicsPendingId = undefined;
717
+ this.#kittyGraphicsProbeCleanup?.();
718
+ this.#kittyGraphicsProbeCleanup = undefined;
719
+ }
720
+ /**
721
+ * Parse an OSC 11 background color response and compute BT.601 luminance.
722
+ * Handles 1-, 2-, 3-, and 4-digit XParseColor hex components.
723
+ */
724
+ #handleOsc11Response(rHex: string, gHex: string, bHex: string): void {
725
+ const normalize = (hex: string): number => {
726
+ const value = parseInt(hex, 16);
727
+ if (Number.isNaN(value)) return 0;
728
+ const max = 16 ** hex.length - 1;
729
+ return max > 0 ? value / max : 0;
730
+ };
731
+ const luminance = 0.299 * normalize(rHex) + 0.587 * normalize(gHex) + 0.114 * normalize(bHex);
732
+ const mode: TerminalAppearance = luminance < 0.5 ? "dark" : "light";
733
+ if (mode === this.#appearance) return;
734
+ this.#appearance = mode;
735
+ for (const cb of this.#appearanceCallbacks) {
736
+ try {
737
+ cb(mode);
738
+ } catch {
739
+ /* ignore callback errors */
740
+ }
741
+ }
742
+ }
743
+
744
+ /**
745
+ * Start periodic OSC 11 re-queries for terminals without Mode 2031 (Warp, Alacritty, WezTerm).
746
+ * Self-disables once Mode 2031 fires (push-based is better than polling).
747
+ */
748
+ #startOsc11Poll(): void {
749
+ this.#stopOsc11Poll();
750
+ this.#osc11PollTimer = setInterval(() => {
751
+ if (this.#dead) {
752
+ this.#stopOsc11Poll();
753
+ return;
754
+ }
755
+ this.#queryBackgroundColor();
756
+ }, 2_000);
757
+ this.#osc11PollTimer.unref();
758
+ }
759
+
760
+ #stopOsc11Poll(): void {
761
+ if (this.#osc11PollTimer) {
762
+ clearInterval(this.#osc11PollTimer);
763
+ this.#osc11PollTimer = undefined;
764
+ }
765
+ }
766
+
767
+ /**
768
+ * Query terminal for Kitty keyboard protocol support and enable if available.
769
+ *
770
+ * Sends CSI ? u to query current flags. If terminal responds with CSI ? <flags> u,
771
+ * it supports the protocol and we enable it with CSI > 1 u.
772
+ *
773
+ * The response is detected in setupStdinBuffer's data handler, which properly
774
+ * handles the case where the response arrives split across multiple stdin events.
775
+ */
776
+ #queryAndEnableKittyProtocol(): void {
777
+ this.#setupStdinBuffer();
778
+ process.stdin.on("data", this.#stdinDataHandler!);
779
+ // Progressive enhancement query: CSI ?u asks the terminal for its current
780
+ // kitty keyboard flags (no side effect on the stack); the DA1 sentinel
781
+ // guarantees a reply even from terminals that ignore CSI ?u.
782
+ this.#da1SentinelOwners.push({ kind: "keyboard" });
783
+ this.#safeWrite("\x1b[?u\x1b[c");
784
+ this.#modifyOtherKeysTimeout = setTimeout(() => {
785
+ this.#modifyOtherKeysTimeout = undefined;
786
+ if (this.#kittyProtocolActive || this.#modifyOtherKeysActive) {
787
+ return;
788
+ }
789
+ this.#safeWrite("\x1b[>4;2m");
790
+ this.#modifyOtherKeysActive = true;
791
+ }, 150);
792
+ }
793
+
794
+ /**
795
+ * Probe a DEC private mode via DECRQM (`CSI ? mode $ p`) plus a DA1 sentinel.
796
+ * The sentinel guarantees resolution even from terminals that ignore DECRQM.
797
+ * Query and sentinel are fused into one write so the bare-`CSI c` sentinel
798
+ * accounting used elsewhere stays accurate.
799
+ */
800
+ #queryPrivateMode(mode: number): void {
801
+ if (this.#dead) return;
802
+ if (this.#privateModeSupport.has(mode)) return;
803
+ this.#da1SentinelOwners.push({ kind: "privateMode", mode });
804
+ this.#safeWrite(`\x1b[?${mode}$p\x1b[c`);
805
+ }
806
+
807
+ /**
808
+ * Record DECRQM support for a private mode (idempotent — first result wins)
809
+ * and notify subscribers. Enables DEC 2048 in-band resize when 2048 resolves
810
+ * supported, and stops the OSC 11 poll when 2031 resolves supported (Mode 2031
811
+ * push notifications make periodic re-querying redundant — and the poll's
812
+ * OSC 11/DA1 writes clobber active text selections on some terminals).
813
+ */
814
+ #resolvePrivateMode(mode: number, supported: boolean): void {
815
+ if (this.#privateModeSupport.has(mode)) return;
816
+ this.#privateModeSupport.set(mode, supported);
817
+ for (const cb of this.#privateModeCallbacks) {
818
+ try {
819
+ cb(mode, supported);
820
+ } catch {
821
+ // Ignore subscriber errors — capability reporting must not crash input.
822
+ }
823
+ }
824
+ if (mode === 2048 && supported) this.#enableInBandResize();
825
+ if (mode === 2031 && supported) this.#stopOsc11Poll();
826
+ }
827
+
828
+ /**
829
+ * Enable DEC 2048 in-band resize notifications. The terminal emits an initial
830
+ * report immediately, seeding reported geometry and cell dimensions.
831
+ */
832
+ #enableInBandResize(): void {
833
+ if (this.#inBandResizeActive || this.#dead) return;
834
+ this.#inBandResizeActive = true;
835
+ this.#safeWrite("\x1b[?2048h");
836
+ }
837
+
838
+ /**
839
+ * Apply an in-band resize report. Stores reported geometry so `rows`/`columns`
840
+ * reflect in-band values, derives cell pixel size, and drives the resize
841
+ * handler only when the report changes the effective row/column geometry.
842
+ */
843
+ #handleInBandResizeReport(rowsRaw: string, colsRaw: string, yPixelsRaw: string, xPixelsRaw: string): void {
844
+ const previousRows = this.rows;
845
+ const previousColumns = this.columns;
846
+ const rows = parseInt(rowsRaw, 10);
847
+ const cols = parseInt(colsRaw, 10);
848
+ const yPixels = parseInt(yPixelsRaw, 10);
849
+ const xPixels = parseInt(xPixelsRaw, 10);
850
+ if (rows > 0) this.#reportedRows = rows;
851
+ if (cols > 0) this.#reportedColumns = cols;
852
+ if (cols > 0 && xPixels > 0 && rows > 0 && yPixels > 0) {
853
+ setCellDimensions({
854
+ widthPx: Math.max(1, Math.round(xPixels / cols)),
855
+ heightPx: Math.max(1, Math.round(yPixels / rows)),
856
+ });
857
+ }
858
+ if (rows > 0 && cols > 0 && (rows !== previousRows || cols !== previousColumns)) {
859
+ this.#resizeHandler?.();
860
+ }
861
+ }
862
+
863
+ /**
864
+ * Reconcile cached in-band geometry with the OS on an OS-level resize.
865
+ *
866
+ * SIGWINCH (POSIX) and ConPTY (Windows) refresh `process.stdout.columns`/
867
+ * `rows` before the `resize` event fires, so they are authoritative for the
868
+ * new cell geometry. A cached DEC 2048 report can be stale: the matching
869
+ * post-resize report may be dropped (split across stdin reads past the flush
870
+ * window) or carry `:`-subparameters the parser skips, leaving the getters
871
+ * pinned to the old size — which freezes the rendered width because the
872
+ * renderer reflows against {@link columns}/{@link rows}, not the live OS
873
+ * value. Drop a cached dimension that disagrees with the live OS value; the
874
+ * terminal's next valid in-band report re-seeds pixel sizing.
875
+ */
876
+ #reconcileInBandGeometryOnResize(): void {
877
+ if (!this.#inBandResizeActive) return;
878
+ const osColumns = process.stdout.columns;
879
+ const osRows = process.stdout.rows;
880
+ if (this.#reportedColumns !== undefined && osColumns > 0 && this.#reportedColumns !== osColumns) {
881
+ this.#reportedColumns = undefined;
882
+ }
883
+ if (this.#reportedRows !== undefined && osRows > 0 && this.#reportedRows !== osRows) {
884
+ this.#reportedRows = undefined;
885
+ }
886
+ }
887
+
888
+ async drainInput(maxMs = 1000, idleMs = 50): Promise<void> {
889
+ if (this.#kittyProtocolActive) {
890
+ // Disable Kitty keyboard protocol first so any late key releases
891
+ // do not generate new Kitty escape sequences.
892
+ this.#safeWrite("\x1b[<u");
893
+ this.#kittyProtocolActive = false;
894
+ setKittyProtocolActive(false);
895
+ }
896
+ if (this.#modifyOtherKeysTimeout) {
897
+ clearTimeout(this.#modifyOtherKeysTimeout);
898
+ this.#modifyOtherKeysTimeout = undefined;
899
+ }
900
+ if (this.#modifyOtherKeysActive) {
901
+ this.#safeWrite("\x1b[>4;0m");
902
+ this.#modifyOtherKeysActive = false;
903
+ }
904
+
905
+ const previousHandler = this.#inputHandler;
906
+ this.#inputHandler = undefined;
907
+
908
+ let lastDataTime = Date.now();
909
+ const onData = () => {
910
+ lastDataTime = Date.now();
911
+ };
912
+
913
+ process.stdin.on("data", onData);
914
+ const endTime = Date.now() + maxMs;
915
+
916
+ try {
917
+ while (true) {
918
+ const now = Date.now();
919
+ const timeLeft = endTime - now;
920
+ if (timeLeft <= 0) break;
921
+ if (now - lastDataTime >= idleMs) break;
922
+ await new Promise(resolve => setTimeout(resolve, Math.min(idleMs, timeLeft)));
923
+ }
924
+ } finally {
925
+ process.stdin.removeListener("data", onData);
926
+ this.#inputHandler = previousHandler;
927
+ }
928
+ }
929
+
930
+ stop(): void {
931
+ // Unregister from emergency cleanup
932
+ if (activeTerminal === this) {
933
+ activeTerminal = null;
934
+ }
935
+
936
+ if (this.#clearProgressTimer()) {
937
+ this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
938
+ }
939
+
940
+ // Leave paint-time terminal modes even if the process exits between the
941
+ // begin/end halves of a frame. Safe no-ops on terminals that ignored them.
942
+ this.#safeWrite("\x1b[?2026l\x1b[?7h");
943
+
944
+ // Disable bracketed paste mode
945
+ this.#safeWrite("\x1b[?2004l");
946
+
947
+ // Disable Mode 2031 appearance change notifications
948
+ this.#safeWrite("\x1b[?2031l");
949
+
950
+ // Disable DEC 2048 in-band resize notifications if we enabled them.
951
+ if (this.#inBandResizeActive) {
952
+ this.#safeWrite("\x1b[?2048l");
953
+ this.#inBandResizeActive = false;
954
+ }
955
+ this.#stopOsc11Poll();
956
+ if (this.#mode2031DebounceTimer) {
957
+ clearTimeout(this.#mode2031DebounceTimer);
958
+ this.#mode2031DebounceTimer = undefined;
959
+ }
960
+ this.#appearanceCallbacks = [];
961
+ this.#osc11Pending = false;
962
+ this.#osc11QueryQueued = false;
963
+ this.#osc11ResponseBuffer = "";
964
+ this.#osc99PendingId = undefined;
965
+ this.#osc99ResponseBuffer = "";
966
+ this.#osc99Capabilities.clear();
967
+ setOsc99Supported(false);
968
+ this.#clearKittyGraphicsProbe();
969
+ this.#privateCsiResponseBuffer = "";
970
+ this.#da1SentinelOwners.length = 0;
971
+ this.#privateModeCallbacks = [];
972
+ this.#privateModeSupport.clear();
973
+ this.#reportedColumns = undefined;
974
+ this.#reportedRows = undefined;
975
+
976
+ // Disable Kitty keyboard protocol if not already done by drainInput()
977
+ if (this.#kittyProtocolActive) {
978
+ this.#safeWrite("\x1b[<u");
979
+ this.#kittyProtocolActive = false;
980
+ setKittyProtocolActive(false);
981
+ }
982
+ if (this.#modifyOtherKeysTimeout) {
983
+ clearTimeout(this.#modifyOtherKeysTimeout);
984
+ this.#modifyOtherKeysTimeout = undefined;
985
+ }
986
+ if (this.#modifyOtherKeysActive) {
987
+ this.#safeWrite("\x1b[>4;0m");
988
+ this.#modifyOtherKeysActive = false;
989
+ }
990
+
991
+ this.#restoreWindowsVTInput();
992
+ // Clean up StdinBuffer
993
+ if (this.#stdinBuffer) {
994
+ this.#stdinBuffer.destroy();
995
+ this.#stdinBuffer = undefined;
996
+ }
997
+
998
+ // Remove event handlers
999
+ if (this.#stdinDataHandler) {
1000
+ process.stdin.removeListener("data", this.#stdinDataHandler);
1001
+ this.#stdinDataHandler = undefined;
1002
+ }
1003
+ this.#inputHandler = undefined;
1004
+ this.#appearance = undefined;
1005
+ if (this.#stdoutResizeListener) {
1006
+ process.stdout.removeListener("resize", this.#stdoutResizeListener);
1007
+ this.#stdoutResizeListener = undefined;
1008
+ }
1009
+ this.#resizeHandler = undefined;
1010
+
1011
+ // Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
1012
+ // re-interpreted after raw mode is disabled. This fixes a race condition
1013
+ // where Ctrl+D could close the parent shell over SSH.
1014
+ process.stdin.pause();
1015
+
1016
+ // Restore raw mode state
1017
+ if (process.stdin.setRawMode) {
1018
+ process.stdin.setRawMode(this.#wasRaw);
1019
+ }
1020
+ }
1021
+
1022
+ write(data: string): void {
1023
+ this.#safeWrite(data);
1024
+ if (this.#writeLogPath) {
1025
+ try {
1026
+ fs.appendFileSync(this.#writeLogPath, data, { encoding: "utf8" });
1027
+ } catch {
1028
+ // Ignore logging errors
1029
+ }
1030
+ }
1031
+ }
1032
+
1033
+ #safeWrite(data: string): void {
1034
+ if (this.#dead) return;
1035
+ // Skip control sequences when stdout isn't a TTY (piped output, tests, log
1036
+ // files). They serve no purpose there and would surface as visible noise.
1037
+ if (!process.stdout.isTTY) return;
1038
+ try {
1039
+ process.stdout.write(data);
1040
+ } catch (err) {
1041
+ // Any write failure means terminal is dead - no recovery possible
1042
+ this.#dead = true;
1043
+ logger.warn("terminal is dead - no recovery possible", { error: err, data });
1044
+ }
1045
+ }
1046
+
1047
+ get columns(): number {
1048
+ if (this.#inBandResizeActive && this.#reportedColumns) return this.#reportedColumns;
1049
+ return process.stdout.columns || Number(Bun.env.COLUMNS) || 80;
1050
+ }
1051
+
1052
+ get rows(): number {
1053
+ if (this.#inBandResizeActive && this.#reportedRows) return this.#reportedRows;
1054
+ return process.stdout.rows || Number(Bun.env.LINES) || 24;
1055
+ }
1056
+
1057
+ moveBy(lines: number): void {
1058
+ if (lines > 0) {
1059
+ // Move down
1060
+ this.#safeWrite(`\x1b[${lines}B`);
1061
+ } else if (lines < 0) {
1062
+ // Move up
1063
+ this.#safeWrite(`\x1b[${-lines}A`);
1064
+ }
1065
+ // lines === 0: no movement
1066
+ }
1067
+
1068
+ hideCursor(): void {
1069
+ this.#safeWrite("\x1b[?25l");
1070
+ }
1071
+
1072
+ showCursor(): void {
1073
+ this.#safeWrite("\x1b[?25h");
1074
+ }
1075
+
1076
+ clearLine(): void {
1077
+ this.#safeWrite("\x1b[K");
1078
+ }
1079
+
1080
+ clearFromCursor(): void {
1081
+ this.#safeWrite("\x1b[J");
1082
+ }
1083
+
1084
+ clearScreen(): void {
1085
+ this.#safeWrite("\x1b[H\x1b[0J"); // Move to home (1,1) and clear from cursor to end
1086
+ }
1087
+
1088
+ setTitle(title: string): void {
1089
+ // OSC 0;title BEL - set terminal window title
1090
+ this.#safeWrite(`\x1b]0;${title}\x07`);
1091
+ }
1092
+
1093
+ setProgress(active: boolean): void {
1094
+ if (active) {
1095
+ this.#safeWrite(TERMINAL_PROGRESS_ACTIVE_SEQUENCE);
1096
+ if (!this.#progressTimer) {
1097
+ this.#progressTimer = setInterval(() => {
1098
+ this.#safeWrite(TERMINAL_PROGRESS_ACTIVE_SEQUENCE);
1099
+ }, TERMINAL_PROGRESS_KEEPALIVE_MS);
1100
+ this.#progressTimer.unref?.();
1101
+ }
1102
+ } else {
1103
+ this.#clearProgressTimer();
1104
+ this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
1105
+ }
1106
+ }
1107
+
1108
+ #clearProgressTimer(): boolean {
1109
+ if (!this.#progressTimer) return false;
1110
+ clearInterval(this.#progressTimer);
1111
+ this.#progressTimer = undefined;
1112
+ return true;
1113
+ }
1114
+ }