jeopi-tui 16.2.13

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 (75) hide show
  1. package/CHANGELOG.md +1861 -0
  2. package/README.md +705 -0
  3. package/dist/types/autocomplete.d.ts +99 -0
  4. package/dist/types/bracketed-paste.d.ts +51 -0
  5. package/dist/types/components/box.d.ts +31 -0
  6. package/dist/types/components/cancellable-loader.d.ts +21 -0
  7. package/dist/types/components/editor.d.ts +155 -0
  8. package/dist/types/components/image.d.ts +112 -0
  9. package/dist/types/components/input.d.ts +23 -0
  10. package/dist/types/components/loader.d.ts +20 -0
  11. package/dist/types/components/markdown.d.ts +64 -0
  12. package/dist/types/components/scroll-view.d.ts +62 -0
  13. package/dist/types/components/select-list.d.ts +68 -0
  14. package/dist/types/components/settings-list.d.ts +123 -0
  15. package/dist/types/components/spacer.d.ts +11 -0
  16. package/dist/types/components/tab-bar.d.ts +89 -0
  17. package/dist/types/components/text.d.ts +14 -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/desktop-notify.d.ts +51 -0
  21. package/dist/types/editor-component.d.ts +38 -0
  22. package/dist/types/fuzzy.d.ts +32 -0
  23. package/dist/types/index.d.ts +32 -0
  24. package/dist/types/keybindings.d.ts +191 -0
  25. package/dist/types/keys.d.ts +208 -0
  26. package/dist/types/kill-ring.d.ts +20 -0
  27. package/dist/types/kitty-graphics.d.ts +79 -0
  28. package/dist/types/latex-block.d.ts +7 -0
  29. package/dist/types/latex-to-unicode.d.ts +33 -0
  30. package/dist/types/loop-watchdog.d.ts +39 -0
  31. package/dist/types/mouse.d.ts +67 -0
  32. package/dist/types/stdin-buffer.d.ts +60 -0
  33. package/dist/types/symbols.d.ts +25 -0
  34. package/dist/types/terminal-capabilities.d.ts +284 -0
  35. package/dist/types/terminal.d.ts +107 -0
  36. package/dist/types/ttyid.d.ts +9 -0
  37. package/dist/types/tui.d.ts +423 -0
  38. package/dist/types/utils.d.ts +95 -0
  39. package/package.json +73 -0
  40. package/src/autocomplete.ts +1026 -0
  41. package/src/bracketed-paste.ts +123 -0
  42. package/src/components/box.ts +194 -0
  43. package/src/components/cancellable-loader.ts +40 -0
  44. package/src/components/editor.ts +3092 -0
  45. package/src/components/image.ts +444 -0
  46. package/src/components/input.ts +474 -0
  47. package/src/components/loader.ts +103 -0
  48. package/src/components/markdown.ts +2068 -0
  49. package/src/components/scroll-view.ts +227 -0
  50. package/src/components/select-list.ts +531 -0
  51. package/src/components/settings-list.ts +793 -0
  52. package/src/components/spacer.ts +32 -0
  53. package/src/components/tab-bar.ts +300 -0
  54. package/src/components/text.ts +122 -0
  55. package/src/components/truncated-text.ts +69 -0
  56. package/src/deccara.ts +314 -0
  57. package/src/desktop-notify.ts +186 -0
  58. package/src/editor-component.ts +74 -0
  59. package/src/fuzzy.ts +356 -0
  60. package/src/index.ts +51 -0
  61. package/src/keybindings.ts +337 -0
  62. package/src/keys.ts +561 -0
  63. package/src/kill-ring.ts +51 -0
  64. package/src/kitty-graphics.ts +171 -0
  65. package/src/latex-block.ts +461 -0
  66. package/src/latex-to-unicode.ts +1994 -0
  67. package/src/loop-watchdog.ts +106 -0
  68. package/src/mouse.ts +105 -0
  69. package/src/stdin-buffer.ts +669 -0
  70. package/src/symbols.ts +26 -0
  71. package/src/terminal-capabilities.ts +1152 -0
  72. package/src/terminal.ts +1463 -0
  73. package/src/ttyid.ts +84 -0
  74. package/src/tui.ts +3901 -0
  75. package/src/utils.ts +570 -0
@@ -0,0 +1,1463 @@
1
+ import { dlopen, FFIType, ptr } from "bun:ffi";
2
+ import * as fs from "node:fs";
3
+ import { $env, isBunTestRuntime, isTerminalHeadless, logger } from "jeopi-utils";
4
+ import { setKittyProtocolActive } from "./keys";
5
+ import { StdinBuffer } from "./stdin-buffer";
6
+ import {
7
+ isInsideTmux,
8
+ NotifyProtocol,
9
+ setCellDimensions,
10
+ setOsc99Supported,
11
+ TERMINAL,
12
+ wrapTmuxPassthrough,
13
+ } from "./terminal-capabilities";
14
+ import { type HangulCompatibilityJamoWidth, setHangulCompatibilityJamoWidth } from "./utils";
15
+
16
+ const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
17
+ const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
18
+ const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07";
19
+ // Hangul Compatibility Jamo (U+3131..=U+318E) render width is terminal-dependent:
20
+ // Ghostty follows UAX#11 (2 cells); Terminal.app and iTerm2 render narrow (1),
21
+ // matching the macOS platform default. Override only for terminals known to
22
+ // disagree — the rest keep the platform default (macOS narrow, otherwise UAX#11),
23
+ // so this is a no-op everywhere except Ghostty. A runtime DSR/CPR probe that
24
+ // auto-detects the width on unknown terminals is tracked separately.
25
+ export function resolveHangulCompatibilityJamoWidthFromTerminalIdentity(
26
+ env: NodeJS.ProcessEnv = Bun.env,
27
+ ): HangulCompatibilityJamoWidth {
28
+ if (
29
+ env.GHOSTTY_RESOURCES_DIR ||
30
+ env.TERM_PROGRAM?.toLowerCase() === "ghostty" ||
31
+ env.TERM?.toLowerCase().includes("ghostty")
32
+ ) {
33
+ return 2;
34
+ }
35
+ return "platform";
36
+ }
37
+
38
+ /**
39
+ * Maximum encoded UTF-8 bytes per `process.stdout.write` call on Windows.
40
+ *
41
+ * Windows ConPTY ties viewport tracking to per-`WriteFile` boundaries: when a
42
+ * single write exceeds ~32-64 KB, the pseudo-console stops following the
43
+ * cursor and the host UI's viewport stays parked at whatever scroll position
44
+ * the write started from. The visible symptom is that a full-paint of a long
45
+ * session (resume, history rebuild, large permission dialog) shows only the
46
+ * first ~30 lines until any focus event forces the host to re-query the
47
+ * cursor. The data is delivered correctly — it's purely a viewport-sync bug.
48
+ *
49
+ * The cap is on **encoded UTF-8 bytes**, not JS code units, because
50
+ * `process.stdout.write(string)` UTF-8-encodes before handing off to
51
+ * `WriteFile`. A pure-CJK transcript row encodes to ~3 bytes per BMP code
52
+ * unit, so a code-unit-based cap of 16 KiB could land at ~48 KiB of actual
53
+ * `WriteFile` traffic and reintroduce the #2034 parked-viewport bug for
54
+ * non-ASCII content.
55
+ *
56
+ * 16 KiB is half the smallest observed Windows Terminal threshold (32 KiB),
57
+ * which keeps the per-write parked-viewport bug fixed by #2034 while halving
58
+ * the WriteFile count on multi-megabyte paints (a 3 MB session resume splits
59
+ * into ~192 chunks instead of ~384). Fewer WriteFiles means fewer chances for
60
+ * WT's viewport-following logic to lose track of the cursor during the burst,
61
+ * which mitigates the residual mid-paint drift the original 8 KiB cap left
62
+ * behind (#2095). Still well clear of the threshold so the other ConPTY hosts
63
+ * (Tabby, Hyper, VS Code) — where the exact limit is undocumented — keep
64
+ * their safety margin.
65
+ */
66
+ const MAX_CONPTY_WRITE_CHUNK_BYTES = 16 * 1024;
67
+
68
+ /**
69
+ * Split `data` into chunks whose encoded UTF-8 byte length is no greater than
70
+ * `maxChunkBytes`, preferring a line boundary (`\n`) as the cut point so
71
+ * escape sequences (which never contain `\n`) stay intact. The TUI's
72
+ * full-paint buffers are line-structured (`buffer += "\r\n"` between rows),
73
+ * so a newline almost always exists within the window. The fallback for a
74
+ * buffer with no newline in range is a hard cut at the last UTF-8 code-point
75
+ * boundary that still fits — the ConPTY viewport bug from a single oversized
76
+ * write is strictly worse than a one-frame escape-sequence glitch on a
77
+ * buffer the renderer effectively never produces.
78
+ *
79
+ * UTF-16 code units are walked manually rather than measuring with
80
+ * `Buffer.byteLength` per slice candidate: each code unit's UTF-8 width is
81
+ * known from its value (BMP `<0x80` → 1, `<0x800` → 2, surrogate pair → 4
82
+ * bytes across two units, other BMP → 3), and surrogate pairs are kept
83
+ * together so the chunker never splits a non-BMP character.
84
+ *
85
+ * Exported for unit testing of the chunking contract; `#safeWrite` is the
86
+ * sole production caller.
87
+ */
88
+ export function chunkForConPTY(data: string, maxChunkBytes: number = MAX_CONPTY_WRITE_CHUNK_BYTES): string[] {
89
+ // Fast path: whole buffer fits in one write.
90
+ if (Buffer.byteLength(data, "utf8") <= maxChunkBytes) return [data];
91
+ const chunks: string[] = [];
92
+ const len = data.length;
93
+ let pos = 0;
94
+ while (pos < len) {
95
+ let bytes = 0;
96
+ // Index just past the most recent `\n` we've consumed inside [pos, i):
97
+ // the natural cut point that leaves escape sequences intact.
98
+ let lastNewlineEnd = -1;
99
+ let i = pos;
100
+ while (i < len) {
101
+ const cu = data.charCodeAt(i);
102
+ let cuLen = 1;
103
+ let cuBytes: number;
104
+ if (cu < 0x80) {
105
+ cuBytes = 1;
106
+ } else if (cu < 0x800) {
107
+ cuBytes = 2;
108
+ } else if (cu >= 0xd800 && cu < 0xdc00) {
109
+ // High surrogate: pair with the following low surrogate (4 bytes
110
+ // across two code units); an unpaired surrogate UTF-8-encodes as
111
+ // the 3-byte U+FFFD replacement character.
112
+ const next = i + 1 < len ? data.charCodeAt(i + 1) : 0;
113
+ if (next >= 0xdc00 && next < 0xe000) {
114
+ cuBytes = 4;
115
+ cuLen = 2;
116
+ } else {
117
+ cuBytes = 3;
118
+ }
119
+ } else {
120
+ // BMP non-surrogate or unpaired low surrogate → 3 bytes.
121
+ cuBytes = 3;
122
+ }
123
+ if (bytes + cuBytes > maxChunkBytes && i > pos) {
124
+ // Would overflow the cap. Cut at the last newline if we found one,
125
+ // otherwise hard-cut at the current code-point boundary.
126
+ const cut = lastNewlineEnd > pos ? lastNewlineEnd : i;
127
+ chunks.push(data.slice(pos, cut));
128
+ pos = cut;
129
+ break;
130
+ }
131
+ bytes += cuBytes;
132
+ i += cuLen;
133
+ if (cu === 0x0a) lastNewlineEnd = i;
134
+ }
135
+ if (i >= len) {
136
+ chunks.push(data.slice(pos));
137
+ pos = len;
138
+ }
139
+ }
140
+ return chunks;
141
+ }
142
+
143
+ /**
144
+ * Minimal terminal interface for TUI
145
+ */
146
+
147
+ // Track active terminal for emergency cleanup on crash
148
+ let activeTerminal: ProcessTerminal | null = null;
149
+ // Track if a terminal was ever started (for emergency restore logic)
150
+ let terminalEverStarted = false;
151
+ // Whether the alternate screen buffer is currently active (mirrors the TUI's
152
+ // overlay enter/leave writes). Consulted by emergencyTerminalRestore: DECRST
153
+ // 1049 must never be written blindly, because Windows' shared VT dispatcher
154
+ // (conhost and Windows Terminal both use AdaptDispatch) executes an
155
+ // unconditional cursor restore on it — with no prior DECSC save the cursor
156
+ // jumps to the viewport home, dropping the parent shell prompt on top of the
157
+ // dead frame after exit.
158
+ let altScreenActive = false;
159
+
160
+ /** Record alternate-screen state (called by the TUI on `?1049h`/`?1049l` writes). */
161
+ export function setAltScreenActive(active: boolean): void {
162
+ altScreenActive = active;
163
+ }
164
+
165
+ const stdoutErrorHandlers = new Set<(err: Error) => void>();
166
+ let stdoutErrorListenerInstalled = false;
167
+
168
+ function onStdoutError(err: Error): void {
169
+ for (const handler of stdoutErrorHandlers) handler(err);
170
+ }
171
+
172
+ function registerStdoutErrorHandler(handler: (err: Error) => void): () => void {
173
+ stdoutErrorHandlers.add(handler);
174
+ if (!stdoutErrorListenerInstalled) {
175
+ process.stdout.on("error", onStdoutError);
176
+ stdoutErrorListenerInstalled = true;
177
+ }
178
+ return () => {
179
+ stdoutErrorHandlers.delete(handler);
180
+ };
181
+ }
182
+
183
+ const STD_INPUT_HANDLE = -10;
184
+ const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200;
185
+ /** UTF-8 codepage id for SetConsoleCP/SetConsoleOutputCP. */
186
+ const CP_UTF8 = 65001;
187
+
188
+ /**
189
+ * Lazily-initialized closure re-asserting the UTF-8 console codepage, or
190
+ * `null` when unavailable (non-win32, FFI failure, console detached).
191
+ */
192
+ let consoleCodepageGuard: (() => void) | null | undefined;
193
+
194
+ /**
195
+ * Re-assert the UTF-8 console codepage before writing (win32 only).
196
+ *
197
+ * Bun sets both console codepages to UTF-8 (65001) at startup, and
198
+ * `process.stdout.write(string)` hands UTF-8 bytes to `WriteFile`, which
199
+ * conhost translates using the *current* console output codepage. Child
200
+ * processes spawned by tools (bash commands, MCP/LSP servers, eval kernels)
201
+ * share this console, and some flip the codepage behind our back: PHP >=7.1
202
+ * CLI issues the equivalent of `chcp` whenever `internal_encoding` mismatches
203
+ * the console codepage (php.net request #73716) and skips the restore when
204
+ * killed — and two PHP processes in a pipeline race their restores. Once the
205
+ * codepage falls back to an OEM page (437/850), every non-ASCII glyph the TUI
206
+ * paints is mis-translated: box-drawing borders degrade into `Γöé`/`ΓöÇ`
207
+ * mojibake on the next full repaint (most visibly ctrl+o expand, which
208
+ * rewrites every row).
209
+ *
210
+ * `GetConsoleOutputCP` is one cheap console call per `#safeWrite`; the setter
211
+ * only runs after a foreign flip. A reading of 0 means "no console" — leave
212
+ * that alone. Guarding the write chokepoint (rather than per-spawn cleanup)
213
+ * covers every console-sharing child and long-running processes that flip
214
+ * the codepage mid-session.
215
+ */
216
+ function ensureWindowsConsoleUtf8(): void {
217
+ if (consoleCodepageGuard === undefined) consoleCodepageGuard = createConsoleCodepageGuard();
218
+ consoleCodepageGuard?.();
219
+ }
220
+
221
+ let lastWarnedCodepage = 0;
222
+
223
+ function createConsoleCodepageGuard(): (() => void) | null {
224
+ if (process.platform !== "win32") return null;
225
+ try {
226
+ const kernel32 = dlopen("kernel32.dll", {
227
+ GetConsoleOutputCP: { args: [], returns: FFIType.u32 },
228
+ SetConsoleOutputCP: { args: [FFIType.u32], returns: FFIType.bool },
229
+ GetConsoleCP: { args: [], returns: FFIType.u32 },
230
+ SetConsoleCP: { args: [FFIType.u32], returns: FFIType.bool },
231
+ });
232
+ return () => {
233
+ try {
234
+ const outCp = kernel32.symbols.GetConsoleOutputCP();
235
+ if (outCp !== 0 && outCp !== CP_UTF8) {
236
+ kernel32.symbols.SetConsoleOutputCP(CP_UTF8);
237
+ if (outCp !== lastWarnedCodepage) {
238
+ lastWarnedCodepage = outCp;
239
+ logger.warn("console output codepage changed by a child process; restoring UTF-8", {
240
+ codepage: outCp,
241
+ });
242
+ }
243
+ }
244
+ const inCp = kernel32.symbols.GetConsoleCP();
245
+ if (inCp !== 0 && inCp !== CP_UTF8) {
246
+ kernel32.symbols.SetConsoleCP(CP_UTF8);
247
+ }
248
+ } catch {
249
+ // Console APIs failed (console detached mid-session); disable the guard.
250
+ consoleCodepageGuard = null;
251
+ }
252
+ };
253
+ } catch {
254
+ // bun:ffi unavailable; rendering proceeds without the guard.
255
+ return null;
256
+ }
257
+ }
258
+ /**
259
+ * Emergency terminal restore - call this from signal/crash handlers
260
+ * Resets terminal state without requiring access to the ProcessTerminal instance
261
+ */
262
+ export function emergencyTerminalRestore(): void {
263
+ try {
264
+ const terminal = activeTerminal;
265
+ if (terminal) {
266
+ terminal.stop();
267
+ // stop() never touches the alternate screen — the TUI owns that
268
+ // state and exits it on the normal shutdown path. Only crash paths
269
+ // with a fullscreen overlay still hold the alt buffer here. The
270
+ // leave sequence is gated on the tracked state because it is NOT a
271
+ // universally safe no-op: Windows' VT dispatcher homes the cursor
272
+ // on DECRST 1049 even when the alt buffer is inactive.
273
+ if (altScreenActive) {
274
+ terminal.write("\x1b[?1049l");
275
+ altScreenActive = false;
276
+ }
277
+ terminal.showCursor();
278
+ } else if (terminalEverStarted && !isTerminalHeadless()) {
279
+ // Blind restore only if we know a terminal was started but lost track of it
280
+ // This avoids writing escape sequences for non-TUI commands (grep, commit, etc.)
281
+ process.stdout.write(
282
+ "\x1b[?2026l" + // End synchronized output
283
+ "\x1b[?7h" + // Restore autowrap
284
+ "\x1b[?2004l" + // Disable bracketed paste
285
+ "\x1b[?2031l" + // Disable Mode 2031 appearance notifications
286
+ "\x1b[?2048l" + // Disable in-band resize notifications
287
+ "\x1b[?5522l" + // Disable enhanced paste notifications
288
+ "\x1b[<u" + // Pop kitty keyboard protocol
289
+ "\x1b[>4;0m" + // Disable modifyOtherKeys fallback
290
+ "\x1b[?1006l\x1b[?1003l\x1b[?1000l" + // Disable mouse tracking (fullscreen overlays)
291
+ // Leave the alternate screen only when a fullscreen overlay
292
+ // actually holds it — on Windows, DECRST 1049 on the main
293
+ // buffer homes the cursor (unconditional CursorRestoreState
294
+ // with no prior save), corrupting the shell handoff on exit.
295
+ (altScreenActive ? "\x1b[?1049l" : "") +
296
+ "\x1b[?25h", // Show cursor
297
+ );
298
+ altScreenActive = false;
299
+ if (process.stdin.setRawMode) {
300
+ process.stdin.setRawMode(false);
301
+ }
302
+ }
303
+ } catch {
304
+ // Terminal may already be dead during crash cleanup - ignore errors
305
+ }
306
+ }
307
+ /** Terminal-reported appearance (dark/light mode). */
308
+ export type TerminalAppearance = "dark" | "light";
309
+ export interface Terminal {
310
+ // Start the terminal with input and resize handlers
311
+ start(onInput: (data: string) => void, onResize: () => void): void;
312
+
313
+ // Stop the terminal and restore state
314
+ stop(): void;
315
+
316
+ /**
317
+ * Drain stdin before exiting to prevent Kitty key release events from
318
+ * leaking to the parent shell over slow SSH connections.
319
+ * @param maxMs - Maximum time to drain (default: 1000ms)
320
+ * @param idleMs - Exit early if no input arrives within this time (default: 50ms)
321
+ */
322
+ drainInput(maxMs?: number, idleMs?: number): Promise<void>;
323
+
324
+ // Write output to terminal
325
+ write(data: string): void;
326
+
327
+ // Get terminal dimensions
328
+ get columns(): number;
329
+ get rows(): number;
330
+
331
+ // Whether Kitty keyboard protocol is active
332
+ get kittyProtocolActive(): boolean;
333
+
334
+ // The exact kitty keyboard push sequence in effect ("\x1b[>1u" or "\x1b[>7u"),
335
+ // or null when the protocol is not active. Kitty keyboard flags are per-screen,
336
+ // so the TUI re-pushes this after entering the alternate screen.
337
+ get kittyEnableSequence(): string | null;
338
+
339
+ // The active modified-key reporting sequence to reassert on alternate-screen
340
+ // entry, or null when no enhanced keyboard mode is active. Optional so custom
341
+ // Terminals built against older pi-tui versions keep working.
342
+ readonly keyboardEnhancementEnterSequence?: string | null;
343
+
344
+ // The sequence that cleanly disables the active enhanced keyboard mode on
345
+ // alternate-screen exit, or null when no exit handshake is required. Optional
346
+ // so custom Terminals built against older pi-tui versions keep working.
347
+ readonly keyboardEnhancementExitSequence?: string | null;
348
+
349
+ // Cursor positioning (relative to current position)
350
+ moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines
351
+
352
+ // Cursor visibility
353
+ hideCursor(): void; // Hide the cursor
354
+ showCursor(): void; // Show the cursor
355
+
356
+ // Clear operations
357
+ clearLine(): void; // Clear current line
358
+ clearFromCursor(): void; // Clear from cursor to end of screen
359
+ clearScreen(): void; // Clear entire screen and move cursor to (0,0)
360
+
361
+ // Title operations
362
+ setTitle(title: string): void; // Set terminal window title
363
+
364
+ // Progress indicator (OSC 9;4)
365
+ setProgress(active: boolean): void;
366
+
367
+ /**
368
+ * Register a callback for terminal appearance (dark/light) changes.
369
+ * Detection uses OSC 11 background color query with Mode 2031 as a change trigger.
370
+ * Fires when the detected appearance changes, including the initial detection.
371
+ */
372
+ onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
373
+ /** The last detected terminal appearance, or undefined if not yet known. */
374
+ get appearance(): TerminalAppearance | undefined;
375
+ /**
376
+ * Register a callback fired once per DEC private mode when its DECRQM support
377
+ * status resolves. Optional: only real terminals implement capability probing.
378
+ */
379
+ onPrivateModeReport?(callback: (mode: number, supported: boolean) => void): void;
380
+ }
381
+
382
+ /**
383
+ * True when stdout flows through a ConPTY pseudo-console (native win32, or
384
+ * Linux running under WSL where stdout still crosses into ConPTY at the
385
+ * `wslhost` boundary). ConPTY hosts share the per-WriteFile viewport-tracking
386
+ * quirks documented above and on {@link MAX_CONPTY_WRITE_CHUNK_BYTES}, so both
387
+ * `#safeWrite` and the renderer's post-big-paint settle gate hang off this
388
+ * single predicate.
389
+ */
390
+ export function isConPTYHosted(): boolean {
391
+ if (process.platform === "win32") return true;
392
+ // WSL: stdout still crosses into ConPTY at the `wslhost` boundary.
393
+ return process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
394
+ }
395
+
396
+ /** Discriminated owner of an outstanding DA1 sentinel in the unified probe FIFO. */
397
+ type Da1SentinelOwner =
398
+ | { kind: "keyboard" }
399
+ | { kind: "osc11" }
400
+ | { kind: "privateMode"; mode: number }
401
+ | { kind: "osc99Probe"; id: string };
402
+
403
+ let nextOsc99ProbeId = 1;
404
+
405
+ function parseOsc99KeyValues(section: string): Map<string, string> {
406
+ const values = new Map<string, string>();
407
+ for (const part of section.split(":")) {
408
+ const eq = part.indexOf("=");
409
+ if (eq !== 1) continue;
410
+ values.set(part.slice(0, eq), part.slice(eq + 1));
411
+ }
412
+ return values;
413
+ }
414
+ const XTERM_SCROLL_TO_BOTTOM_MODES = [1010, 1011] as const;
415
+
416
+ function isXtermScrollToBottomMode(mode: number): boolean {
417
+ return mode === 1010 || mode === 1011;
418
+ }
419
+
420
+ function isPrivateModeSet(status: string): boolean {
421
+ return status === "1" || status === "3";
422
+ }
423
+
424
+ function isPrivateModeSupported(status: string): boolean {
425
+ return status !== "0" && status !== "4";
426
+ }
427
+
428
+ /**
429
+ * Real terminal using process.stdin/stdout
430
+ */
431
+ export class ProcessTerminal implements Terminal {
432
+ #wasRaw = false;
433
+ #inputHandler?: (data: string) => void;
434
+ #resizeHandler?: () => void;
435
+ #stdoutResizeListener?: () => void;
436
+ #kittyProtocolActive = false;
437
+ #kittyEnableSeq: string | null = null;
438
+ #modifyOtherKeysActive = false;
439
+ #modifyOtherKeysTimeout?: Timer;
440
+ #stdinBuffer?: StdinBuffer;
441
+ #stdinDataHandler?: (data: string) => void;
442
+ #dead = false;
443
+ // Captured at construction and re-read at start(): when true, every real
444
+ // terminal side effect (writes, probes, raw mode, SIGWINCH, timers) is
445
+ // suppressed. Defaults on under `bun test` — see isTerminalHeadless().
446
+ #headless = isTerminalHeadless();
447
+ #writeLogPath = $env.PI_TUI_WRITE_LOG || "";
448
+ #stdoutErrorCleanup?: () => void;
449
+ #stdoutErrorHandler = (err: Error) => {
450
+ this.#markTerminalWriteFailed(err);
451
+ };
452
+
453
+ #windowsVTInputRestore?: () => void;
454
+ #xtermScrollToBottomRestoreModes = new Set<number>();
455
+ #appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
456
+ #appearance: TerminalAppearance | undefined;
457
+ #osc11Pending = false;
458
+ #osc11QueryQueued = false;
459
+ #osc11ResponseBuffer = "";
460
+ #osc99PendingId: string | undefined;
461
+ #osc99ResponseBuffer = "";
462
+ #osc99Capabilities = new Map<string, string>();
463
+ #privateCsiResponseBuffer = "";
464
+ #da1SentinelOwners: Da1SentinelOwner[] = [];
465
+ /** Resolved DECRQM support per private mode (mode → supported). */
466
+ #privateModeSupport = new Map<number, boolean>();
467
+ #privateModeCallbacks: Array<(mode: number, supported: boolean) => void> = [];
468
+ /** Whether DEC 2048 in-band resize notifications are currently enabled. */
469
+ #inBandResizeActive = false;
470
+ /** Reassembly buffer for a DEC 2048 in-band resize report split across stdin reads. */
471
+ #inBandResizeBuffer = "";
472
+ #reportedColumns?: number;
473
+ #reportedRows?: number;
474
+ #mode2031DebounceTimer?: Timer;
475
+ #progressTimer?: Timer;
476
+
477
+ get kittyProtocolActive(): boolean {
478
+ return this.#kittyProtocolActive;
479
+ }
480
+
481
+ get kittyEnableSequence(): string | null {
482
+ return this.#kittyProtocolActive ? this.#kittyEnableSeq : null;
483
+ }
484
+
485
+ get keyboardEnhancementEnterSequence(): string | null {
486
+ if (this.#kittyProtocolActive) return this.#kittyEnableSeq;
487
+ return this.#modifyOtherKeysActive ? "\x1b[>4;2m" : null;
488
+ }
489
+
490
+ get keyboardEnhancementExitSequence(): string | null {
491
+ // kitty is a stack push (per-screen), so the matching pop balances alt-screen
492
+ // entry. xterm modifyOtherKeys is a single global flag with no per-screen
493
+ // stack — emitting `>4;0m` here would clear it on the normal screen too,
494
+ // breaking the composer between overlays. terminal.stop() still disables it
495
+ // globally on graceful exit; the emergency-restore path mirrors that.
496
+ return this.#kittyProtocolActive ? "\x1b[<u" : null;
497
+ }
498
+
499
+ get appearance(): TerminalAppearance | undefined {
500
+ return this.#appearance;
501
+ }
502
+
503
+ onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void {
504
+ this.#appearanceCallbacks.push(callback);
505
+ }
506
+
507
+ onPrivateModeReport(callback: (mode: number, supported: boolean) => void): void {
508
+ this.#privateModeCallbacks.push(callback);
509
+ }
510
+
511
+ start(onInput: (data: string) => void, onResize: () => void): void {
512
+ this.#inputHandler = onInput;
513
+ this.#resizeHandler = onResize;
514
+
515
+ // Headless (tests): suppress every real-terminal side effect. Skip raw
516
+ // mode, stdin listeners, capability probes, SIGWINCH, and emergency-restore
517
+ // ownership; #safeWrite is also a no-op, so frame paints and teardown
518
+ // escapes never reach the developer's terminal during `bun test`.
519
+ this.#headless = isTerminalHeadless();
520
+ if (this.#headless) return;
521
+
522
+ // Register for emergency cleanup
523
+ activeTerminal = this;
524
+ terminalEverStarted = true;
525
+
526
+ // Save previous state and enable raw mode
527
+ this.#wasRaw = process.stdin.isRaw || false;
528
+ if (process.stdin.setRawMode) {
529
+ process.stdin.setRawMode(true);
530
+ }
531
+ process.stdin.setEncoding("utf8");
532
+ process.stdin.resume();
533
+
534
+ // Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
535
+ this.#safeWrite("\x1b[?2004h");
536
+
537
+ // Set up resize handler immediately. The OS refreshes process.stdout
538
+ // dimensions before firing `resize`, so it is authoritative for geometry:
539
+ // reconcile any stale cached DEC 2048 report before notifying the renderer.
540
+ this.#stdoutResizeListener = () => {
541
+ this.#reconcileInBandGeometryOnResize();
542
+ this.#resizeHandler?.();
543
+ };
544
+ process.stdout.on("resize", this.#stdoutResizeListener);
545
+
546
+ // Refresh terminal dimensions - they may be stale after suspend/resume
547
+ // (SIGWINCH is lost while process is stopped). Unix only.
548
+ if (process.platform !== "win32") {
549
+ process.kill(process.pid, "SIGWINCH");
550
+ }
551
+
552
+ // On Windows, enable ENABLE_VIRTUAL_TERMINAL_INPUT so the console sends
553
+ // VT escape sequences (e.g. \x1b[Z for Shift+Tab) instead of raw console
554
+ // events that lose modifier information. Must run after setRawMode(true)
555
+ // since that resets console mode flags.
556
+ this.#enableWindowsVTInput();
557
+ // Query and enable Kitty keyboard protocol
558
+ // The query handler intercepts input temporarily, then installs the user's handler
559
+ // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
560
+ this.#queryAndEnableKittyProtocol();
561
+ setHangulCompatibilityJamoWidth(resolveHangulCompatibilityJamoWidthFromTerminalIdentity());
562
+
563
+ // Query terminal background color via OSC 11 for dark/light detection.
564
+ // Uses DA1 (Primary Device Attributes) as a sentinel: terminals process
565
+ // sequences in order, so if DA1 arrives before OSC 11 response,
566
+ // the terminal does not support OSC 11. This avoids indefinite hangs.
567
+ // Technique used by Neovim, bat, fish, and terminal-colorsaurus.
568
+ this.#queryBackgroundColor();
569
+
570
+ // Query OSC 99 notification capabilities for Kitty. The query uses the
571
+ // same DA1 sentinel FIFO as OSC 11/DECRQM so unsupported terminals resolve
572
+ // without leaking probe bytes to application input.
573
+ this.#queryOsc99Support();
574
+
575
+ // Subscribe to Mode 2031 appearance change notifications.
576
+ // When the terminal reports a change, we re-query OSC 11 to get the
577
+ // actual background color (following Neovim convention) with 100ms debounce.
578
+ this.#safeWrite("\x1b[?2031h");
579
+
580
+ // Theme detection relies on (1) the startup OSC 11 probe above and
581
+ // (2) DEC Mode 2031 push notifications. Terminals without Mode 2031
582
+ // (macOS Terminal.app, Warp, VS Code's built-in, older Alacritty/
583
+ // WezTerm) detect the appearance once at startup and pick up later OS
584
+ // theme changes on next launch. Earlier builds polled OSC 11 every 30 s
585
+ // here for those terminals, but each poll's OSC 11/DA1 write wiped the
586
+ // user's active text selection on several of them (#3297).
587
+
588
+ // Probe DEC private-mode support via DECRQM. 2026 (synchronized output)
589
+ // gates the renderer's begin/end markers; 2048 (in-band resize) is enabled
590
+ // only after the terminal confirms support; 2031 (appearance change
591
+ // notifications) drives mid-session theme tracking. Xterm ?1010/?1011
592
+ // are disabled while OMP owns the TTY so typing in the editor does not
593
+ // force a reader scrolled into native history back to the tail. Each probe
594
+ // rides the shared DA1 sentinel, so terminals that ignore DECRQM resolve as
595
+ // unsupported when the DA1 reply arrives.
596
+ this.#queryPrivateMode(2026);
597
+ this.#queryPrivateMode(2048);
598
+ this.#queryPrivateMode(2031);
599
+ for (const mode of XTERM_SCROLL_TO_BOTTOM_MODES) {
600
+ this.#queryPrivateMode(mode);
601
+ }
602
+ }
603
+
604
+ /**
605
+ * On Windows, add ENABLE_VIRTUAL_TERMINAL_INPUT to the stdin console mode
606
+ * so modified keys (for example Shift+Tab) arrive as VT escape sequences.
607
+ */
608
+ #enableWindowsVTInput(): void {
609
+ if (process.platform !== "win32") return;
610
+ this.#restoreWindowsVTInput();
611
+ try {
612
+ const kernel32 = dlopen("kernel32.dll", {
613
+ GetStdHandle: { args: [FFIType.i32], returns: FFIType.ptr },
614
+ GetConsoleMode: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.bool },
615
+ SetConsoleMode: { args: [FFIType.ptr, FFIType.u32], returns: FFIType.bool },
616
+ });
617
+ const handle = kernel32.symbols.GetStdHandle(STD_INPUT_HANDLE);
618
+ const mode = new Uint32Array(1);
619
+ const modePtr = ptr(mode);
620
+ if (!modePtr || !kernel32.symbols.GetConsoleMode(handle, modePtr)) {
621
+ kernel32.close();
622
+ return;
623
+ }
624
+ const originalMode = mode[0]!;
625
+ const vtMode = originalMode | ENABLE_VIRTUAL_TERMINAL_INPUT;
626
+ if (vtMode !== originalMode && !kernel32.symbols.SetConsoleMode(handle, vtMode)) {
627
+ kernel32.close();
628
+ return;
629
+ }
630
+ this.#windowsVTInputRestore = () => {
631
+ try {
632
+ kernel32.symbols.SetConsoleMode(handle, originalMode);
633
+ } finally {
634
+ kernel32.close();
635
+ }
636
+ };
637
+ } catch {
638
+ // bun:ffi unavailable or console API unsupported; keep startup non-fatal.
639
+ }
640
+ }
641
+
642
+ #restoreWindowsVTInput(): void {
643
+ if (process.platform !== "win32") return;
644
+ const restore = this.#windowsVTInputRestore;
645
+ this.#windowsVTInputRestore = undefined;
646
+ if (!restore) return;
647
+ try {
648
+ restore();
649
+ } catch {
650
+ // Ignore restore errors during terminal teardown.
651
+ }
652
+ }
653
+
654
+ /**
655
+ * Set up StdinBuffer to split batched input into individual sequences.
656
+ * This ensures components receive single events, making matchesKey/isKeyRelease work correctly.
657
+ *
658
+ * Also watches for Kitty protocol response and enables it when detected.
659
+ * This is done here (after stdinBuffer parsing) rather than on raw stdin
660
+ * to handle the case where the response arrives split across multiple events.
661
+ */
662
+ #setupStdinBuffer(): void {
663
+ // 50ms balances two failure modes: a bare ESC keypress on legacy
664
+ // terminals waits this long before it is delivered, while a CSI key
665
+ // escape split across stdin reads (laggy ssh/tmux links) leaks as
666
+ // literal typed text if the flush fires between the fragments. 10ms
667
+ // proved too tight for split escapes (#1238 covered only probe replies).
668
+ this.#stdinBuffer = new StdinBuffer({ timeout: 50 });
669
+
670
+ // Kitty protocol response pattern: \x1b[?<flags>u
671
+ const kittyResponsePattern = /^\x1b\[\?(\d+)u$/;
672
+
673
+ // Mode 2031 DSR response: \x1b[?997;{1=dark,2=light}n
674
+ const appearanceDsrPattern = /^\x1b\[\?997;([12])n$/;
675
+
676
+ // OSC 11 response: \x1b]11;rgb:RR/GG/BB or rgba:RR/GG/BB, terminated by BEL or ST.
677
+ const osc11ResponsePattern =
678
+ /^\x1b\]11;rgba?:([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})(?:\x07|\x1b\\)$/;
679
+
680
+ // DA1 (Primary Device Attributes) response: \x1b[?...c
681
+ const da1ResponsePattern = /^\x1b\[\?[\d;]*c$/;
682
+
683
+ // Private CSI partial: \x1b[?<digits/semicolons>... — incomplete probe response
684
+ // that the StdinBuffer flushed before the terminator arrived (split across
685
+ // stdin reads). Used to reassemble DA1, kitty, and Mode 2031 replies.
686
+ const privateCsiPartialPattern = /^\x1b\[\?[\d;]*[\x20-\x2f]*$/;
687
+
688
+ // DECRPM private-mode report (DECRQM reply): \x1b[?<mode>;<status>$y
689
+ const decrpmResponsePattern = /^\x1b\[\?(\d+);(\d+)\$y$/;
690
+
691
+ // In-band resize report (DEC mode 2048): \x1b[48;rows;cols;yPixels;xPixels t
692
+ const inBandResizePattern = /^\x1b\[48;(\d+);(\d+);(\d+);(\d+)t$/;
693
+
694
+ this.#stdinBuffer.on("data", (sequence: string) => {
695
+ // Fast path for plain-text bytes: every escape-probe regex below
696
+ // anchors on `^\x1b…`, so a byte that is not ESC can never match. A
697
+ // non-bracketed paste of N printable chars arrives as N per-scalar
698
+ // `data` events; running the full probe suite per event turns a
699
+ // 100 KB paste into ~600K regex executions and blocks the event
700
+ // loop. Skip straight to the input handler when no reassembly
701
+ // buffer is holding state that a non-ESC continuation could feed
702
+ // (issue #4073 case C).
703
+ if (
704
+ (sequence.length === 0 || sequence.charCodeAt(0) !== 0x1b) &&
705
+ this.#privateCsiResponseBuffer.length === 0 &&
706
+ this.#inBandResizeBuffer.length === 0 &&
707
+ this.#osc11ResponseBuffer.length === 0 &&
708
+ this.#osc99ResponseBuffer.length === 0
709
+ ) {
710
+ if (this.#inputHandler) {
711
+ this.#inputHandler(sequence);
712
+ }
713
+ return;
714
+ }
715
+
716
+ // Reassemble split private CSI responses (DA1, kitty keyboard, Mode 2031).
717
+ // When the terminal writes the response slowly enough that the StdinBuffer's
718
+ // flush timeout elapses mid-sequence, the prefix `\x1b[?<digits>` arrives as
719
+ // one event and the tail `;...<terminator>` arrives as individual character
720
+ // events that would otherwise leak into the prompt as keystrokes. See #1238.
721
+ if (
722
+ this.#privateCsiResponseBuffer ||
723
+ (privateCsiPartialPattern.test(sequence) && this.#da1SentinelOwners.length > 0)
724
+ ) {
725
+ if (this.#privateCsiResponseBuffer && sequence.startsWith("\x1b")) {
726
+ // New escape arrived mid-reassembly — abandon partial and re-process the new sequence.
727
+ this.#privateCsiResponseBuffer = "";
728
+ } else {
729
+ this.#privateCsiResponseBuffer += sequence;
730
+ // Cap accumulator to defend against runaway partials if the terminator never arrives.
731
+ if (this.#privateCsiResponseBuffer.length > 256) {
732
+ this.#privateCsiResponseBuffer = "";
733
+ return;
734
+ }
735
+ const lastChar = this.#privateCsiResponseBuffer.at(-1)!;
736
+ const lastCode = lastChar.charCodeAt(0);
737
+ if (lastCode >= 0x40 && lastCode <= 0x7e) {
738
+ // Terminator byte arrived. Fall through to the pattern checks with the
739
+ // reassembled sequence so the existing DA1/kitty/Mode 2031 handlers run.
740
+ sequence = this.#privateCsiResponseBuffer;
741
+ this.#privateCsiResponseBuffer = "";
742
+ } else if (!privateCsiPartialPattern.test(this.#privateCsiResponseBuffer)) {
743
+ // Diverged from a valid private CSI prefix (unexpected byte). Drop the
744
+ // probe noise we ate; do not forward to the input handler.
745
+ this.#privateCsiResponseBuffer = "";
746
+ return;
747
+ } else {
748
+ // Still accumulating.
749
+ return;
750
+ }
751
+ }
752
+ }
753
+
754
+ // In-band resize report (DEC 2048) split across stdin reads. The report
755
+ // is `\x1b[48;rows;cols;yPx;xPx t`; when the StdinBuffer flush timeout
756
+ // elapses mid-sequence — common during a rapid resize that keeps the
757
+ // event loop busy — the `\x1b[48;…` prefix arrives as one event and the
758
+ // tail (`…;xPx t`) arrives as bare character events that would otherwise
759
+ // leak into the prompt as literal keystrokes. Reassemble until the
760
+ // terminator, then fall through to the resize handler below. A
761
+ // reassembled sequence that turns out not to be a resize report (e.g. a
762
+ // split kitty `\x1b[48;…u` for a digit key) is forwarded to the input
763
+ // handler rather than dropped.
764
+ const inBandResizePartialPattern = /^\x1b\[4[\d;]*$/;
765
+ const isInBandResizePartial = this.#inBandResizeActive && inBandResizePartialPattern.test(sequence);
766
+ if (this.#inBandResizeBuffer && sequence.startsWith("\x1b")) {
767
+ // A new escape interrupted the partial; the stale partial is
768
+ // unrecoverable. If the new escape is itself an in-band prefix,
769
+ // restart reassembly with it; otherwise let it flow through below.
770
+ this.#inBandResizeBuffer = isInBandResizePartial ? sequence : "";
771
+ if (isInBandResizePartial) return;
772
+ } else if (this.#inBandResizeBuffer || isInBandResizePartial) {
773
+ this.#inBandResizeBuffer += sequence;
774
+ if (this.#inBandResizeBuffer.length > 256) {
775
+ this.#inBandResizeBuffer = "";
776
+ return;
777
+ }
778
+ const lastCode = this.#inBandResizeBuffer.charCodeAt(this.#inBandResizeBuffer.length - 1);
779
+ if (lastCode >= 0x40 && lastCode <= 0x7e) {
780
+ // Terminator arrived: let the resize handler below claim it, or
781
+ // fall through to the input handler if it is not a resize report.
782
+ sequence = this.#inBandResizeBuffer;
783
+ this.#inBandResizeBuffer = "";
784
+ } else if (!inBandResizePartialPattern.test(this.#inBandResizeBuffer)) {
785
+ // Diverged from a valid in-band prefix — drop the garbled report.
786
+ this.#inBandResizeBuffer = "";
787
+ return;
788
+ } else {
789
+ // Still accumulating the report.
790
+ return;
791
+ }
792
+ }
793
+
794
+ // In-band resize report (DEC mode 2048). Unsolicited and not tied to a
795
+ // sentinel: update reported geometry + cell size, then drive the resize
796
+ // handler so the renderer reflows.
797
+ const resizeMatch = sequence.match(inBandResizePattern);
798
+ if (resizeMatch) {
799
+ this.#handleInBandResizeReport(resizeMatch[1]!, resizeMatch[2]!, resizeMatch[3]!, resizeMatch[4]!);
800
+ return;
801
+ }
802
+
803
+ // DECRPM private-mode report. Resolves the matching probe by mode; the
804
+ // owner stays in the FIFO and is drained by its DA1 sentinel (a no-op
805
+ // once resolved). Per DECRPM, status 0 = unrecognized, 1/2 =
806
+ // set/reset, 3 = permanently set, and 4 = permanently reset.
807
+ const decrpmMatch = sequence.match(decrpmResponsePattern);
808
+ if (decrpmMatch) {
809
+ this.#handlePrivateModeReport(parseInt(decrpmMatch[1]!, 10), decrpmMatch[2]!);
810
+ return;
811
+ }
812
+
813
+ // DA1 response: swallow our sentinel reply regardless of whether an
814
+ // earlier capability-specific response already succeeded. Other terminal
815
+ // probes should never see these replies.
816
+ if (da1ResponsePattern.test(sequence) && this.#da1SentinelOwners.length > 0) {
817
+ const owner = this.#da1SentinelOwners.shift()!;
818
+ switch (owner.kind) {
819
+ case "osc11": {
820
+ if (this.#osc11Pending) {
821
+ // DA1 arrived before the OSC 11 reply: terminal does not support OSC 11.
822
+ this.#osc11Pending = false;
823
+ this.#osc11ResponseBuffer = "";
824
+ }
825
+ // Start a queued OSC 11 query once the prior cycle is fully drained.
826
+ if (
827
+ this.#osc11QueryQueued &&
828
+ !this.#osc11Pending &&
829
+ !this.#da1SentinelOwners.some(o => o.kind === "osc11") &&
830
+ !this.#dead
831
+ ) {
832
+ this.#osc11QueryQueued = false;
833
+ this.#startOsc11Query();
834
+ }
835
+ break;
836
+ }
837
+ case "privateMode": {
838
+ // DA1 beat the DECRPM reply for this mode → treat as unsupported.
839
+ this.#resolvePrivateMode(owner.mode, false);
840
+ break;
841
+ }
842
+ case "keyboard": {
843
+ // Keyboard probe sentinel: kitty reply never arrived → fall back to modifyOtherKeys.
844
+ if (!this.#kittyProtocolActive && !this.#modifyOtherKeysActive && this.#modifyOtherKeysTimeout) {
845
+ clearTimeout(this.#modifyOtherKeysTimeout);
846
+ this.#modifyOtherKeysTimeout = undefined;
847
+ this.#safeWrite("\x1b[>4;2m");
848
+ this.#modifyOtherKeysActive = true;
849
+ }
850
+ break;
851
+ }
852
+ case "osc99Probe": {
853
+ this.#resolveOsc99Support(owner.id, false);
854
+ break;
855
+ }
856
+ }
857
+ return;
858
+ }
859
+
860
+ const match = sequence.match(kittyResponsePattern);
861
+ if (match) {
862
+ if (this.#modifyOtherKeysTimeout) {
863
+ clearTimeout(this.#modifyOtherKeysTimeout);
864
+ this.#modifyOtherKeysTimeout = undefined;
865
+ }
866
+ // A DA1 sentinel that beat the kitty reply may have already
867
+ // engaged the modifyOtherKeys fallback (terminals such as
868
+ // Superset/xterm-on-Electron answer DA1 before `\x1b[?u`).
869
+ // Kitty is strictly preferred — undo the fallback so the two
870
+ // modes do not stack. See #2042.
871
+ if (this.#modifyOtherKeysActive) {
872
+ this.#safeWrite("\x1b[>4;0m");
873
+ this.#modifyOtherKeysActive = false;
874
+ }
875
+ // Any reply to `\x1b[?u` means the terminal speaks the kitty keyboard
876
+ // protocol. The reported flag value is the *current* stack-top — fresh
877
+ // terminals report 0 — so support is implied by the reply itself, not by
878
+ // the flag value. Pick the level we want; `\x1b[>Nu` pushes one frame
879
+ // that shutdown's single `\x1b[<u` pop balances.
880
+ const reportedFlags = parseInt(match[1]!, 10);
881
+ this.#kittyProtocolActive = true;
882
+ setKittyProtocolActive(true);
883
+ if (reportedFlags >= 3) {
884
+ // Already enriched (Ghostty/foot may keep flags from a parent app).
885
+ // Push level-2 to lock in event reporting.
886
+ this.#kittyEnableSeq = "\x1b[>7u";
887
+ this.#safeWrite(this.#kittyEnableSeq);
888
+ } else {
889
+ // Level 1 (disambiguate escape codes) — enough for Shift+Enter
890
+ // without the modifyOtherKeys fallback that caused regression #3259.
891
+ this.#kittyEnableSeq = "\x1b[>1u";
892
+ this.#safeWrite(this.#kittyEnableSeq);
893
+ }
894
+ return;
895
+ }
896
+
897
+ // OSC 11 replies can be split if the stdin buffer flushes a partial sequence.
898
+ // Accumulate fragments until the BEL/ST terminator arrives, then parse once.
899
+ // If a new escape sequence arrives (not the ST terminator), abort buffering
900
+ // and forward it as normal input so user keystrokes are never swallowed.
901
+ if (this.#osc11Pending && (this.#osc11ResponseBuffer || sequence.startsWith("\x1b]11;"))) {
902
+ if (this.#osc11ResponseBuffer && sequence.startsWith("\x1b") && sequence !== "\x1b\\") {
903
+ // New escape sequence arrived mid-buffer — not an OSC 11 continuation.
904
+ this.#osc11ResponseBuffer = "";
905
+ // Fall through to normal input handling below.
906
+ } else {
907
+ this.#osc11ResponseBuffer += sequence;
908
+ const osc11Match = this.#osc11ResponseBuffer.match(osc11ResponsePattern);
909
+ if (!osc11Match) return;
910
+ const [, rHex, gHex, bHex] = osc11Match;
911
+ this.#osc11Pending = false;
912
+ this.#osc11ResponseBuffer = "";
913
+ this.#handleOsc11Response(rHex!, gHex!, bHex!);
914
+ return;
915
+ }
916
+ }
917
+
918
+ if (this.#osc99PendingId && (this.#osc99ResponseBuffer || sequence.startsWith("\x1b]99;"))) {
919
+ if (this.#osc99ResponseBuffer && sequence.startsWith("\x1b") && sequence !== "\x1b\\") {
920
+ this.#osc99ResponseBuffer = "";
921
+ } else {
922
+ this.#osc99ResponseBuffer += sequence;
923
+ const osc99Match = this.#osc99ResponseBuffer.match(/^\x1b\]99;([^;]*);([\s\S]*?)(?:\x07|\x1b\\)$/u);
924
+ if (!osc99Match) return;
925
+ const [, meta, payload] = osc99Match;
926
+ this.#osc99ResponseBuffer = "";
927
+ this.#handleOsc99CapabilityResponse(meta!, payload!);
928
+ return;
929
+ }
930
+ }
931
+
932
+ // Mode 2031 change notification: re-query OSC 11 with 100ms debounce
933
+ // (Neovim convention — coalesces rapid notifications during transitions)
934
+ const appearanceMatch = sequence.match(appearanceDsrPattern);
935
+ if (appearanceMatch) {
936
+ if (this.#mode2031DebounceTimer) clearTimeout(this.#mode2031DebounceTimer);
937
+ this.#mode2031DebounceTimer = setTimeout(() => {
938
+ this.#mode2031DebounceTimer = undefined;
939
+ this.#queryBackgroundColor();
940
+ }, 100);
941
+ return;
942
+ }
943
+ if (this.#inputHandler) {
944
+ this.#inputHandler(sequence);
945
+ }
946
+ });
947
+
948
+ // Re-wrap paste content with bracketed paste markers for existing editor handling
949
+ this.#stdinBuffer.on("paste", (content: string) => {
950
+ if (this.#inputHandler) {
951
+ this.#inputHandler(`\x1b[200~${content}\x1b[201~`);
952
+ }
953
+ });
954
+
955
+ // Handler that pipes stdin data through the buffer
956
+ this.#stdinDataHandler = (data: string) => {
957
+ this.#stdinBuffer!.process(data);
958
+ };
959
+ }
960
+
961
+ /**
962
+ * Send OSC 11 background color query followed by DA1 sentinel.
963
+ * DA1 avoids indefinite hangs: if DA1 response arrives before OSC 11,
964
+ * the terminal does not support OSC 11.
965
+ */
966
+ #queryBackgroundColor(): void {
967
+ if (this.#dead) return;
968
+ // Queue if an OSC 11 query is in flight or its DA1 sentinel hasn't been
969
+ // consumed yet. Starting a new query while a DA1 is outstanding would
970
+ // increment the sentinel counter, and the old DA1 arrival would then
971
+ // prematurely clear the new query's pending state.
972
+ if (this.#osc11Pending || this.#da1SentinelOwners.some(o => o.kind === "osc11")) {
973
+ this.#osc11QueryQueued = true;
974
+ return;
975
+ }
976
+ this.#startOsc11Query();
977
+ }
978
+
979
+ #startOsc11Query(): void {
980
+ this.#osc11Pending = true;
981
+ this.#osc11ResponseBuffer = "";
982
+ this.#da1SentinelOwners.push({ kind: "osc11" });
983
+ this.#safeWrite("\x1b]11;?\x07"); // OSC 11 query (BEL terminated)
984
+ this.#safeWrite("\x1b[c"); // DA1 sentinel
985
+ }
986
+
987
+ #shouldQueryOsc99Support(): boolean {
988
+ if (TERMINAL.notifyProtocol !== NotifyProtocol.Osc99) return false;
989
+ return !isBunTestRuntime() || $env.PI_TUI_OSC99_PROBE === "1";
990
+ }
991
+
992
+ #queryOsc99Support(): void {
993
+ setOsc99Supported(false);
994
+ this.#osc99Capabilities.clear();
995
+ this.#osc99PendingId = undefined;
996
+ this.#osc99ResponseBuffer = "";
997
+ if (this.#dead || !this.#shouldQueryOsc99Support()) return;
998
+
999
+ const id = `omp-probe-${nextOsc99ProbeId++}`;
1000
+ this.#osc99PendingId = id;
1001
+ this.#da1SentinelOwners.push({ kind: "osc99Probe", id });
1002
+ // Wrap the probe under tmux so terminals behind `allow-passthrough on`
1003
+ // can still respond (mirroring how `TerminalInfo.sendNotification`
1004
+ // wraps notification deliveries). Without it the probe is swallowed
1005
+ // inside tmux even when the outer terminal speaks OSC 99, and rich
1006
+ // notifications stay permanently downgraded to the single-line fallback.
1007
+ const probe = `\x1b]99;i=${id}:p=?;\x1b\\`;
1008
+ const sequence = isInsideTmux() ? wrapTmuxPassthrough(probe) : probe;
1009
+ this.#safeWrite(`${sequence}\x1b[c`);
1010
+ }
1011
+
1012
+ #handleOsc99CapabilityResponse(metaRaw: string, payload: string): boolean {
1013
+ const pendingId = this.#osc99PendingId;
1014
+ if (!pendingId) return false;
1015
+ const meta = parseOsc99KeyValues(metaRaw);
1016
+ if (meta.get("i") !== pendingId || meta.get("p") !== "?") return false;
1017
+
1018
+ const capabilities = parseOsc99KeyValues(payload);
1019
+ this.#osc99Capabilities = capabilities;
1020
+ const payloadTypes = capabilities.get("p")?.split(",") ?? [];
1021
+ this.#resolveOsc99Support(pendingId, payloadTypes.includes("title"));
1022
+ return true;
1023
+ }
1024
+
1025
+ #resolveOsc99Support(id: string, supported: boolean): void {
1026
+ if (this.#osc99PendingId !== id) return;
1027
+ this.#osc99PendingId = undefined;
1028
+ this.#osc99ResponseBuffer = "";
1029
+ if (!supported) this.#osc99Capabilities.clear();
1030
+ setOsc99Supported(supported);
1031
+ }
1032
+
1033
+ /**
1034
+ * Parse an OSC 11 background color response and compute BT.601 luminance.
1035
+ * Handles 1-, 2-, 3-, and 4-digit XParseColor hex components.
1036
+ */
1037
+ #handleOsc11Response(rHex: string, gHex: string, bHex: string): void {
1038
+ const normalize = (hex: string): number => {
1039
+ const value = parseInt(hex, 16);
1040
+ if (Number.isNaN(value)) return 0;
1041
+ const max = 16 ** hex.length - 1;
1042
+ return max > 0 ? value / max : 0;
1043
+ };
1044
+ const luminance = 0.299 * normalize(rHex) + 0.587 * normalize(gHex) + 0.114 * normalize(bHex);
1045
+ const mode: TerminalAppearance = luminance < 0.5 ? "dark" : "light";
1046
+ if (mode === this.#appearance) return;
1047
+ this.#appearance = mode;
1048
+ for (const cb of this.#appearanceCallbacks) {
1049
+ try {
1050
+ cb(mode);
1051
+ } catch {
1052
+ /* ignore callback errors */
1053
+ }
1054
+ }
1055
+ }
1056
+
1057
+ /**
1058
+ * Query terminal for Kitty keyboard protocol support and enable if available.
1059
+ *
1060
+ * Sends CSI ? u to query current flags. If terminal responds with CSI ? <flags> u,
1061
+ * it supports the protocol and we enable it with CSI > 1 u.
1062
+ *
1063
+ * The response is detected in setupStdinBuffer's data handler, which properly
1064
+ * handles the case where the response arrives split across multiple stdin events.
1065
+ */
1066
+ #queryAndEnableKittyProtocol(): void {
1067
+ this.#setupStdinBuffer();
1068
+ process.stdin.on("data", this.#stdinDataHandler!);
1069
+ // Progressive enhancement query: CSI ?u asks the terminal for its current
1070
+ // kitty keyboard flags (no side effect on the stack); the DA1 sentinel
1071
+ // guarantees a reply even from terminals that ignore CSI ?u.
1072
+ this.#da1SentinelOwners.push({ kind: "keyboard" });
1073
+ this.#safeWrite("\x1b[?u\x1b[c");
1074
+ this.#modifyOtherKeysTimeout = setTimeout(() => {
1075
+ this.#modifyOtherKeysTimeout = undefined;
1076
+ if (this.#kittyProtocolActive || this.#modifyOtherKeysActive) {
1077
+ return;
1078
+ }
1079
+ this.#safeWrite("\x1b[>4;2m");
1080
+ this.#modifyOtherKeysActive = true;
1081
+ }, 150);
1082
+ }
1083
+
1084
+ /**
1085
+ * Probe a DEC private mode via DECRQM (`CSI ? mode $ p`) plus a DA1 sentinel.
1086
+ * The sentinel guarantees resolution even from terminals that ignore DECRQM.
1087
+ * Query and sentinel are fused into one write so the bare-`CSI c` sentinel
1088
+ * accounting used elsewhere stays accurate.
1089
+ */
1090
+ #queryPrivateMode(mode: number): void {
1091
+ if (this.#dead) return;
1092
+ if (this.#privateModeSupport.has(mode)) return;
1093
+ this.#da1SentinelOwners.push({ kind: "privateMode", mode });
1094
+ this.#safeWrite(`\x1b[?${mode}$p\x1b[c`);
1095
+ }
1096
+
1097
+ #handlePrivateModeReport(mode: number, status: string): void {
1098
+ this.#resolvePrivateMode(mode, isPrivateModeSupported(status));
1099
+ if (isXtermScrollToBottomMode(mode) && isPrivateModeSet(status)) {
1100
+ this.#disableXtermScrollToBottomMode(mode);
1101
+ }
1102
+ }
1103
+
1104
+ /**
1105
+ * Record DECRQM support for a private mode (idempotent — first result wins)
1106
+ * and notify subscribers. Enables DEC 2048 in-band resize when 2048 resolves
1107
+ * supported.
1108
+ */
1109
+ #resolvePrivateMode(mode: number, supported: boolean): void {
1110
+ if (this.#privateModeSupport.has(mode)) return;
1111
+ this.#privateModeSupport.set(mode, supported);
1112
+ for (const cb of this.#privateModeCallbacks) {
1113
+ try {
1114
+ cb(mode, supported);
1115
+ } catch {
1116
+ // Ignore subscriber errors — capability reporting must not crash input.
1117
+ }
1118
+ }
1119
+ if (mode === 2048 && supported) this.#enableInBandResize();
1120
+ }
1121
+
1122
+ #disableXtermScrollToBottomMode(mode: number): void {
1123
+ if (this.#xtermScrollToBottomRestoreModes.has(mode) || this.#dead) return;
1124
+ this.#xtermScrollToBottomRestoreModes.add(mode);
1125
+ this.#safeWrite(`\x1b[?${mode}l`);
1126
+ }
1127
+
1128
+ /**
1129
+ * Enable DEC 2048 in-band resize notifications. The terminal emits an initial
1130
+ * report immediately, seeding reported geometry and cell dimensions.
1131
+ */
1132
+ #enableInBandResize(): void {
1133
+ if (this.#inBandResizeActive || this.#dead) return;
1134
+ this.#inBandResizeActive = true;
1135
+ this.#safeWrite("\x1b[?2048h");
1136
+ }
1137
+
1138
+ /**
1139
+ * Apply an in-band resize report. Stores reported geometry so `rows`/`columns`
1140
+ * reflect in-band values, derives cell pixel size, and drives the resize
1141
+ * handler only when the report changes the effective row/column geometry.
1142
+ */
1143
+ #handleInBandResizeReport(rowsRaw: string, colsRaw: string, yPixelsRaw: string, xPixelsRaw: string): void {
1144
+ const previousRows = this.rows;
1145
+ const previousColumns = this.columns;
1146
+ const rows = parseInt(rowsRaw, 10);
1147
+ const cols = parseInt(colsRaw, 10);
1148
+ const yPixels = parseInt(yPixelsRaw, 10);
1149
+ const xPixels = parseInt(xPixelsRaw, 10);
1150
+ if (rows > 0) this.#reportedRows = rows;
1151
+ if (cols > 0) this.#reportedColumns = cols;
1152
+ if (cols > 0 && xPixels > 0 && rows > 0 && yPixels > 0) {
1153
+ setCellDimensions({
1154
+ widthPx: Math.max(1, Math.round(xPixels / cols)),
1155
+ heightPx: Math.max(1, Math.round(yPixels / rows)),
1156
+ });
1157
+ }
1158
+ if (rows > 0 && cols > 0 && (rows !== previousRows || cols !== previousColumns)) {
1159
+ this.#resizeHandler?.();
1160
+ }
1161
+ }
1162
+
1163
+ /**
1164
+ * Reconcile cached in-band geometry with the OS on an OS-level resize.
1165
+ *
1166
+ * SIGWINCH (POSIX) and ConPTY (Windows) refresh `process.stdout.columns`/
1167
+ * `rows` before the `resize` event fires, so they are authoritative for the
1168
+ * new cell geometry. A cached DEC 2048 report can be stale: the matching
1169
+ * post-resize report may be dropped (split across stdin reads past the flush
1170
+ * window) or carry `:`-subparameters the parser skips, leaving the getters
1171
+ * pinned to the old size — which freezes the rendered width because the
1172
+ * renderer reflows against {@link columns}/{@link rows}, not the live OS
1173
+ * value. Drop a cached dimension that disagrees with the live OS value; the
1174
+ * terminal's next valid in-band report re-seeds pixel sizing.
1175
+ */
1176
+ #reconcileInBandGeometryOnResize(): void {
1177
+ if (!this.#inBandResizeActive) return;
1178
+ const osColumns = process.stdout.columns;
1179
+ const osRows = process.stdout.rows;
1180
+ if (this.#reportedColumns !== undefined && osColumns > 0 && this.#reportedColumns !== osColumns) {
1181
+ this.#reportedColumns = undefined;
1182
+ }
1183
+ if (this.#reportedRows !== undefined && osRows > 0 && this.#reportedRows !== osRows) {
1184
+ this.#reportedRows = undefined;
1185
+ }
1186
+ }
1187
+
1188
+ async drainInput(maxMs = 1000, idleMs = 50): Promise<void> {
1189
+ if (this.#headless) return;
1190
+ if (this.#kittyProtocolActive) {
1191
+ // Disable Kitty keyboard protocol first so any late key releases
1192
+ // do not generate new Kitty escape sequences.
1193
+ this.#safeWrite("\x1b[<u");
1194
+ this.#kittyProtocolActive = false;
1195
+ setKittyProtocolActive(false);
1196
+ }
1197
+ if (this.#modifyOtherKeysTimeout) {
1198
+ clearTimeout(this.#modifyOtherKeysTimeout);
1199
+ this.#modifyOtherKeysTimeout = undefined;
1200
+ }
1201
+ if (this.#modifyOtherKeysActive) {
1202
+ this.#safeWrite("\x1b[>4;0m");
1203
+ this.#modifyOtherKeysActive = false;
1204
+ }
1205
+
1206
+ const previousHandler = this.#inputHandler;
1207
+ this.#inputHandler = undefined;
1208
+
1209
+ let lastDataTime = Date.now();
1210
+ const onData = () => {
1211
+ lastDataTime = Date.now();
1212
+ };
1213
+
1214
+ process.stdin.on("data", onData);
1215
+ const endTime = Date.now() + maxMs;
1216
+
1217
+ try {
1218
+ while (true) {
1219
+ const now = Date.now();
1220
+ const timeLeft = endTime - now;
1221
+ if (timeLeft <= 0) break;
1222
+ if (now - lastDataTime >= idleMs) break;
1223
+ await new Promise(resolve => setTimeout(resolve, Math.min(idleMs, timeLeft)));
1224
+ }
1225
+ } finally {
1226
+ process.stdin.removeListener("data", onData);
1227
+ this.#inputHandler = previousHandler;
1228
+ }
1229
+ }
1230
+
1231
+ stop(): void {
1232
+ if (this.#headless) return;
1233
+ // Unregister from emergency cleanup
1234
+ if (activeTerminal === this) {
1235
+ activeTerminal = null;
1236
+ }
1237
+
1238
+ if (this.#clearProgressTimer()) {
1239
+ this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
1240
+ }
1241
+
1242
+ // Leave paint-time terminal modes even if the process exits between the
1243
+ // begin/end halves of a frame. Safe no-ops on terminals that ignored them.
1244
+ this.#safeWrite("\x1b[?2026l\x1b[?7h");
1245
+
1246
+ // Disable bracketed paste mode
1247
+ this.#safeWrite("\x1b[?2004l");
1248
+ this.#safeWrite("\x1b[?5522l");
1249
+
1250
+ // Disable mouse tracking (enabled only by fullscreen overlays; safe
1251
+ // no-ops otherwise). Covers crash paths that reach stop() without the
1252
+ // TUI's own overlay teardown running.
1253
+ this.#safeWrite("\x1b[?1006l\x1b[?1003l\x1b[?1000l");
1254
+
1255
+ // Disable Mode 2031 appearance change notifications
1256
+ this.#safeWrite("\x1b[?2031l");
1257
+
1258
+ // Restore xterm scroll-to-bottom modes that were set before startup.
1259
+ for (const mode of this.#xtermScrollToBottomRestoreModes) {
1260
+ this.#safeWrite(`\x1b[?${mode}h`);
1261
+ }
1262
+ this.#xtermScrollToBottomRestoreModes.clear();
1263
+
1264
+ if (this.#inBandResizeActive) {
1265
+ this.#safeWrite("\x1b[?2048l");
1266
+ this.#inBandResizeActive = false;
1267
+ }
1268
+ if (this.#mode2031DebounceTimer) {
1269
+ clearTimeout(this.#mode2031DebounceTimer);
1270
+ this.#mode2031DebounceTimer = undefined;
1271
+ }
1272
+ this.#appearanceCallbacks = [];
1273
+ this.#osc11Pending = false;
1274
+ this.#osc11QueryQueued = false;
1275
+ this.#osc11ResponseBuffer = "";
1276
+ this.#osc99PendingId = undefined;
1277
+ this.#osc99ResponseBuffer = "";
1278
+ this.#osc99Capabilities.clear();
1279
+ setOsc99Supported(false);
1280
+ this.#privateCsiResponseBuffer = "";
1281
+ this.#inBandResizeBuffer = "";
1282
+ this.#da1SentinelOwners.length = 0;
1283
+ this.#privateModeCallbacks = [];
1284
+ this.#privateModeSupport.clear();
1285
+ this.#xtermScrollToBottomRestoreModes.clear();
1286
+ this.#reportedColumns = undefined;
1287
+ this.#reportedRows = undefined;
1288
+
1289
+ // Disable Kitty keyboard protocol if not already done by drainInput()
1290
+ if (this.#kittyProtocolActive) {
1291
+ this.#safeWrite("\x1b[<u");
1292
+ this.#kittyProtocolActive = false;
1293
+ setKittyProtocolActive(false);
1294
+ }
1295
+ if (this.#modifyOtherKeysTimeout) {
1296
+ clearTimeout(this.#modifyOtherKeysTimeout);
1297
+ this.#modifyOtherKeysTimeout = undefined;
1298
+ }
1299
+ if (this.#modifyOtherKeysActive) {
1300
+ this.#safeWrite("\x1b[>4;0m");
1301
+ this.#modifyOtherKeysActive = false;
1302
+ }
1303
+
1304
+ this.#restoreWindowsVTInput();
1305
+ // Clean up StdinBuffer
1306
+ if (this.#stdinBuffer) {
1307
+ this.#stdinBuffer.destroy();
1308
+ this.#stdinBuffer = undefined;
1309
+ }
1310
+
1311
+ // Remove event handlers
1312
+ if (this.#stdinDataHandler) {
1313
+ process.stdin.removeListener("data", this.#stdinDataHandler);
1314
+ this.#stdinDataHandler = undefined;
1315
+ }
1316
+ this.#inputHandler = undefined;
1317
+ this.#appearance = undefined;
1318
+ if (this.#stdoutResizeListener) {
1319
+ process.stdout.removeListener("resize", this.#stdoutResizeListener);
1320
+ this.#stdoutResizeListener = undefined;
1321
+ }
1322
+ this.#resizeHandler = undefined;
1323
+
1324
+ // Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
1325
+ // re-interpreted after raw mode is disabled. This fixes a race condition
1326
+ // where Ctrl+D could close the parent shell over SSH.
1327
+ process.stdin.pause();
1328
+
1329
+ // Restore raw mode state
1330
+ if (process.stdin.setRawMode) {
1331
+ process.stdin.setRawMode(this.#wasRaw);
1332
+ }
1333
+ this.#stdoutErrorCleanup?.();
1334
+ this.#stdoutErrorCleanup = undefined;
1335
+ }
1336
+
1337
+ #ensureStdoutErrorHandler(): void {
1338
+ this.#stdoutErrorCleanup ??= registerStdoutErrorHandler(this.#stdoutErrorHandler);
1339
+ }
1340
+
1341
+ #markTerminalWriteFailed(err: unknown): void {
1342
+ if (this.#dead) return;
1343
+ this.#dead = true;
1344
+ logger.warn("terminal write failed; disabling terminal rendering", { err });
1345
+ }
1346
+
1347
+ write(data: string): void {
1348
+ this.#safeWrite(data);
1349
+ if (this.#writeLogPath) {
1350
+ try {
1351
+ fs.appendFileSync(this.#writeLogPath, data, { encoding: "utf8" });
1352
+ } catch {
1353
+ // Ignore logging errors
1354
+ }
1355
+ }
1356
+ }
1357
+
1358
+ #safeWrite(data: string): void {
1359
+ if (this.#headless) return;
1360
+ if (this.#dead) return;
1361
+ // Skip control sequences when stdout isn't a TTY (piped output, tests, log
1362
+ // files). They serve no purpose there and would surface as visible noise.
1363
+ if (!process.stdout.isTTY) return;
1364
+ this.#ensureStdoutErrorHandler();
1365
+ // A console-sharing child process may have flipped the console codepage
1366
+ // away from UTF-8; repair it before any bytes hit WriteFile so no frame
1367
+ // is ever translated through an OEM codepage. See ensureWindowsConsoleUtf8.
1368
+ if (process.platform === "win32") ensureWindowsConsoleUtf8();
1369
+ try {
1370
+ // Windows ConPTY drops viewport tracking when a single write exceeds
1371
+ // ~32-64 KB: the host UI's scroll position stays parked at wherever
1372
+ // the write began, even though every byte landed in scrollback. Split
1373
+ // large paints into newline-aligned chunks so each underlying
1374
+ // `WriteFile` stays well below the threshold. The gate also covers
1375
+ // WSL — `process.platform === "linux"` there, but stdout still
1376
+ // crosses into ConPTY at the `wslhost` boundary, so the same per-
1377
+ // WriteFile cap applies. Non-ConPTY PTYs keep the single-write fast
1378
+ // path. The cap is on encoded UTF-8 bytes, not JS code units, because
1379
+ // `process.stdout.write(string)` UTF-8-encodes before `WriteFile`,
1380
+ // and a code-unit cap would let CJK transcript rows expand past the
1381
+ // threshold. See #2034 and #2095.
1382
+ if (isConPTYHosted() && Buffer.byteLength(data, "utf8") > MAX_CONPTY_WRITE_CHUNK_BYTES) {
1383
+ for (const chunk of chunkForConPTY(data, MAX_CONPTY_WRITE_CHUNK_BYTES)) {
1384
+ if (this.#dead) break;
1385
+ process.stdout.write(chunk);
1386
+ }
1387
+ } else {
1388
+ process.stdout.write(data);
1389
+ }
1390
+ } catch (err) {
1391
+ this.#markTerminalWriteFailed(err);
1392
+ }
1393
+ }
1394
+
1395
+ get columns(): number {
1396
+ if (this.#inBandResizeActive && this.#reportedColumns) return this.#reportedColumns;
1397
+ return process.stdout.columns || Number(Bun.env.COLUMNS) || 80;
1398
+ }
1399
+
1400
+ get rows(): number {
1401
+ if (this.#inBandResizeActive && this.#reportedRows) return this.#reportedRows;
1402
+ return process.stdout.rows || Number(Bun.env.LINES) || 24;
1403
+ }
1404
+
1405
+ moveBy(lines: number): void {
1406
+ if (lines > 0) {
1407
+ // Move down
1408
+ this.#safeWrite(`\x1b[${lines}B`);
1409
+ } else if (lines < 0) {
1410
+ // Move up
1411
+ this.#safeWrite(`\x1b[${-lines}A`);
1412
+ }
1413
+ // lines === 0: no movement
1414
+ }
1415
+
1416
+ hideCursor(): void {
1417
+ this.#safeWrite("\x1b[?25l");
1418
+ }
1419
+
1420
+ showCursor(): void {
1421
+ this.#safeWrite("\x1b[?25h");
1422
+ }
1423
+
1424
+ clearLine(): void {
1425
+ this.#safeWrite("\x1b[K");
1426
+ }
1427
+
1428
+ clearFromCursor(): void {
1429
+ this.#safeWrite("\x1b[J");
1430
+ }
1431
+
1432
+ clearScreen(): void {
1433
+ this.#safeWrite("\x1b[H\x1b[0J"); // Move to home (1,1) and clear from cursor to end
1434
+ }
1435
+
1436
+ setTitle(title: string): void {
1437
+ // OSC 0;title BEL - set terminal window title
1438
+ this.#safeWrite(`\x1b]0;${title}\x07`);
1439
+ }
1440
+
1441
+ setProgress(active: boolean): void {
1442
+ if (this.#headless) return;
1443
+ if (active) {
1444
+ this.#safeWrite(TERMINAL_PROGRESS_ACTIVE_SEQUENCE);
1445
+ if (!this.#progressTimer) {
1446
+ this.#progressTimer = setInterval(() => {
1447
+ this.#safeWrite(TERMINAL_PROGRESS_ACTIVE_SEQUENCE);
1448
+ }, TERMINAL_PROGRESS_KEEPALIVE_MS);
1449
+ this.#progressTimer.unref?.();
1450
+ }
1451
+ } else {
1452
+ this.#clearProgressTimer();
1453
+ this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
1454
+ }
1455
+ }
1456
+
1457
+ #clearProgressTimer(): boolean {
1458
+ if (!this.#progressTimer) return false;
1459
+ clearInterval(this.#progressTimer);
1460
+ this.#progressTimer = undefined;
1461
+ return true;
1462
+ }
1463
+ }