@sayknow-cli/tui 0.2.2

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 (61) hide show
  1. package/CHANGELOG.md +903 -0
  2. package/README.md +704 -0
  3. package/dist/types/autocomplete.d.ts +82 -0
  4. package/dist/types/bracketed-paste.d.ts +26 -0
  5. package/dist/types/components/box.d.ts +20 -0
  6. package/dist/types/components/cancellable-loader.d.ts +21 -0
  7. package/dist/types/components/editor.d.ts +111 -0
  8. package/dist/types/components/image.d.ts +16 -0
  9. package/dist/types/components/input.d.ts +16 -0
  10. package/dist/types/components/loader.d.ts +14 -0
  11. package/dist/types/components/markdown.d.ts +64 -0
  12. package/dist/types/components/select-list.d.ts +46 -0
  13. package/dist/types/components/settings-list.d.ts +39 -0
  14. package/dist/types/components/spacer.d.ts +11 -0
  15. package/dist/types/components/tab-bar.d.ts +56 -0
  16. package/dist/types/components/text.d.ts +13 -0
  17. package/dist/types/components/truncated-text.d.ts +10 -0
  18. package/dist/types/editor-component.d.ts +36 -0
  19. package/dist/types/fuzzy.d.ts +15 -0
  20. package/dist/types/index.d.ts +26 -0
  21. package/dist/types/keybindings.d.ts +189 -0
  22. package/dist/types/keys.d.ts +208 -0
  23. package/dist/types/kill-ring.d.ts +27 -0
  24. package/dist/types/metrics.d.ts +85 -0
  25. package/dist/types/stdin-buffer.d.ts +50 -0
  26. package/dist/types/symbols.d.ts +23 -0
  27. package/dist/types/terminal-capabilities.d.ts +75 -0
  28. package/dist/types/terminal.d.ts +76 -0
  29. package/dist/types/ttyid.d.ts +9 -0
  30. package/dist/types/tui.d.ts +181 -0
  31. package/dist/types/utils.d.ts +75 -0
  32. package/package.json +74 -0
  33. package/src/autocomplete.ts +896 -0
  34. package/src/bracketed-paste.ts +47 -0
  35. package/src/components/box.ts +173 -0
  36. package/src/components/cancellable-loader.ts +40 -0
  37. package/src/components/editor.ts +2820 -0
  38. package/src/components/image.ts +90 -0
  39. package/src/components/input.ts +465 -0
  40. package/src/components/loader.ts +103 -0
  41. package/src/components/markdown.ts +1061 -0
  42. package/src/components/select-list.ts +249 -0
  43. package/src/components/settings-list.ts +211 -0
  44. package/src/components/spacer.ts +28 -0
  45. package/src/components/tab-bar.ts +175 -0
  46. package/src/components/text.ts +110 -0
  47. package/src/components/truncated-text.ts +61 -0
  48. package/src/editor-component.ts +71 -0
  49. package/src/fuzzy.ts +143 -0
  50. package/src/index.ts +41 -0
  51. package/src/keybindings.ts +279 -0
  52. package/src/keys.ts +537 -0
  53. package/src/kill-ring.ts +46 -0
  54. package/src/metrics.ts +382 -0
  55. package/src/stdin-buffer.ts +444 -0
  56. package/src/symbols.ts +24 -0
  57. package/src/terminal-capabilities.ts +537 -0
  58. package/src/terminal.ts +807 -0
  59. package/src/ttyid.ts +73 -0
  60. package/src/tui.ts +1765 -0
  61. package/src/utils.ts +389 -0
@@ -0,0 +1,807 @@
1
+ import { dlopen, FFIType, ptr } from "bun:ffi";
2
+ import * as fs from "node:fs";
3
+ import { $env, $flag } from "@sayknow-cli/utils";
4
+ import { setKittyProtocolActive } from "./keys";
5
+ import { StdinBuffer } from "./stdin-buffer";
6
+
7
+ const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
8
+ const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
9
+ const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07";
10
+
11
+ /**
12
+ * Whether SKC may reprogram the keyboard with enhanced input protocols
13
+ * (the Kitty keyboard protocol and the xterm modifyOtherKeys fallback).
14
+ *
15
+ * Enabled by default. Set `SKC_TUI_KEYBOARD_PROTOCOL=0` to leave the keyboard in
16
+ * its default mode. Some terminals — notably Android Termius — break IME
17
+ * composition (e.g. Korean/Hangul syllable composition) while these enhanced
18
+ * modes are active, committing every intermediate composing jamo/syllable
19
+ * instead of only the final character. Disabling the protocol restores normal
20
+ * IME behavior, matching how other TUIs that leave the keyboard untouched render
21
+ * Korean correctly.
22
+ */
23
+ export function keyboardEnhancementEnabled(): boolean {
24
+ return $flag("SKC_TUI_KEYBOARD_PROTOCOL", true);
25
+ }
26
+
27
+ /**
28
+ * Minimal terminal interface for TUI
29
+ */
30
+
31
+ // Track active terminal for emergency cleanup on crash
32
+ let activeTerminal: ProcessTerminal | null = null;
33
+ // Track if a terminal was ever started (for emergency restore logic)
34
+ let terminalEverStarted = false;
35
+
36
+ const STD_INPUT_HANDLE = -10;
37
+ const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200;
38
+ /**
39
+ * Emergency terminal restore - call this from signal/crash handlers
40
+ * Resets terminal state without requiring access to the ProcessTerminal instance
41
+ */
42
+ export function emergencyTerminalRestore(): void {
43
+ try {
44
+ const terminal = activeTerminal;
45
+ if (terminal) {
46
+ terminal.stop();
47
+ terminal.showCursor();
48
+ } else if (terminalEverStarted) {
49
+ // Blind restore only if we know a terminal was started but lost track of it
50
+ // This avoids writing escape sequences for non-TUI commands (grep, commit, etc.)
51
+ process.stdout.write(
52
+ "\x1b[?2004l" + // Disable bracketed paste
53
+ "\x1b[?1000l" + // Disable normal mouse reporting
54
+ "\x1b[?1006l" + // Disable SGR extended mouse reporting
55
+ "\x1b[?2031l" + // Disable Mode 2031 appearance notifications
56
+ "\x1b[<u" + // Pop kitty keyboard protocol
57
+ "\x1b[>4;0m" + // Disable modifyOtherKeys fallback
58
+ "\x1b[?25h", // Show cursor
59
+ );
60
+ if (process.stdin.setRawMode) {
61
+ process.stdin.setRawMode(false);
62
+ }
63
+ }
64
+ } catch {
65
+ // Terminal may already be dead during crash cleanup - ignore errors
66
+ }
67
+ }
68
+ /** Terminal-reported appearance (dark/light mode). */
69
+ export type TerminalAppearance = "dark" | "light";
70
+ export interface Terminal {
71
+ // Start the terminal with input and resize handlers
72
+ start(onInput: (data: string) => void, onResize: () => void): void;
73
+
74
+ // Stop the terminal and restore state
75
+ stop(): void;
76
+
77
+ /**
78
+ * Drain stdin before exiting to prevent Kitty key release events from
79
+ * leaking to the parent shell over slow SSH connections.
80
+ * @param maxMs - Maximum time to drain (default: 1000ms)
81
+ * @param idleMs - Exit early if no input arrives within this time (default: 50ms)
82
+ */
83
+ drainInput(maxMs?: number, idleMs?: number): Promise<void>;
84
+
85
+ // Write output to terminal
86
+ write(data: string): void;
87
+
88
+ // Whether terminal output is still writable
89
+ get available(): boolean;
90
+
91
+ // Get terminal dimensions
92
+ get columns(): number;
93
+ get rows(): number;
94
+
95
+ // Whether Kitty keyboard protocol is active
96
+ get kittyProtocolActive(): boolean;
97
+
98
+ // Cursor positioning (relative to current position)
99
+ moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines
100
+
101
+ // Cursor visibility
102
+ hideCursor(): void; // Hide the cursor
103
+ showCursor(): void; // Show the cursor
104
+
105
+ // Clear operations
106
+ clearLine(): void; // Clear current line
107
+ clearFromCursor(): void; // Clear from cursor to end of screen
108
+ clearScreen(): void; // Clear entire screen and move cursor to (0,0)
109
+
110
+ // Title operations
111
+ setTitle(title: string): void; // Set terminal window title
112
+
113
+ // Progress indicator (OSC 9;4)
114
+ setProgress(active: boolean): void;
115
+
116
+ /**
117
+ * Register a callback for terminal appearance (dark/light) changes.
118
+ * Detection uses OSC 11 background color query with Mode 2031 as a change trigger.
119
+ * Fires when the detected appearance changes, including the initial detection.
120
+ */
121
+ onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
122
+
123
+ /** The last detected terminal appearance, or undefined if not yet known. */
124
+ get appearance(): TerminalAppearance | undefined;
125
+ }
126
+
127
+ function isWindowsSubsystemForLinux(): boolean {
128
+ return process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
129
+ }
130
+
131
+ /**
132
+ * Real terminal using process.stdin/stdout
133
+ */
134
+ export class ProcessTerminal implements Terminal {
135
+ #wasRaw = false;
136
+ #inputHandler?: (data: string) => void;
137
+ #resizeHandler?: () => void;
138
+ #kittyProtocolActive = false;
139
+ #modifyOtherKeysActive = false;
140
+ #modifyOtherKeysTimeout?: Timer;
141
+ #stdinBuffer?: StdinBuffer;
142
+ #stdinDataHandler?: (data: string | Buffer) => void;
143
+ #dead = false;
144
+ #writeLogPath = $env.PI_TUI_WRITE_LOG || "";
145
+ #detachLogPath = $env.PI_TUI_TERMINAL_DETACH_LOG || "";
146
+ #windowsVTInputRestore?: () => void;
147
+ #stdoutErrorHandler?: (err: Error) => void;
148
+ #appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
149
+ #appearance: TerminalAppearance | undefined;
150
+ #osc11Pending = false;
151
+ #osc11QueryQueued = false;
152
+ #osc11ResponseBuffer = "";
153
+ #privateCsiResponseBuffer = "";
154
+ #pendingDa1Sentinels = 0;
155
+ #osc11PollTimer?: Timer;
156
+ #mode2031DebounceTimer?: Timer;
157
+ #progressTimer?: ReturnType<typeof setInterval>;
158
+
159
+ get kittyProtocolActive(): boolean {
160
+ return this.#kittyProtocolActive;
161
+ }
162
+
163
+ get appearance(): TerminalAppearance | undefined {
164
+ return this.#appearance;
165
+ }
166
+
167
+ onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void {
168
+ this.#appearanceCallbacks.push(callback);
169
+ }
170
+
171
+ start(onInput: (data: string) => void, onResize: () => void): void {
172
+ this.#inputHandler = onInput;
173
+ this.#resizeHandler = onResize;
174
+
175
+ // Register for emergency cleanup
176
+ activeTerminal = this;
177
+ terminalEverStarted = true;
178
+
179
+ // Save previous state and enable raw mode
180
+ this.#wasRaw = process.stdin.isRaw || false;
181
+ if (process.stdin.setRawMode) {
182
+ process.stdin.setRawMode(true);
183
+ }
184
+ // Do NOT setEncoding("utf8"): raw stdin chunks may split a multi-byte
185
+ // UTF-8 character across reads, and Bun's raw-TTY string decoding does
186
+ // not reliably reassemble them (issue #454 — Korean paste mojibake).
187
+ // StdinBuffer is the single decoding boundary and decodes Buffers via a
188
+ // persistent StringDecoder, so we forward raw Buffers untouched.
189
+ process.stdin.resume();
190
+
191
+ // Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
192
+ this.#safeWrite("\x1b[?2004h");
193
+
194
+ // Set up resize handler immediately
195
+ process.stdout.on("resize", this.#resizeHandler);
196
+ this.#stdoutErrorHandler = (err: Error) => {
197
+ this.#markUnavailable(err, "stdout-error");
198
+ };
199
+ process.stdout.on("error", this.#stdoutErrorHandler);
200
+
201
+ // Refresh terminal dimensions - they may be stale after suspend/resume
202
+ // (SIGWINCH is lost while process is stopped). Unix only.
203
+ if (process.platform !== "win32") {
204
+ process.kill(process.pid, "SIGWINCH");
205
+ }
206
+
207
+ // On Windows, enable ENABLE_VIRTUAL_TERMINAL_INPUT so the console sends
208
+ // VT escape sequences (e.g. \x1b[Z for Shift+Tab) instead of raw console
209
+ // events that lose modifier information. Must run after setRawMode(true)
210
+ // since that resets console mode flags.
211
+ this.#enableWindowsVTInput();
212
+ // Query and enable Kitty keyboard protocol
213
+ // The query handler intercepts input temporarily, then installs the user's handler
214
+ // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
215
+ this.#queryAndEnableKittyProtocol();
216
+
217
+ // Query terminal background color via OSC 11 for dark/light detection.
218
+ // Uses DA1 (Primary Device Attributes) as a sentinel: terminals process
219
+ // sequences in order, so if DA1 arrives before OSC 11 response,
220
+ // the terminal does not support OSC 11. This avoids indefinite hangs.
221
+ // Technique used by Neovim, bat, fish, and terminal-colorsaurus.
222
+ this.#queryBackgroundColor();
223
+
224
+ // Subscribe to Mode 2031 appearance change notifications.
225
+ // When the terminal reports a change, we re-query OSC 11 to get the
226
+ // actual background color (following Neovim convention) with 100ms debounce.
227
+ this.#safeWrite("\x1b[?2031h");
228
+
229
+ // Start periodic OSC 11 re-query for terminals without Mode 2031
230
+ // (Warp, Alacritty, WezTerm, iTerm2). Self-disables once Mode 2031 fires.
231
+ // Windows Terminal under WSL has been observed to close the hosting tab
232
+ // after repeated OSC 11/DA1 probes. Keep the initial/event-driven probes,
233
+ // but avoid background polling there.
234
+ if (!isWindowsSubsystemForLinux()) {
235
+ this.#startOsc11Poll();
236
+ }
237
+ }
238
+
239
+ /**
240
+ * On Windows, add ENABLE_VIRTUAL_TERMINAL_INPUT to the stdin console mode
241
+ * so modified keys (for example Shift+Tab) arrive as VT escape sequences.
242
+ */
243
+ #enableWindowsVTInput(): void {
244
+ if (process.platform !== "win32") return;
245
+ this.#restoreWindowsVTInput();
246
+ try {
247
+ const kernel32 = dlopen("kernel32.dll", {
248
+ GetStdHandle: { args: [FFIType.i32], returns: FFIType.ptr },
249
+ GetConsoleMode: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.bool },
250
+ SetConsoleMode: { args: [FFIType.ptr, FFIType.u32], returns: FFIType.bool },
251
+ });
252
+ const handle = kernel32.symbols.GetStdHandle(STD_INPUT_HANDLE);
253
+ const mode = new Uint32Array(1);
254
+ const modePtr = ptr(mode);
255
+ if (!modePtr || !kernel32.symbols.GetConsoleMode(handle, modePtr)) {
256
+ kernel32.close();
257
+ return;
258
+ }
259
+ const originalMode = mode[0]!;
260
+ const vtMode = originalMode | ENABLE_VIRTUAL_TERMINAL_INPUT;
261
+ if (vtMode !== originalMode && !kernel32.symbols.SetConsoleMode(handle, vtMode)) {
262
+ kernel32.close();
263
+ return;
264
+ }
265
+ this.#windowsVTInputRestore = () => {
266
+ try {
267
+ kernel32.symbols.SetConsoleMode(handle, originalMode);
268
+ } finally {
269
+ kernel32.close();
270
+ }
271
+ };
272
+ } catch {
273
+ // bun:ffi unavailable or console API unsupported; keep startup non-fatal.
274
+ }
275
+ }
276
+
277
+ #restoreWindowsVTInput(): void {
278
+ if (process.platform !== "win32") return;
279
+ const restore = this.#windowsVTInputRestore;
280
+ this.#windowsVTInputRestore = undefined;
281
+ if (!restore) return;
282
+ try {
283
+ restore();
284
+ } catch {
285
+ // Ignore restore errors during terminal teardown.
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Set up StdinBuffer to split batched input into individual sequences.
291
+ * This ensures components receive single events, making matchesKey/isKeyRelease work correctly.
292
+ *
293
+ * Also watches for Kitty protocol response and enables it when detected.
294
+ * This is done here (after stdinBuffer parsing) rather than on raw stdin
295
+ * to handle the case where the response arrives split across multiple events.
296
+ */
297
+ #setupStdinBuffer(): void {
298
+ this.#stdinBuffer = new StdinBuffer({ timeout: 10 });
299
+
300
+ // Kitty protocol response pattern: \x1b[?<flags>u
301
+ const kittyResponsePattern = /^\x1b\[\?(\d+)u$/;
302
+
303
+ // Mode 2031 DSR response: \x1b[?997;{1=dark,2=light}n
304
+ const appearanceDsrPattern = /^\x1b\[\?997;([12])n$/;
305
+
306
+ // OSC 11 response: \x1b]11;rgb:RR/GG/BB or rgba:RR/GG/BB, terminated by BEL or ST.
307
+ const osc11ResponsePattern =
308
+ /^\x1b\]11;rgba?:([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})(?:\x07|\x1b\\)$/;
309
+
310
+ // DA1 (Primary Device Attributes) response: \x1b[?...c
311
+ const da1ResponsePattern = /^\x1b\[\?[\d;]*c$/;
312
+
313
+ // Private CSI partial: \x1b[?<digits/semicolons>... — incomplete probe response
314
+ // that the StdinBuffer flushed before the terminator arrived (split across
315
+ // stdin reads). Used to reassemble DA1, kitty, and Mode 2031 replies.
316
+ const privateCsiPartialPattern = /^\x1b\[\?[\d;]*$/;
317
+
318
+ // Forward individual sequences to the input handler
319
+ this.#stdinBuffer.on("data", (sequence: string) => {
320
+ // Reassemble split private CSI responses (DA1, kitty keyboard, Mode 2031).
321
+ // When the terminal writes the response slowly enough that the StdinBuffer's
322
+ // flush timeout elapses mid-sequence, the prefix `\x1b[?<digits>` arrives as
323
+ // one event and the tail `;...<terminator>` arrives as individual character
324
+ // events that would otherwise leak into the prompt as keystrokes. See #1238.
325
+ if (
326
+ this.#privateCsiResponseBuffer ||
327
+ (privateCsiPartialPattern.test(sequence) && this.#pendingDa1Sentinels > 0)
328
+ ) {
329
+ if (this.#privateCsiResponseBuffer && sequence.startsWith("\x1b")) {
330
+ // New escape arrived mid-reassembly — abandon partial and re-process the new sequence.
331
+ this.#privateCsiResponseBuffer = "";
332
+ } else {
333
+ this.#privateCsiResponseBuffer += sequence;
334
+ // Cap accumulator to defend against runaway partials if the terminator never arrives.
335
+ if (this.#privateCsiResponseBuffer.length > 256) {
336
+ this.#privateCsiResponseBuffer = "";
337
+ return;
338
+ }
339
+ const lastChar = this.#privateCsiResponseBuffer.at(-1)!;
340
+ const lastCode = lastChar.charCodeAt(0);
341
+ if (lastCode >= 0x40 && lastCode <= 0x7e) {
342
+ // Terminator byte arrived. Fall through to the pattern checks with the
343
+ // reassembled sequence so the existing DA1/kitty/Mode 2031 handlers run.
344
+ sequence = this.#privateCsiResponseBuffer;
345
+ this.#privateCsiResponseBuffer = "";
346
+ } else if (!privateCsiPartialPattern.test(this.#privateCsiResponseBuffer)) {
347
+ // Diverged from a valid private CSI prefix (unexpected byte). Drop the
348
+ // probe noise we ate; do not forward to the input handler.
349
+ this.#privateCsiResponseBuffer = "";
350
+ return;
351
+ } else {
352
+ // Still accumulating.
353
+ return;
354
+ }
355
+ }
356
+ }
357
+
358
+ // Check for Kitty protocol response (only if not already enabled)
359
+ if (!this.#kittyProtocolActive) {
360
+ const match = sequence.match(kittyResponsePattern);
361
+ if (match) {
362
+ if (this.#modifyOtherKeysTimeout) {
363
+ clearTimeout(this.#modifyOtherKeysTimeout);
364
+ this.#modifyOtherKeysTimeout = undefined;
365
+ }
366
+ this.#kittyProtocolActive = true;
367
+ setKittyProtocolActive(true);
368
+
369
+ // Enable Kitty keyboard protocol (push flags)
370
+ // Flag 1 = disambiguate escape codes
371
+ // Flag 2 = report event types (press/repeat/release)
372
+ // Flag 4 = report alternate keys
373
+ this.#safeWrite("\x1b[>7u");
374
+ return; // Don't forward protocol response to TUI
375
+ }
376
+ }
377
+
378
+ // DA1 response: swallow our sentinel reply regardless of whether OSC 11
379
+ // already succeeded. Other terminal probes should never see these replies.
380
+ if (da1ResponsePattern.test(sequence) && this.#pendingDa1Sentinels > 0) {
381
+ this.#pendingDa1Sentinels--;
382
+ if (this.#osc11Pending) {
383
+ // DA1 arrived before OSC 11 response: terminal does not support
384
+ // OSC 11. Clear the pending state without starting a queued query
385
+ // (queued query is started below, after sentinel is consumed).
386
+ this.#osc11Pending = false;
387
+ this.#osc11ResponseBuffer = "";
388
+ }
389
+ // Now that this DA1 cycle is complete, start any queued query.
390
+ if (this.#osc11QueryQueued && !this.#dead) {
391
+ this.#osc11QueryQueued = false;
392
+ this.#startOsc11Query();
393
+ }
394
+ return;
395
+ }
396
+
397
+ // OSC 11 replies can be split if the stdin buffer flushes a partial sequence.
398
+ // Accumulate fragments until the BEL/ST terminator arrives, then parse once.
399
+ // If a new escape sequence arrives (not the ST terminator), abort buffering
400
+ // and forward it as normal input so user keystrokes are never swallowed.
401
+ if (this.#osc11Pending && (this.#osc11ResponseBuffer || sequence.startsWith("\x1b]11;"))) {
402
+ if (this.#osc11ResponseBuffer && sequence.startsWith("\x1b") && sequence !== "\x1b\\") {
403
+ // New escape sequence arrived mid-buffer — not an OSC 11 continuation.
404
+ this.#osc11ResponseBuffer = "";
405
+ // Fall through to normal input handling below.
406
+ } else {
407
+ this.#osc11ResponseBuffer += sequence;
408
+ const osc11Match = this.#osc11ResponseBuffer.match(osc11ResponsePattern);
409
+ if (!osc11Match) return;
410
+ const [, rHex, gHex, bHex] = osc11Match;
411
+ this.#osc11Pending = false;
412
+ this.#osc11ResponseBuffer = "";
413
+ this.#handleOsc11Response(rHex!, gHex!, bHex!);
414
+ return;
415
+ }
416
+ }
417
+
418
+ // Mode 2031 change notification: re-query OSC 11 with 100ms debounce
419
+ // (Neovim convention — coalesces rapid notifications during transitions)
420
+ const appearanceMatch = sequence.match(appearanceDsrPattern);
421
+ if (appearanceMatch) {
422
+ this.#stopOsc11Poll();
423
+ if (this.#mode2031DebounceTimer) clearTimeout(this.#mode2031DebounceTimer);
424
+ this.#mode2031DebounceTimer = setTimeout(() => {
425
+ this.#mode2031DebounceTimer = undefined;
426
+ this.#queryBackgroundColor();
427
+ }, 100);
428
+ return;
429
+ }
430
+ if (this.#inputHandler) {
431
+ this.#inputHandler(sequence);
432
+ }
433
+ });
434
+
435
+ // Re-wrap paste content with bracketed paste markers for existing editor handling
436
+ this.#stdinBuffer.on("paste", (content: string) => {
437
+ if (this.#inputHandler) {
438
+ this.#inputHandler(`\x1b[200~${content}\x1b[201~`);
439
+ }
440
+ });
441
+
442
+ // Handler that pipes stdin data through the buffer
443
+ this.#stdinDataHandler = (data: string | Buffer) => {
444
+ this.#stdinBuffer!.process(data);
445
+ };
446
+ }
447
+
448
+ /**
449
+ * Send OSC 11 background color query followed by DA1 sentinel.
450
+ * DA1 avoids indefinite hangs: if DA1 response arrives before OSC 11,
451
+ * the terminal does not support OSC 11.
452
+ */
453
+ #queryBackgroundColor(): void {
454
+ if (this.#dead) return;
455
+ // Queue if an OSC 11 query is in flight or its DA1 sentinel hasn't been
456
+ // consumed yet. Starting a new query while a DA1 is outstanding would
457
+ // increment the sentinel counter, and the old DA1 arrival would then
458
+ // prematurely clear the new query's pending state.
459
+ if (this.#osc11Pending || this.#pendingDa1Sentinels > 0) {
460
+ this.#osc11QueryQueued = true;
461
+ return;
462
+ }
463
+ this.#startOsc11Query();
464
+ }
465
+
466
+ #startOsc11Query(): void {
467
+ this.#osc11Pending = true;
468
+ this.#osc11ResponseBuffer = "";
469
+ this.#pendingDa1Sentinels++;
470
+ this.#safeWrite("\x1b]11;?\x07"); // OSC 11 query (BEL terminated)
471
+ this.#safeWrite("\x1b[c"); // DA1 sentinel
472
+ }
473
+ /**
474
+ * Parse an OSC 11 background color response and compute BT.601 luminance.
475
+ * Handles 1-, 2-, 3-, and 4-digit XParseColor hex components.
476
+ */
477
+ #handleOsc11Response(rHex: string, gHex: string, bHex: string): void {
478
+ const normalize = (hex: string): number => {
479
+ const value = parseInt(hex, 16);
480
+ if (Number.isNaN(value)) return 0;
481
+ const max = 16 ** hex.length - 1;
482
+ return max > 0 ? value / max : 0;
483
+ };
484
+ const luminance = 0.299 * normalize(rHex) + 0.587 * normalize(gHex) + 0.114 * normalize(bHex);
485
+ const mode: TerminalAppearance = luminance < 0.5 ? "dark" : "light";
486
+ if (mode === this.#appearance) return;
487
+ this.#appearance = mode;
488
+ for (const cb of this.#appearanceCallbacks) {
489
+ try {
490
+ cb(mode);
491
+ } catch {
492
+ /* ignore callback errors */
493
+ }
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Start periodic OSC 11 re-queries for terminals without Mode 2031 (Warp, Alacritty, WezTerm).
499
+ * Self-disables once Mode 2031 fires (push-based is better than polling).
500
+ */
501
+ #startOsc11Poll(): void {
502
+ this.#stopOsc11Poll();
503
+ this.#osc11PollTimer = setInterval(() => {
504
+ if (this.#dead) {
505
+ this.#stopOsc11Poll();
506
+ return;
507
+ }
508
+ this.#queryBackgroundColor();
509
+ }, 2_000);
510
+ this.#osc11PollTimer.unref();
511
+ }
512
+
513
+ #stopOsc11Poll(): void {
514
+ if (this.#osc11PollTimer) {
515
+ clearInterval(this.#osc11PollTimer);
516
+ this.#osc11PollTimer = undefined;
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Query terminal for Kitty keyboard protocol support and enable if available.
522
+ *
523
+ * Sends CSI ? u to query current flags. If terminal responds with CSI ? <flags> u,
524
+ * it supports the protocol and we enable it with CSI > 1 u.
525
+ *
526
+ * The response is detected in setupStdinBuffer's data handler, which properly
527
+ * handles the case where the response arrives split across multiple stdin events.
528
+ */
529
+ #queryAndEnableKittyProtocol(): void {
530
+ this.#setupStdinBuffer();
531
+ process.stdin.on("data", this.#stdinDataHandler!);
532
+ // Leave the keyboard in its default mode when enhanced input protocols are
533
+ // disabled. Android Termius (and similar terminals) break IME/Hangul
534
+ // composition when the Kitty keyboard protocol or modifyOtherKeys is active,
535
+ // committing every intermediate composing jamo/syllable. Skipping the query
536
+ // and the modifyOtherKeys fallback restores normal IME composition.
537
+ if (!keyboardEnhancementEnabled()) {
538
+ return;
539
+ }
540
+ this.#safeWrite("\x1b[?u");
541
+ this.#modifyOtherKeysTimeout = setTimeout(() => {
542
+ this.#modifyOtherKeysTimeout = undefined;
543
+ if (this.#kittyProtocolActive || this.#modifyOtherKeysActive) {
544
+ return;
545
+ }
546
+ this.#safeWrite("\x1b[>4;2m");
547
+ this.#modifyOtherKeysActive = true;
548
+ }, 150);
549
+ }
550
+
551
+ async drainInput(maxMs = 1000, idleMs = 50): Promise<void> {
552
+ if (this.#kittyProtocolActive) {
553
+ // Disable Kitty keyboard protocol first so any late key releases
554
+ // do not generate new Kitty escape sequences.
555
+ this.#safeWrite("\x1b[<u");
556
+ this.#kittyProtocolActive = false;
557
+ setKittyProtocolActive(false);
558
+ }
559
+ if (this.#modifyOtherKeysTimeout) {
560
+ clearTimeout(this.#modifyOtherKeysTimeout);
561
+ this.#modifyOtherKeysTimeout = undefined;
562
+ }
563
+ if (this.#modifyOtherKeysActive) {
564
+ this.#safeWrite("\x1b[>4;0m");
565
+ this.#modifyOtherKeysActive = false;
566
+ }
567
+
568
+ const previousHandler = this.#inputHandler;
569
+ this.#inputHandler = undefined;
570
+
571
+ let lastDataTime = Date.now();
572
+ const onData = () => {
573
+ lastDataTime = Date.now();
574
+ };
575
+
576
+ process.stdin.on("data", onData);
577
+ const endTime = Date.now() + maxMs;
578
+
579
+ try {
580
+ while (true) {
581
+ const now = Date.now();
582
+ const timeLeft = endTime - now;
583
+ if (timeLeft <= 0) break;
584
+ if (now - lastDataTime >= idleMs) break;
585
+ await new Promise(resolve => setTimeout(resolve, Math.min(idleMs, timeLeft)));
586
+ }
587
+ } finally {
588
+ process.stdin.removeListener("data", onData);
589
+ this.#inputHandler = previousHandler;
590
+ }
591
+ }
592
+
593
+ stop(): void {
594
+ // Unregister from emergency cleanup
595
+ if (activeTerminal === this) {
596
+ activeTerminal = null;
597
+ }
598
+
599
+ if (this.#clearProgressTimer()) {
600
+ this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
601
+ }
602
+
603
+ // Disable bracketed paste mode
604
+ this.#safeWrite("\x1b[?2004l");
605
+ this.#safeWrite("\x1b[?1000l");
606
+ this.#safeWrite("\x1b[?1006l");
607
+
608
+ // Disable Mode 2031 appearance change notifications
609
+ this.#safeWrite("\x1b[?2031l");
610
+ this.#stopOsc11Poll();
611
+ if (this.#mode2031DebounceTimer) {
612
+ clearTimeout(this.#mode2031DebounceTimer);
613
+ this.#mode2031DebounceTimer = undefined;
614
+ }
615
+ this.#appearanceCallbacks = [];
616
+ this.#osc11Pending = false;
617
+ this.#osc11QueryQueued = false;
618
+ this.#osc11ResponseBuffer = "";
619
+ this.#privateCsiResponseBuffer = "";
620
+ this.#pendingDa1Sentinels = 0;
621
+
622
+ // Disable Kitty keyboard protocol if not already done by drainInput()
623
+ if (this.#kittyProtocolActive) {
624
+ this.#safeWrite("\x1b[<u");
625
+ this.#kittyProtocolActive = false;
626
+ setKittyProtocolActive(false);
627
+ }
628
+ if (this.#modifyOtherKeysTimeout) {
629
+ clearTimeout(this.#modifyOtherKeysTimeout);
630
+ this.#modifyOtherKeysTimeout = undefined;
631
+ }
632
+ if (this.#modifyOtherKeysActive) {
633
+ this.#safeWrite("\x1b[>4;0m");
634
+ this.#modifyOtherKeysActive = false;
635
+ }
636
+
637
+ this.#restoreWindowsVTInput();
638
+ // Clean up StdinBuffer
639
+ if (this.#stdinBuffer) {
640
+ this.#stdinBuffer.destroy();
641
+ this.#stdinBuffer = undefined;
642
+ }
643
+
644
+ // Remove event handlers
645
+ if (this.#stdinDataHandler) {
646
+ process.stdin.removeListener("data", this.#stdinDataHandler);
647
+ this.#stdinDataHandler = undefined;
648
+ }
649
+ this.#inputHandler = undefined;
650
+ this.#appearance = undefined;
651
+ if (this.#resizeHandler) {
652
+ process.stdout.removeListener("resize", this.#resizeHandler);
653
+ this.#resizeHandler = undefined;
654
+ }
655
+ if (this.#stdoutErrorHandler) {
656
+ process.stdout.removeListener("error", this.#stdoutErrorHandler);
657
+ this.#stdoutErrorHandler = undefined;
658
+ }
659
+
660
+ // Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
661
+ // re-interpreted after raw mode is disabled. This fixes a race condition
662
+ // where Ctrl+D could close the parent shell over SSH.
663
+ process.stdin.pause();
664
+
665
+ // Restore raw mode state
666
+ if (process.stdin.setRawMode) {
667
+ process.stdin.setRawMode(this.#wasRaw);
668
+ }
669
+ }
670
+
671
+ write(data: string): void {
672
+ this.#safeWrite(data);
673
+ if (this.#writeLogPath) {
674
+ try {
675
+ fs.appendFileSync(this.#writeLogPath, data, { encoding: "utf8" });
676
+ } catch {
677
+ // Ignore logging errors
678
+ }
679
+ }
680
+ }
681
+
682
+ #safeWrite(data: string): void {
683
+ if (this.#dead) return;
684
+ // Skip control sequences when stdout isn't a TTY (piped output, tests, log
685
+ // files). They serve no purpose there and would surface as visible noise.
686
+ if (!process.stdout.isTTY) return;
687
+ if (
688
+ !process.stdout.writable ||
689
+ process.stdout.destroyed ||
690
+ process.stdout.closed ||
691
+ process.stdout.writableEnded
692
+ ) {
693
+ this.#markUnavailable(undefined, "stdout-closed");
694
+ return;
695
+ }
696
+ try {
697
+ process.stdout.write(data);
698
+ } catch (err) {
699
+ this.#markUnavailable(err, "write");
700
+ }
701
+ }
702
+
703
+ #markUnavailable(err: unknown, operation: string): void {
704
+ if (this.#dead) return;
705
+ this.#dead = true;
706
+ this.#clearProgressTimer();
707
+ this.#stopOsc11Poll();
708
+ if (this.#mode2031DebounceTimer) {
709
+ clearTimeout(this.#mode2031DebounceTimer);
710
+ this.#mode2031DebounceTimer = undefined;
711
+ }
712
+ if (this.#modifyOtherKeysTimeout) {
713
+ clearTimeout(this.#modifyOtherKeysTimeout);
714
+ this.#modifyOtherKeysTimeout = undefined;
715
+ }
716
+ this.#appendDetachDebugEvent(operation, err);
717
+ }
718
+
719
+ #appendDetachDebugEvent(operation: string, err: unknown): void {
720
+ if (!this.#detachLogPath) return;
721
+ const error = err instanceof Error ? err : undefined;
722
+ const code =
723
+ typeof (err as { code?: unknown } | undefined)?.code === "string" ? (err as { code: string }).code : undefined;
724
+ const line = JSON.stringify({
725
+ at: new Date().toISOString(),
726
+ operation,
727
+ code,
728
+ name: error?.name,
729
+ message: error?.message,
730
+ });
731
+ try {
732
+ fs.appendFileSync(this.#detachLogPath, `${line}\n`, { encoding: "utf8" });
733
+ } catch {
734
+ // Ignore debug logging errors; the terminal is already unavailable.
735
+ }
736
+ }
737
+
738
+ get available(): boolean {
739
+ return !this.#dead;
740
+ }
741
+
742
+ get columns(): number {
743
+ return process.stdout.columns || Number(Bun.env.COLUMNS) || 80;
744
+ }
745
+
746
+ get rows(): number {
747
+ return process.stdout.rows || Number(Bun.env.LINES) || 24;
748
+ }
749
+
750
+ moveBy(lines: number): void {
751
+ if (lines > 0) {
752
+ // Move down
753
+ this.#safeWrite(`\x1b[${lines}B`);
754
+ } else if (lines < 0) {
755
+ // Move up
756
+ this.#safeWrite(`\x1b[${-lines}A`);
757
+ }
758
+ // lines === 0: no movement
759
+ }
760
+
761
+ hideCursor(): void {
762
+ this.#safeWrite("\x1b[?25l");
763
+ }
764
+
765
+ showCursor(): void {
766
+ this.#safeWrite("\x1b[?25h");
767
+ }
768
+
769
+ clearLine(): void {
770
+ this.#safeWrite("\x1b[K");
771
+ }
772
+
773
+ clearFromCursor(): void {
774
+ this.#safeWrite("\x1b[J");
775
+ }
776
+
777
+ clearScreen(): void {
778
+ this.#safeWrite("\x1b[H\x1b[0J"); // Move to home (1,1) and clear from cursor to end
779
+ }
780
+
781
+ setTitle(title: string): void {
782
+ // OSC 0;title BEL - set terminal window title
783
+ this.#safeWrite(`\x1b]0;${title}\x07`);
784
+ }
785
+
786
+ setProgress(active: boolean): void {
787
+ if (active) {
788
+ this.#safeWrite(TERMINAL_PROGRESS_ACTIVE_SEQUENCE);
789
+ if (!this.#progressTimer) {
790
+ this.#progressTimer = setInterval(() => {
791
+ this.#safeWrite(TERMINAL_PROGRESS_ACTIVE_SEQUENCE);
792
+ }, TERMINAL_PROGRESS_KEEPALIVE_MS);
793
+ this.#progressTimer.unref?.();
794
+ }
795
+ } else {
796
+ this.#clearProgressTimer();
797
+ this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
798
+ }
799
+ }
800
+
801
+ #clearProgressTimer(): boolean {
802
+ if (!this.#progressTimer) return false;
803
+ clearInterval(this.#progressTimer);
804
+ this.#progressTimer = undefined;
805
+ return true;
806
+ }
807
+ }