@sayknow-cli/tui 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/types/animation-scheduler.d.ts +13 -0
  2. package/dist/types/autocomplete.d.ts +83 -0
  3. package/dist/types/bracketed-paste.d.ts +26 -0
  4. package/dist/types/components/box.d.ts +20 -0
  5. package/dist/types/components/cancellable-loader.d.ts +21 -0
  6. package/dist/types/components/editor.d.ts +126 -0
  7. package/dist/types/components/image.d.ts +16 -0
  8. package/dist/types/components/input.d.ts +16 -0
  9. package/dist/types/components/loader.d.ts +23 -0
  10. package/dist/types/components/markdown.d.ts +77 -0
  11. package/dist/types/components/select-list.d.ts +46 -0
  12. package/dist/types/components/settings-list.d.ts +39 -0
  13. package/dist/types/components/spacer.d.ts +11 -0
  14. package/dist/types/components/tab-bar.d.ts +56 -0
  15. package/dist/types/components/text.d.ts +13 -0
  16. package/dist/types/components/truncated-text.d.ts +10 -0
  17. package/dist/types/editor-component.d.ts +36 -0
  18. package/dist/types/fuzzy.d.ts +15 -0
  19. package/dist/types/index.d.ts +27 -0
  20. package/dist/types/keybindings.d.ts +201 -0
  21. package/dist/types/keys.d.ts +208 -0
  22. package/dist/types/kill-ring.d.ts +27 -0
  23. package/dist/types/metrics.d.ts +85 -0
  24. package/dist/types/stdin-buffer.d.ts +50 -0
  25. package/dist/types/symbols.d.ts +23 -0
  26. package/dist/types/terminal-capabilities.d.ts +75 -0
  27. package/dist/types/terminal.d.ts +88 -0
  28. package/dist/types/ttyid.d.ts +9 -0
  29. package/dist/types/tui.d.ts +206 -0
  30. package/dist/types/utils.d.ts +87 -0
  31. package/package.json +10 -9
  32. package/src/animation-scheduler.ts +99 -0
  33. package/src/autocomplete.ts +119 -96
  34. package/src/components/editor.ts +310 -128
  35. package/src/components/input.ts +2 -1
  36. package/src/components/loader.ts +36 -37
  37. package/src/components/markdown.ts +79 -2
  38. package/src/components/select-list.ts +8 -1
  39. package/src/index.ts +1 -0
  40. package/src/stdin-buffer.ts +89 -11
  41. package/src/terminal.ts +44 -8
  42. package/src/tui.ts +362 -64
  43. package/src/utils.ts +77 -11
@@ -58,7 +58,8 @@ export class Input implements Component, Focusable {
58
58
  setValue(value: string): void {
59
59
  const normalized = value.normalize("NFC");
60
60
  this.#value = normalized;
61
- this.#cursor = Math.min(this.#cursor, normalized.length);
61
+ this.#cursor = normalized.length;
62
+ this.#lastAction = null;
62
63
  }
63
64
 
64
65
  handleInput(data: string): void {
@@ -1,34 +1,32 @@
1
+ import { type AnimationRegistration, registerAnimationCallback } from "../animation-scheduler";
1
2
  import type { TUI } from "../tui";
2
3
  import { sliceByColumn, visibleWidth } from "../utils";
3
4
  import { Text } from "./text";
4
5
 
5
- /**
6
- * Loader component that drives display refresh at ~60fps so callers whose
7
- * message colorizer is time-dependent (e.g. shimmer/KITT) animate smoothly.
8
- *
9
- * Two cadences are interleaved on a single timer:
10
- * - **Recompute tick** (every `RENDER_INTERVAL_MS`) → recomposes the spinner +
11
- * colorized message every 16ms. A redraw is requested only when that composed
12
- * text actually changed since the last tick (`#lastDisplayed`), so animated
13
- * colorizers (shimmer/KITT) and spinner-frame advances still repaint, while
14
- * static loaders skip the redundant no-op render requests between advances.
15
- * - **Spinner advance** (every `SPINNER_ADVANCE_MS`) → bumps the spinner
16
- * frame index. Decoupled from the recompute cadence so the spinner keeps
17
- * its classic ~12.5fps step pace regardless of shimmer state.
18
- *
19
- * The animation timer is `unref`'d so an active loader never keeps the event
20
- * loop alive on its own.
21
- */
22
- const RENDER_INTERVAL_MS = 16;
23
6
  const SPINNER_ADVANCE_MS = 80;
24
7
 
8
+ export interface LoaderOptions {
9
+ timeDependentColor?: boolean;
10
+ }
11
+
12
+ /** Test-only performance counters for advisory baseline tests. */
13
+ export const __loaderPerfCounters = {
14
+ liveIntervals: 0,
15
+ startedIntervals: 0,
16
+ reset(): void {
17
+ this.liveIntervals = 0;
18
+ this.startedIntervals = 0;
19
+ },
20
+ };
21
+
25
22
  export class Loader extends Text {
26
23
  #frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
27
24
  #currentFrame = 0;
28
- #intervalId?: NodeJS.Timeout;
25
+ #animation?: AnimationRegistration;
29
26
  #ui: TUI | null = null;
30
27
  #lastSpinnerTick = 0;
31
28
  #lastDisplayed?: string;
29
+ #timeDependentColor: boolean;
32
30
 
33
31
  constructor(
34
32
  ui: TUI,
@@ -36,9 +34,11 @@ export class Loader extends Text {
36
34
  private messageColorFn: (str: string) => string,
37
35
  private message: string = "Loading...",
38
36
  spinnerFrames?: string[],
37
+ options: LoaderOptions = {},
39
38
  ) {
40
39
  super("", 1, 0);
41
40
  this.#ui = ui;
41
+ this.#timeDependentColor = options.timeDependentColor ?? false;
42
42
  if (spinnerFrames && spinnerFrames.length > 0) {
43
43
  this.#frames = spinnerFrames;
44
44
  }
@@ -57,24 +57,28 @@ export class Loader extends Text {
57
57
  }
58
58
 
59
59
  start() {
60
+ if (this.#animation) return;
60
61
  this.#lastSpinnerTick = performance.now();
61
62
  this.#updateDisplay();
62
- this.#intervalId = setInterval(() => {
63
- const now = performance.now();
64
- if (now - this.#lastSpinnerTick >= SPINNER_ADVANCE_MS) {
65
- this.#currentFrame = (this.#currentFrame + 1) % this.#frames.length;
66
- this.#lastSpinnerTick = now;
67
- }
68
- this.#updateDisplay();
69
- }, RENDER_INTERVAL_MS);
70
- // Don't let the animation timer keep the event loop alive on its own.
71
- this.#intervalId?.unref?.();
63
+ __loaderPerfCounters.liveIntervals += 1;
64
+ __loaderPerfCounters.startedIntervals += 1;
65
+ this.#animation = registerAnimationCallback(
66
+ now => {
67
+ if (now - this.#lastSpinnerTick >= SPINNER_ADVANCE_MS) {
68
+ this.#currentFrame = (this.#currentFrame + 1) % this.#frames.length;
69
+ this.#lastSpinnerTick = now;
70
+ }
71
+ this.#updateDisplay();
72
+ },
73
+ this.#timeDependentColor ? 16 : 80,
74
+ );
72
75
  }
73
76
 
74
77
  stop() {
75
- if (this.#intervalId) {
76
- clearInterval(this.#intervalId);
77
- this.#intervalId = undefined;
78
+ if (this.#animation) {
79
+ this.#animation.unregister();
80
+ __loaderPerfCounters.liveIntervals = Math.max(0, __loaderPerfCounters.liveIntervals - 1);
81
+ this.#animation = undefined;
78
82
  }
79
83
  }
80
84
 
@@ -90,11 +94,6 @@ export class Loader extends Text {
90
94
  #updateDisplay() {
91
95
  const frame = this.#frames[this.#currentFrame];
92
96
  const next = `${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}`;
93
- // Only touch the component and ask the TUI to repaint when the rendered
94
- // text actually changed. Time-dependent colorizers (shimmer/KITT) produce
95
- // new text every tick and still animate; static loaders skip the ~16ms
96
- // no-op render requests between 80ms spinner advances. Output is unchanged
97
- // because a suppressed frame would have produced a no-op write anyway.
98
97
  if (next === this.#lastDisplayed) return;
99
98
  this.#lastDisplayed = next;
100
99
  this.setText(next);
@@ -42,6 +42,13 @@ const RENDER_CACHE_MAX = 256; // sane cap: ~256 distinct message × width combos
42
42
  const renderCache = new LRUCache<string, { source: string; lines: string[] }>({ max: RENDER_CACHE_MAX });
43
43
  const PARSE_CACHE_MAX = 128;
44
44
  const parseCache = new LRUCache<string, { source: string; tokens: Token[] }>({ max: PARSE_CACHE_MAX });
45
+ const MARKDOWN_STREAM_THROTTLE_MS = 64;
46
+ let markdownNow = (): number => performance.now();
47
+
48
+ /** Test-only clock seam for streaming throttle tests. */
49
+ export function __setMarkdownNowForTest(now: (() => number) | undefined): void {
50
+ markdownNow = now ?? (() => performance.now());
51
+ }
45
52
 
46
53
  // Per-code-block highlight cache (F3): keyed by theme + lang + code so streaming
47
54
  // appends only highlight new/changed blocks instead of re-highlighting the whole
@@ -61,6 +68,16 @@ export function resetMarkdownHighlightCallCount(): void {
61
68
  highlightCallCount = 0;
62
69
  }
63
70
 
71
+ /** Test-only performance counters for advisory baseline tests. */
72
+ export const __markdownPerfCounters = {
73
+ lexerInvocations: 0,
74
+ lexedBytes: 0,
75
+ reset(): void {
76
+ this.lexerInvocations = 0;
77
+ this.lexedBytes = 0;
78
+ },
79
+ };
80
+
64
81
  // Full-content 64-bit wyhash over every byte (no lossy sampling). Cache hits
65
82
  // additionally verify entry.source against the normalized text, so even a
66
83
  // hash collision can never return another message's render.
@@ -178,6 +195,11 @@ export class Markdown implements Component {
178
195
  #cachedWidth?: number;
179
196
  #cachedLines?: string[];
180
197
 
198
+ #streaming = false;
199
+ #lastFullParseAt = 0;
200
+ #onStaleThrottle?: () => void;
201
+ #staleThrottleTimer?: ReturnType<typeof setTimeout>;
202
+
181
203
  constructor(
182
204
  text: string,
183
205
  paddingX: number,
@@ -194,11 +216,55 @@ export class Markdown implements Component {
194
216
  this.#codeBlockIndent = Math.max(0, Math.floor(codeBlockIndent));
195
217
  }
196
218
 
197
- setText(text: string): void {
219
+ setOnStaleThrottle(callback: (() => void) | undefined): void {
220
+ this.#onStaleThrottle = callback;
221
+ }
222
+
223
+ setText(text: string, options?: { streaming?: boolean }): void {
224
+ if (options?.streaming !== undefined) {
225
+ this.setStreaming(options.streaming);
226
+ }
198
227
  this.#text = text;
228
+ if (this.#streaming) {
229
+ return;
230
+ }
199
231
  this.invalidate();
200
232
  }
201
233
 
234
+ setStreaming(streaming: boolean): void {
235
+ if (this.#streaming === streaming) return;
236
+ this.#streaming = streaming;
237
+ if (!streaming) {
238
+ this.#clearStaleThrottleTimer();
239
+ this.#lastFullParseAt = 0;
240
+ this.invalidate();
241
+ }
242
+ }
243
+
244
+ #clearStaleThrottleTimer(): void {
245
+ if (!this.#staleThrottleTimer) return;
246
+ clearTimeout(this.#staleThrottleTimer);
247
+ this.#staleThrottleTimer = undefined;
248
+ }
249
+
250
+ #armStaleThrottleTimer(remainingMs: number): void {
251
+ if (!this.#onStaleThrottle || this.#staleThrottleTimer || this.#cachedText === this.#text) return;
252
+ this.#staleThrottleTimer = setTimeout(
253
+ () => {
254
+ this.#staleThrottleTimer = undefined;
255
+ if (this.#streaming && this.#cachedText !== this.#text) {
256
+ this.#onStaleThrottle?.();
257
+ }
258
+ },
259
+ Math.max(0, remainingMs),
260
+ );
261
+ this.#staleThrottleTimer.unref?.();
262
+ }
263
+
264
+ dispose(): void {
265
+ this.#clearStaleThrottleTimer();
266
+ }
267
+
202
268
  invalidate(): void {
203
269
  this.#cachedText = undefined;
204
270
  this.#cachedWidth = undefined;
@@ -256,6 +322,14 @@ export class Markdown implements Component {
256
322
  // Calculate available width for content (subtract horizontal padding)
257
323
  const contentWidth = Math.max(1, width - this.#paddingX * 2);
258
324
 
325
+ if (this.#streaming && this.#cachedLines && this.#cachedWidth === width && this.#lastFullParseAt > 0) {
326
+ const elapsedMs = markdownNow() - this.#lastFullParseAt;
327
+ if (elapsedMs < MARKDOWN_STREAM_THROTTLE_MS) {
328
+ this.#armStaleThrottleTimer(MARKDOWN_STREAM_THROTTLE_MS - elapsedMs);
329
+ return this.#cachedLines;
330
+ }
331
+ }
332
+
259
333
  // Don't render anything if there's no actual text
260
334
  if (!this.#text || this.#text.trim() === "") {
261
335
  const result: string[] = [];
@@ -268,7 +342,7 @@ export class Markdown implements Component {
268
342
 
269
343
  // Replace tabs with 3 spaces for consistent rendering
270
344
  const normalizedText = replaceTabs(this.#text);
271
-
345
+ this.#clearStaleThrottleTimer();
272
346
  const contentKey = markdownContentKey(normalizedText);
273
347
 
274
348
  // L2: module-level LRU — survives component disposal/recreation across
@@ -296,6 +370,8 @@ export class Markdown implements Component {
296
370
  if (cachedParse !== undefined && cachedParse.source === normalizedText) {
297
371
  tokens = cachedParse.tokens;
298
372
  } else {
373
+ __markdownPerfCounters.lexerInvocations += 1;
374
+ __markdownPerfCounters.lexedBytes += normalizedText.length;
299
375
  tokens = markdownParser.lexer(normalizedText);
300
376
  parseCache.set(contentKey, { source: normalizedText, tokens });
301
377
  }
@@ -361,6 +437,7 @@ export class Markdown implements Component {
361
437
  this.#cachedText = this.#text;
362
438
  this.#cachedWidth = width;
363
439
  this.#cachedLines = result;
440
+ this.#lastFullParseAt = markdownNow();
364
441
 
365
442
  // Update L2 module-level LRU so future instances with the same key skip
366
443
  // the marked.lexer + highlightCode (Rust FFI) work entirely.
@@ -117,8 +117,15 @@ export class SelectList implements Component {
117
117
  }
118
118
 
119
119
  handleInput(keyData: string): void {
120
- if (this.#filteredItems.length === 0) return;
121
120
  const kb = getKeybindings();
121
+ if (this.#filteredItems.length === 0) {
122
+ if (kb.matches(keyData, "tui.select.cancel")) {
123
+ if (this.onCancel) {
124
+ this.onCancel();
125
+ }
126
+ }
127
+ return;
128
+ }
122
129
  // Up arrow - wrap to bottom when at top
123
130
  if (kb.matches(keyData, "tui.select.up")) {
124
131
  this.#selectedIndex = this.#selectedIndex === 0 ? this.#filteredItems.length - 1 : this.#selectedIndex - 1;
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // Core TUI interfaces and classes
2
2
 
3
+ export * from "./animation-scheduler";
3
4
  // Autocomplete support
4
5
  export * from "./autocomplete";
5
6
  // Components
@@ -24,6 +24,39 @@ const ESC = "\x1b";
24
24
  const BRACKETED_PASTE_START = "\x1b[200~";
25
25
  const BRACKETED_PASTE_END = "\x1b[201~";
26
26
 
27
+ function isUtf8LeadByte(byte: number): boolean {
28
+ return byte >= 0xc2 && byte <= 0xf4;
29
+ }
30
+
31
+ function endsWithIncompleteUtf8Sequence(data: Buffer): boolean {
32
+ if (data.length === 0) return false;
33
+
34
+ let index = data.length - 1;
35
+ let continuationCount = 0;
36
+ while (index >= 0) {
37
+ const byte = data[index]!;
38
+ if (byte < 0x80 || byte > 0xbf) break;
39
+ continuationCount++;
40
+ index--;
41
+ }
42
+
43
+ if (index < 0) {
44
+ return continuationCount > 0;
45
+ }
46
+
47
+ const lead = data[index]!;
48
+ let expectedLength = 0;
49
+ if (lead >= 0xc2 && lead <= 0xdf) expectedLength = 2;
50
+ else if (lead >= 0xe0 && lead <= 0xef) expectedLength = 3;
51
+ else if (lead >= 0xf0 && lead <= 0xf4) expectedLength = 4;
52
+
53
+ return expectedLength > 0 && continuationCount + 1 < expectedLength;
54
+ }
55
+
56
+ function legacyMetaSequence(byte: number): string {
57
+ return `\x1b${String.fromCharCode(byte - 128)}`;
58
+ }
59
+
27
60
  /**
28
61
  * Check if a string is a complete escape sequence or needs more data
29
62
  */
@@ -268,6 +301,8 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
268
301
  // split across two stdin events) reassemble correctly instead of emitting
269
302
  // U+FFFD. Reset on clear()/destroy(); never finalized on normal flush.
270
303
  #decoder = new StringDecoder("utf8");
304
+ #decoderHasPendingUtf8 = false;
305
+ #pendingSingleUtf8LeadByte: number | undefined;
271
306
 
272
307
  constructor(options: StdinBufferOptions = {}) {
273
308
  super();
@@ -286,22 +321,51 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
286
321
  let str: string;
287
322
  let decodedFromBuffer = false;
288
323
  if (Buffer.isBuffer(data)) {
289
- // Legacy 8-bit meta: an isolated high byte (0x80-0xFF) is treated
290
- // as ESC + (byte - 128) for Alt/meta compatibility, BEFORE UTF-8
291
- // decoding. This is the one documented exception to UTF-8 boundary
292
- // decoding a lone high byte that is also a valid UTF-8 lead byte
293
- // is still read as meta — so such a byte is never fed to the decoder.
294
- if (data.length === 1 && data[0]! > 127) {
295
- const byte = data[0]! - 128;
296
- str = `\x1b${String.fromCharCode(byte)}`;
324
+ let bytes = data;
325
+ const hadPendingUtf8 = this.#decoderHasPendingUtf8 || this.#pendingSingleUtf8LeadByte !== undefined;
326
+
327
+ if (this.#pendingSingleUtf8LeadByte !== undefined) {
328
+ const nextByte = data[0];
329
+ if (nextByte !== undefined && nextByte >= 0x80 && nextByte <= 0xbf) {
330
+ bytes = Buffer.concat([Buffer.from([this.#pendingSingleUtf8LeadByte]), data]);
331
+ this.#pendingSingleUtf8LeadByte = undefined;
332
+ } else {
333
+ const pendingMeta = this.#consumePendingSingleUtf8LeadAsMeta();
334
+ if (pendingMeta !== undefined) {
335
+ this.#emitDataSequence(pendingMeta);
336
+ }
337
+ }
338
+ }
339
+
340
+ if (bytes.length === 1 && bytes[0]! > 127 && !this.#decoderHasPendingUtf8) {
341
+ const byte = bytes[0]!;
342
+ if (isUtf8LeadByte(byte)) {
343
+ this.#pendingSingleUtf8LeadByte = byte;
344
+ this.#decoderHasPendingUtf8 = true;
345
+ this.#timeout = setTimeout(() => {
346
+ const sequence = this.#consumePendingSingleUtf8LeadAsMeta();
347
+ if (sequence !== undefined) {
348
+ this.#emitDataSequence(sequence);
349
+ }
350
+ }, this.#timeoutMs);
351
+ return;
352
+ }
353
+ str = legacyMetaSequence(byte);
297
354
  } else {
298
355
  // Decode through the persistent StringDecoder so a multi-byte
299
356
  // sequence split across chunks (e.g. a 3-byte Korean syllable)
300
357
  // is reassembled instead of emitting U+FFFD.
301
- str = this.#decoder.write(data);
358
+ str = this.#decoder.write(bytes);
302
359
  decodedFromBuffer = true;
360
+ const allContinuationBytes = bytes.every(byte => byte >= 0x80 && byte <= 0xbf);
361
+ this.#decoderHasPendingUtf8 =
362
+ endsWithIncompleteUtf8Sequence(bytes) && !(hadPendingUtf8 && str.length > 0 && allContinuationBytes);
303
363
  }
304
364
  } else {
365
+ const pendingMeta = this.#consumePendingSingleUtf8LeadAsMeta();
366
+ if (pendingMeta !== undefined) {
367
+ this.#emitDataSequence(pendingMeta);
368
+ }
305
369
  str = data;
306
370
  }
307
371
 
@@ -348,6 +412,9 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
348
412
  for (const sequence of result.sequences) {
349
413
  this.#emitDataSequence(sequence);
350
414
  }
415
+ if (result.remainder.length > 0) {
416
+ this.#emitDataSequence(result.remainder);
417
+ }
351
418
  }
352
419
 
353
420
  this.#pendingKittyPrintableCodepoint = undefined;
@@ -392,6 +459,13 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
392
459
  }
393
460
  }
394
461
 
462
+ #consumePendingSingleUtf8LeadAsMeta(): string | undefined {
463
+ const byte = this.#pendingSingleUtf8LeadByte;
464
+ if (byte === undefined) return undefined;
465
+ this.#pendingSingleUtf8LeadByte = undefined;
466
+ this.#decoderHasPendingUtf8 = false;
467
+ return legacyMetaSequence(byte);
468
+ }
395
469
  #emitDataSequence(sequence: string): void {
396
470
  const rawCodepoint = sequence.length === 1 ? sequence.codePointAt(0) : undefined;
397
471
  if (rawCodepoint !== undefined && rawCodepoint === this.#pendingKittyPrintableCodepoint) {
@@ -409,11 +483,13 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
409
483
  this.#timeout = undefined;
410
484
  }
411
485
 
486
+ const pendingMeta = this.#consumePendingSingleUtf8LeadAsMeta();
487
+
412
488
  if (this.#buffer.length === 0) {
413
- return [];
489
+ return pendingMeta === undefined ? [] : [pendingMeta];
414
490
  }
415
491
 
416
- const sequences = [this.#buffer];
492
+ const sequences = pendingMeta === undefined ? [this.#buffer] : [pendingMeta, this.#buffer];
417
493
  this.#buffer = "";
418
494
  this.#pendingKittyPrintableCodepoint = undefined;
419
495
  return sequences;
@@ -432,6 +508,8 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
432
508
  // stale partial prefix cannot combine with future input. destroy()
433
509
  // resets the decoder by calling clear().
434
510
  this.#decoder = new StringDecoder("utf8");
511
+ this.#decoderHasPendingUtf8 = false;
512
+ this.#pendingSingleUtf8LeadByte = undefined;
435
513
  }
436
514
 
437
515
  getBuffer(): string {
package/src/terminal.ts CHANGED
@@ -207,6 +207,7 @@ export function resolveTerminalRows(
207
207
  function isWindowsSubsystemForLinux(): boolean {
208
208
  return process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
209
209
  }
210
+ const STDOUT_ERROR_HANDLER_GRACE_MS = 250;
210
211
 
211
212
  /**
212
213
  * Real terminal using process.stdin/stdout
@@ -225,6 +226,7 @@ export class ProcessTerminal implements Terminal {
225
226
  #detachLogPath = $env.PI_TUI_TERMINAL_DETACH_LOG || "";
226
227
  #windowsVTInputRestore?: () => void;
227
228
  #stdoutErrorHandler?: (err: Error) => void;
229
+ #stdoutErrorHandlerCleanupTimer?: Timer;
228
230
  #appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
229
231
  #appearance: TerminalAppearance | undefined;
230
232
  #osc11Pending = false;
@@ -277,10 +279,16 @@ export class ProcessTerminal implements Terminal {
277
279
 
278
280
  // Set up resize handler immediately
279
281
  process.stdout.on("resize", this.#resizeHandler);
280
- this.#stdoutErrorHandler = (err: Error) => {
281
- this.#markUnavailable(err, "stdout-error");
282
- };
283
- process.stdout.on("error", this.#stdoutErrorHandler);
282
+ if (this.#stdoutErrorHandlerCleanupTimer) {
283
+ clearTimeout(this.#stdoutErrorHandlerCleanupTimer);
284
+ this.#stdoutErrorHandlerCleanupTimer = undefined;
285
+ }
286
+ if (!this.#stdoutErrorHandler) {
287
+ this.#stdoutErrorHandler = (err: Error) => {
288
+ this.#markUnavailable(err, "stdout-error");
289
+ };
290
+ process.stdout.on("error", this.#stdoutErrorHandler);
291
+ }
284
292
 
285
293
  // Refresh terminal dimensions - they may be stale after suspend/resume
286
294
  // (SIGWINCH is lost while process is stopped). Unix only.
@@ -622,6 +630,20 @@ export class ProcessTerminal implements Terminal {
622
630
  return;
623
631
  }
624
632
  this.#safeWrite("\x1b[?u");
633
+ // Windows Terminal and conhost do not implement the Kitty keyboard
634
+ // protocol, so the query above never activates it there. They do honor the
635
+ // modifyOtherKeys fallback below — but that mode breaks Windows CJK/Hangul
636
+ // IME composition: Alt+Enter (and other chords) bypass the IME commit, so
637
+ // the syllable still being composed is never delivered to the app and the
638
+ // action fires on empty text (e.g. queue-message no-ops unless the user
639
+ // types a trailing space to force a commit first). Skip the fallback on
640
+ // win32; legacy encodings still deliver Alt+Enter (ESC CR) and the newline
641
+ // chords, and IME composition works again. Opt back in with
642
+ // SKC_TUI_KEYBOARD_PROTOCOL=0 disabling all enhancement, or force-enable
643
+ // elsewhere if a Kitty-capable Windows terminal appears.
644
+ if (process.platform === "win32") {
645
+ return;
646
+ }
625
647
  this.#modifyOtherKeysTimeout = setTimeout(() => {
626
648
  this.#modifyOtherKeysTimeout = undefined;
627
649
  if (this.#kittyProtocolActive || this.#modifyOtherKeysActive) {
@@ -736,10 +758,7 @@ export class ProcessTerminal implements Terminal {
736
758
  process.stdout.removeListener("resize", this.#resizeHandler);
737
759
  this.#resizeHandler = undefined;
738
760
  }
739
- if (this.#stdoutErrorHandler) {
740
- process.stdout.removeListener("error", this.#stdoutErrorHandler);
741
- this.#stdoutErrorHandler = undefined;
742
- }
761
+ this.#scheduleStdoutErrorHandlerCleanup();
743
762
 
744
763
  // Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
745
764
  // re-interpreted after raw mode is disabled. This fixes a race condition
@@ -752,6 +771,23 @@ export class ProcessTerminal implements Terminal {
752
771
  }
753
772
  }
754
773
 
774
+ #scheduleStdoutErrorHandlerCleanup(): void {
775
+ if (!this.#stdoutErrorHandler) return;
776
+ if (this.#stdoutErrorHandlerCleanupTimer) clearTimeout(this.#stdoutErrorHandlerCleanupTimer);
777
+ // Terminal restore writes above can fail asynchronously after stop() returns
778
+ // when an SSH/Windows Terminal PTY disappears. Keep the stdout error listener
779
+ // armed briefly so late EIO/EPIPE events mark the terminal unavailable instead
780
+ // of surfacing as uncaught exceptions that kill the tmux pane.
781
+ this.#stdoutErrorHandlerCleanupTimer = setTimeout(() => {
782
+ if (this.#stdoutErrorHandler) {
783
+ process.stdout.removeListener("error", this.#stdoutErrorHandler);
784
+ this.#stdoutErrorHandler = undefined;
785
+ }
786
+ this.#stdoutErrorHandlerCleanupTimer = undefined;
787
+ }, STDOUT_ERROR_HANDLER_GRACE_MS);
788
+ this.#stdoutErrorHandlerCleanupTimer.unref?.();
789
+ }
790
+
755
791
  write(data: string): void {
756
792
  this.#safeWrite(data);
757
793
  if (this.#writeLogPath) {