@oh-my-pi/pi-tui 17.0.2 → 17.0.4

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.0.3] - 2026-07-17
6
+
7
+ ### Fixed
8
+
9
+ - Fixed multiline pastes arriving without bracketed-paste markers (e.g. Cmd+V in the Codex desktop embedded terminal on macOS) being split into one submit per line: `StdinBuffer` now collects adjacent ESC-free, CR/LF-bearing stdin reads in a fixed 10 ms classification window and coalesces three or more lines into one paste event, while ambiguous one-break input (including Enter batched with a following keystroke) is replayed unchanged ([#5841](https://github.com/can1357/oh-my-pi/issues/5841)).
10
+ - Fixed wrapped OSC 8 links in Markdown tables making cell padding, separators, and adjacent cells clickable ([#5885](https://github.com/can1357/oh-my-pi/issues/5885)).
11
+ - Fixed interactive sessions surviving terminal closure and entering a runaway render loop by stopping the TUI and raising SIGHUP when terminal input closes or output fails ([#5835](https://github.com/can1357/oh-my-pi/issues/5835)).
12
+ - Fixed native cmux SSH pane resizes inserting blank rows into terminal scrollback by routing remote-transport sessions through the in-place repaint path ([#5857](https://github.com/can1357/oh-my-pi/issues/5857)).
13
+ - Fixed the terminal flickering when leaving a fullscreen overlay (e.g. `/settings`) on terminals that re-report their size when the alternate screen buffer toggles: the alt-toggle SIGWINCH echo is height-only, so the resize fast path no longer borrows the alternate screen for it ([#5854](https://github.com/can1357/oh-my-pi/issues/5854)).
14
+
5
15
  ## [17.0.2] - 2026-07-17
6
16
 
7
17
  ### Added
package/README.md CHANGED
@@ -522,7 +522,7 @@ The TUI works with any object implementing the `Terminal` interface:
522
522
 
523
523
  ```typescript
524
524
  interface Terminal {
525
- start(onInput: (data: string) => void, onResize: () => void): void;
525
+ start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void;
526
526
  stop(): void;
527
527
  write(data: string): void;
528
528
  get columns(): number;
@@ -31,7 +31,7 @@ export declare function emergencyTerminalRestore(): void;
31
31
  /** Terminal-reported appearance (dark/light mode). */
32
32
  export type TerminalAppearance = "dark" | "light";
33
33
  export interface Terminal {
34
- start(onInput: (data: string) => void, onResize: () => void): void;
34
+ start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void;
35
35
  stop(): void;
36
36
  /**
37
37
  * Drain stdin before exiting to prevent Kitty key release events from
@@ -113,7 +113,7 @@ export declare class ProcessTerminal implements Terminal {
113
113
  */
114
114
  refreshAppearance(): void;
115
115
  onPrivateModeReport(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void;
116
- start(onInput: (data: string) => void, onResize: () => void): void;
116
+ start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void;
117
117
  drainInput(maxMs?: number, idleMs?: number): Promise<void>;
118
118
  stop(): void;
119
119
  write(data: string): void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "17.0.2",
4
+ "version": "17.0.4",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "17.0.2",
41
- "@oh-my-pi/pi-utils": "17.0.2",
40
+ "@oh-my-pi/pi-natives": "17.0.4",
41
+ "@oh-my-pi/pi-utils": "17.0.4",
42
42
  "lru-cache": "11.5.2",
43
43
  "marked": "^18.0.6"
44
44
  },
@@ -2190,8 +2190,8 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
2190
2190
  if (token.text === token.href || token.text === hrefForComparison)
2191
2191
  result += clickableLinkText + stylePrefix;
2192
2192
  else {
2193
- const styledLinkUrl = this.#theme.linkUrl(` (${token.href})`);
2194
- result += clickableLinkText + formatHyperlink(styledLinkUrl, token.href) + stylePrefix;
2193
+ const styledLinkUrl = this.#theme.linkUrl(`(${token.href})`);
2194
+ result += `${clickableLinkText} ${formatHyperlink(styledLinkUrl, token.href)}${stylePrefix}`;
2195
2195
  }
2196
2196
  break;
2197
2197
  }
@@ -2388,7 +2388,14 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
2388
2388
  */
2389
2389
  #wrapCellText(text: string, maxWidth: number): string[] {
2390
2390
  const cellWidth = Math.max(1, maxWidth);
2391
- return splitTerminalLines(text).flatMap(line => wrapTextWithAnsi(line, cellWidth));
2391
+ // Wrap the whole cell in one call so wrapTextWithAnsi() balances OSC 8
2392
+ // hyperlink state across explicit newlines (e.g. `<br>` rendered as \n);
2393
+ // per-fragment wrapping would drop the reopened link on later rows.
2394
+ const wrapped = wrapTextWithAnsi(text, cellWidth);
2395
+ while (wrapped.length > 1 && wrapped[wrapped.length - 1] === "") {
2396
+ wrapped.pop();
2397
+ }
2398
+ return wrapped;
2392
2399
  }
2393
2400
 
2394
2401
  /**
@@ -62,6 +62,39 @@ const MAX_STRING_SEQ_BYTES = 16 * 1024 * 1024;
62
62
  // runs at most once per resolved report — never inside the growth loop.
63
63
  const SGR_MOUSE_COMPLETE = /^<\d+;\d+;\d+[Mm]$/;
64
64
 
65
+ // Raw-paste classification holds CR/LF-bearing, ESC-free input briefly so
66
+ // adjacent stdin reads from one unmarked paste can be considered together.
67
+ // Fixed from the first break-bearing read (not an inactivity debounce): normal
68
+ // Enter latency and candidate memory remain bounded even under a continuous
69
+ // stream. Ten milliseconds spans adjacent PTY reads without becoming perceptible.
70
+ const RAW_PASTE_CLASSIFICATION_TIMEOUT_MS = 10;
71
+
72
+ /**
73
+ * Whether `text` has two completed logical line breaks (three line segments).
74
+ *
75
+ * A single Enter may be batched with surrounding keystrokes in one stdin read,
76
+ * so one break is ambiguous and must stay on the key path. CRLF counts as one
77
+ * logical break. Content after the second break completes the third segment;
78
+ * until then the classification window keeps buffering.
79
+ */
80
+ function isRawMultilineBurst(text: string): boolean {
81
+ let breaks = 0;
82
+ for (let i = 0; i < text.length; i++) {
83
+ const code = text.charCodeAt(i);
84
+ if (code === 0x0d) {
85
+ breaks++;
86
+ if (text.charCodeAt(i + 1) === 0x0a) i++;
87
+ continue;
88
+ }
89
+ if (code === 0x0a) {
90
+ breaks++;
91
+ continue;
92
+ }
93
+ if (breaks >= 2) return true;
94
+ }
95
+ return false;
96
+ }
97
+
65
98
  /**
66
99
  * Resolve the exclusive-end index of the escape sequence starting at `pos`
67
100
  * (`buffer.charCodeAt(pos)` must be ESC). `resumeSearchFrom` is honored only
@@ -364,6 +397,8 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
364
397
  #pendingKittyPrintableCodepoint: number | undefined;
365
398
  #pendingKittyPrintableAtMs = 0;
366
399
  #escapeSearchOffset = 0;
400
+ #rawPasteCandidate = "";
401
+ #rawPasteTimer?: NodeJS.Timeout;
367
402
 
368
403
  constructor(options: StdinBufferOptions = {}) {
369
404
  super();
@@ -398,20 +433,49 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
398
433
  this.#clearFlushTimer();
399
434
  }
400
435
 
401
- if (str.length === 0 && this.#buffer.length === 0) {
436
+ if (str.length === 0 && this.#buffer.length === 0 && this.#rawPasteCandidate.length === 0) {
402
437
  this.#emitDataSequence("");
403
438
  return;
404
439
  }
405
440
 
406
- this.#buffer += str;
407
-
408
441
  if (this.#pasteMode) {
409
- const chunk = this.#buffer;
410
- this.#buffer = "";
411
- this.#consumePasteChunk(chunk);
442
+ this.#consumePasteChunk(str);
443
+ return;
444
+ }
445
+
446
+ if (this.#rawPasteCandidate.length > 0) {
447
+ if (str.indexOf(ESC) !== -1) {
448
+ // Escape-bearing input cannot belong to an unmarked raw paste.
449
+ // Replay the ambiguous prefix as keys before parsing the escape.
450
+ this.#flushRawPasteCandidate();
451
+ } else {
452
+ this.#rawPasteCandidate += str;
453
+ if (isRawMultilineBurst(this.#rawPasteCandidate)) {
454
+ this.#emitRawPasteCandidate();
455
+ }
456
+ return;
457
+ }
458
+ }
459
+
460
+ if (
461
+ this.#buffer.length === 0 &&
462
+ str.indexOf(ESC) === -1 &&
463
+ (str.indexOf("\r") !== -1 || str.indexOf("\n") !== -1)
464
+ ) {
465
+ // Hold the first break-bearing read briefly. A split raw paste can
466
+ // then accumulate enough logical lines to classify; an ordinary
467
+ // Enter is replayed unchanged when the fixed window expires.
468
+ this.#rawPasteCandidate = str;
469
+ if (isRawMultilineBurst(str)) {
470
+ this.#emitRawPasteCandidate();
471
+ } else {
472
+ this.#armRawPasteTimer();
473
+ }
412
474
  return;
413
475
  }
414
476
 
477
+ this.#buffer += str;
478
+
415
479
  const startIndex = this.#buffer.indexOf(BRACKETED_PASTE_START);
416
480
  if (startIndex !== -1) {
417
481
  if (startIndex > 0) {
@@ -526,6 +590,46 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
526
590
  this.emit("paste", content);
527
591
  }
528
592
 
593
+ /** Start one fixed window from the first break-bearing raw read. */
594
+ #armRawPasteTimer(): void {
595
+ if (this.#rawPasteTimer) return;
596
+ this.#rawPasteTimer = setTimeout(() => {
597
+ this.#rawPasteTimer = undefined;
598
+ this.#flushRawPasteCandidate();
599
+ }, RAW_PASTE_CLASSIFICATION_TIMEOUT_MS);
600
+ }
601
+
602
+ #clearRawPasteTimer(): void {
603
+ if (this.#rawPasteTimer) {
604
+ clearTimeout(this.#rawPasteTimer);
605
+ this.#rawPasteTimer = undefined;
606
+ }
607
+ }
608
+
609
+ #takeRawPasteCandidate(): string {
610
+ this.#clearRawPasteTimer();
611
+ const content = this.#rawPasteCandidate;
612
+ this.#rawPasteCandidate = "";
613
+ return content;
614
+ }
615
+
616
+ /** Emit a classified raw multiline burst through the paste channel. */
617
+ #emitRawPasteCandidate(): void {
618
+ const content = this.#takeRawPasteCandidate();
619
+ this.#pendingKittyPrintableCodepoint = undefined;
620
+ this.emit("paste", content);
621
+ }
622
+
623
+ /** Replay an ambiguous raw candidate as the original per-key data events. */
624
+ #flushRawPasteCandidate(): void {
625
+ const content = this.#takeRawPasteCandidate();
626
+ if (content.length === 0) return;
627
+ const result = extractCompleteSequences(content, 0);
628
+ for (const sequence of result.sequences) {
629
+ this.#emitDataSequence(sequence);
630
+ }
631
+ }
632
+
529
633
  #emitDataSequence(sequence: string): void {
530
634
  const rawCodepoint = sequence.length === 1 ? sequence.codePointAt(0) : undefined;
531
635
  if (
@@ -627,8 +731,12 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
627
731
  flush(): string[] {
628
732
  this.#clearFlushTimer();
629
733
 
734
+ const rawCandidate = this.#takeRawPasteCandidate();
735
+ const sequences = rawCandidate.length > 0 ? extractCompleteSequences(rawCandidate, 0).sequences : [];
736
+
630
737
  if (this.#buffer.length === 0) {
631
- return [];
738
+ this.#pendingKittyPrintableCodepoint = undefined;
739
+ return sequences;
632
740
  }
633
741
 
634
742
  const buffered = this.#buffer;
@@ -641,15 +749,19 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
641
749
  // emission swallows the double-escape gesture (#3857). Mirror the inline
642
750
  // split in `extractCompleteSequences` and deliver two ESC events.
643
751
  if (buffered === `${ESC}${ESC}`) {
644
- return [ESC, ESC];
752
+ sequences.push(ESC, ESC);
753
+ } else {
754
+ sequences.push(buffered);
645
755
  }
646
- return [buffered];
756
+ return sequences;
647
757
  }
648
758
 
649
759
  clear(): void {
650
760
  this.#clearFlushTimer();
651
761
  this.#clearPasteWatchdog();
762
+ this.#clearRawPasteTimer();
652
763
  this.#buffer = "";
764
+ this.#rawPasteCandidate = "";
653
765
  this.#pasteMode = false;
654
766
  this.#pasteChunks = [];
655
767
  this.#pasteOverlap = "";
@@ -660,7 +772,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
660
772
  }
661
773
 
662
774
  getBuffer(): string {
663
- return this.#buffer;
775
+ return `${this.#rawPasteCandidate}${this.#buffer}`;
664
776
  }
665
777
 
666
778
  destroy(): void {
@@ -180,12 +180,13 @@ export class TerminalInfo {
180
180
 
181
181
  /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
182
182
  export function isInsideTerminalMultiplexer(env: NodeJS.ProcessEnv = Bun.env): boolean {
183
- // TMUX/STY/ZELLIJ/CMUX workspace+surface ids are authoritative session
184
- // signals. TERM can also survive when those are stripped (`sudo` without -E,
185
- // `su`, env-sanitizing launchers/ssh). Do not use CMUX_SOCKET_PATH here: it is
186
- // a CLI socket override and can be set outside a CMUX terminal.
183
+ // TMUX/STY/ZELLIJ and CMUX workspace/surface/remote-transport markers are
184
+ // authoritative session signals. TERM can also survive when those are
185
+ // stripped (`sudo` without -E, `su`, env-sanitizing launchers/ssh). Do not
186
+ // use CMUX_SOCKET_PATH here: it is a CLI socket override and can be set
187
+ // outside a CMUX terminal.
187
188
  if (env.TMUX || env.STY || env.ZELLIJ) return true;
188
- if (env.CMUX_WORKSPACE_ID || env.CMUX_SURFACE_ID) return true;
189
+ if (env.CMUX_WORKSPACE_ID || env.CMUX_SURFACE_ID || env.CMUX_REMOTE_TRANSPORT) return true;
189
190
  const term = env.TERM?.toLowerCase() ?? "";
190
191
  return term.startsWith("tmux") || term.startsWith("screen");
191
192
  }
package/src/terminal.ts CHANGED
@@ -337,8 +337,8 @@ export function emergencyTerminalRestore(): void {
337
337
  /** Terminal-reported appearance (dark/light mode). */
338
338
  export type TerminalAppearance = "dark" | "light";
339
339
  export interface Terminal {
340
- // Start the terminal with input and resize handlers
341
- start(onInput: (data: string) => void, onResize: () => void): void;
340
+ // Start the terminal with input, resize, and host-disconnect handlers.
341
+ start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void;
342
342
 
343
343
  // Stop the terminal and restore state
344
344
  stop(): void;
@@ -483,6 +483,16 @@ export class ProcessTerminal implements Terminal {
483
483
  #modifyOtherKeysTimeout?: Timer;
484
484
  #stdinBuffer?: StdinBuffer;
485
485
  #stdinDataHandler?: (data: string) => void;
486
+ #disconnectHandler?: () => void;
487
+ #stdinEndHandler = () => {
488
+ this.#markTerminalDisconnected("stdin ended");
489
+ };
490
+ #stdinCloseHandler = () => {
491
+ this.#markTerminalDisconnected("stdin closed");
492
+ };
493
+ #stdinErrorHandler = (err: Error) => {
494
+ this.#markTerminalDisconnected("stdin failed", err);
495
+ };
486
496
  #dead = false;
487
497
  // Captured at construction and re-read at start(): when true, every real
488
498
  // terminal side effect (writes, probes, raw mode, SIGWINCH, timers) is
@@ -491,7 +501,7 @@ export class ProcessTerminal implements Terminal {
491
501
  #writeLogPath = $env.PI_TUI_WRITE_LOG || "";
492
502
  #stdoutErrorCleanup?: () => void;
493
503
  #stdoutErrorHandler = (err: Error) => {
494
- this.#markTerminalWriteFailed(err);
504
+ this.#markTerminalDisconnected("stdout failed", err);
495
505
  };
496
506
 
497
507
  #windowsVTInputRestore?: () => void;
@@ -577,9 +587,10 @@ export class ProcessTerminal implements Terminal {
577
587
  this.#privateModeCallbacks.push(callback);
578
588
  }
579
589
 
580
- start(onInput: (data: string) => void, onResize: () => void): void {
590
+ start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void {
581
591
  this.#inputHandler = onInput;
582
592
  this.#resizeHandler = onResize;
593
+ this.#disconnectHandler = onDisconnect;
583
594
 
584
595
  // Headless (tests): suppress every real-terminal side effect. Skip raw
585
596
  // mode, stdin listeners, capability probes, SIGWINCH, and emergency-restore
@@ -604,6 +615,9 @@ export class ProcessTerminal implements Terminal {
604
615
  process.stdin.setRawMode(true);
605
616
  }
606
617
  process.stdin.setEncoding("utf8");
618
+ process.stdin.on("end", this.#stdinEndHandler);
619
+ process.stdin.on("close", this.#stdinCloseHandler);
620
+ process.stdin.on("error", this.#stdinErrorHandler);
607
621
  process.stdin.resume();
608
622
 
609
623
  // Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
@@ -1425,6 +1439,10 @@ export class ProcessTerminal implements Terminal {
1425
1439
  process.stdin.removeListener("data", this.#stdinDataHandler);
1426
1440
  this.#stdinDataHandler = undefined;
1427
1441
  }
1442
+ process.stdin.removeListener("end", this.#stdinEndHandler);
1443
+ process.stdin.removeListener("close", this.#stdinCloseHandler);
1444
+ process.stdin.removeListener("error", this.#stdinErrorHandler);
1445
+ this.#disconnectHandler = undefined;
1428
1446
  this.#inputHandler = undefined;
1429
1447
  this.#appearance = undefined;
1430
1448
  if (this.#stdoutResizeListener) {
@@ -1450,10 +1468,26 @@ export class ProcessTerminal implements Terminal {
1450
1468
  this.#stdoutErrorCleanup ??= registerStdoutErrorHandler(this.#stdoutErrorHandler);
1451
1469
  }
1452
1470
 
1453
- #markTerminalWriteFailed(err: unknown): void {
1471
+ #markTerminalDisconnected(reason: string, err?: unknown): void {
1454
1472
  if (this.#dead) return;
1455
1473
  this.#dead = true;
1456
- logger.warn("terminal write failed; disabling terminal rendering", { err });
1474
+ logger.warn("terminal disconnected; stopping interactive rendering", { reason, err });
1475
+
1476
+ const disconnectHandler = this.#disconnectHandler;
1477
+ this.#disconnectHandler = undefined;
1478
+ if (!disconnectHandler) return;
1479
+ disconnectHandler();
1480
+
1481
+ if (process.platform === "win32") {
1482
+ void postmortem.quit(129);
1483
+ return;
1484
+ }
1485
+ try {
1486
+ process.kill(process.pid, "SIGHUP");
1487
+ } catch (signalErr) {
1488
+ logger.error("Failed to deliver terminal disconnect signal; exiting directly", { err: signalErr });
1489
+ void postmortem.quit(129);
1490
+ }
1457
1491
  }
1458
1492
 
1459
1493
  write(data: string): void {
@@ -1500,7 +1534,7 @@ export class ProcessTerminal implements Terminal {
1500
1534
  process.stdout.write(data);
1501
1535
  }
1502
1536
  } catch (err) {
1503
- this.#markTerminalWriteFailed(err);
1537
+ this.#markTerminalDisconnected("stdout failed", err);
1504
1538
  }
1505
1539
  }
1506
1540
 
package/src/tui.ts CHANGED
@@ -1571,7 +1571,9 @@ export class TUI extends Container {
1571
1571
  }
1572
1572
  this.#armMultiplexerResizeTimer(false);
1573
1573
  },
1574
+ () => this.stop(),
1574
1575
  );
1576
+ if (this.#stopped) return;
1575
1577
  for (const listener of this.#startListeners) {
1576
1578
  try {
1577
1579
  listener();
@@ -3725,15 +3727,23 @@ export class TUI extends Container {
3725
3727
  }
3726
3728
 
3727
3729
  /**
3728
- * Emit a throwaway viewport repaint for the resize fast path as an alternate-
3729
- * screen per-row overwrite. The normal buffer may reflow full-width rows on a
3730
- * width change before the app can repaint; keeping the drag on the alternate
3731
- * screen makes those transient resizes truncate instead of pushing wrapped
3732
- * fragments into native scrollback. Normal-screen history is rebuilt once at
3733
- * settle via `#emitFullPaint`.
3730
+ * Emit a throwaway viewport repaint for the resize fast path as a per-row
3731
+ * overwrite. A width change can make the terminal's normal buffer reflow
3732
+ * full-width rows before the app repaints, so a width drag borrows the
3733
+ * alternate screen: transient resizes truncate the viewport instead of
3734
+ * pushing wrapped fragments into native scrollback. A height-only resize
3735
+ * reflows nothing, so it repaints the normal screen in place — borrowing the
3736
+ * alt buffer there is pure flicker, and on terminals that re-report their
3737
+ * size when the alt buffer toggles it is self-sustaining: leaving a
3738
+ * fullscreen overlay's alt screen fires a height-only SIGWINCH echo, which
3739
+ * would otherwise re-borrow the alt buffer for one frame (the settings-exit
3740
+ * flash, #5854). Normal-screen history is rebuilt once at settle via
3741
+ * `#emitFullPaint`.
3734
3742
  */
3735
3743
  #emitResizeViewport(window: readonly string[], height: number, contentRows: number, width: number): void {
3736
- let buffer = `${this.#paintBeginSequence + this.#enterResizeAltSequence()}\x1b[H`;
3744
+ const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
3745
+ const altEnter = widthChanged ? this.#enterResizeAltSequence() : "";
3746
+ let buffer = `${this.#paintBeginSequence + altEnter}\x1b[H`;
3737
3747
  for (let r = 0; r < height; r++) {
3738
3748
  if (r > 0) buffer += "\r\n";
3739
3749
  buffer += this.#lineRewriteSequence(window[r] ?? "", width);