@oh-my-pi/pi-tui 17.4.2 → 18.0.1

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.
@@ -105,7 +105,7 @@ export class TerminalInfo {
105
105
  public readonly deccara: boolean = false,
106
106
  readonly supportsScreenToScrollback: boolean = false,
107
107
  /** Renders the Kitty OSC 66 text-sizing protocol (scaled spans). Kitty only. */
108
- public readonly textSizing: boolean = false,
108
+ public readonly supportsTextSizing: boolean = false,
109
109
  /**
110
110
  * Hangul Compatibility Jamo (U+3131..=U+318E) cell width. Ghostty follows
111
111
  * UAX#11 (2 cells); Warp paints 1; "platform" keeps the OS default
@@ -129,7 +129,13 @@ export class TerminalInfo {
129
129
  if (this.imageProtocol === ImageProtocol.Sixel) {
130
130
  return hasSixelDcsStart(line);
131
131
  }
132
- return hasNeedleBefore(line, this.imageProtocol, 64) || hasNeedleBefore(line, KITTY_PLACEHOLDER, 64);
132
+ // 512-unit window: placeholder cells can sit deep in a composed row —
133
+ // the composer attachment band prefixes each thumbnail row with border
134
+ // SGRs and stacks cards side by side, so the first placeholder of a
135
+ // later card starts hundreds of units in. Rows past the window would
136
+ // silently lose the verbatim image-line path (no truncation, no SGR
137
+ // coalescing) that placeholder grids and placement APCs rely on.
138
+ return hasNeedleBefore(line, this.imageProtocol, 512) || hasNeedleBefore(line, KITTY_PLACEHOLDER, 512);
133
139
  }
134
140
 
135
141
  formatNotification(message: string | TerminalNotification): string {
@@ -551,11 +557,14 @@ export interface RuntimeTerminal extends TerminalInfo {
551
557
  hyperlinks: boolean;
552
558
  deccara: boolean;
553
559
  supportsScreenToScrollback: boolean;
560
+ /** Whether OSC 66 text sizing is currently enabled. */
554
561
  textSizing: boolean;
555
562
  }
556
563
 
557
564
  export const TERMINAL: RuntimeTerminal = (() => {
558
565
  const resolved = getTerminalInfo(TERMINAL_ID).clone();
566
+ // Detection records support; hosts opt into OSC 66 separately.
567
+ resolved.textSizing = false;
559
568
 
560
569
  const forcedImageProtocol = getForcedImageProtocol();
561
570
  if (forcedImageProtocol !== undefined) {
@@ -612,7 +621,7 @@ export function setTerminalScreenToScrollback(enabled: boolean): void {
612
621
 
613
622
  /**
614
623
  * Enable/disable OSC 66 text-sizing at runtime. The coding-agent calls this from
615
- * the `tui.textSizing` setting (gated on the terminal's static `textSizing`
624
+ * the `tui.textSizing` setting (gated on the terminal's static `supportsTextSizing`
616
625
  * capability); tests flip it directly to exercise the scaled-heading path.
617
626
  */
618
627
  export function setTerminalTextSizing(enabled: boolean): void {
@@ -825,6 +834,14 @@ export function encodeKittyPlacementLine(options: {
825
834
  export function encodeKittyDeleteImage(imageId: number): string {
826
835
  return wrapTmuxPassthroughIfNeeded(`\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`);
827
836
  }
837
+ /**
838
+ * Delete every Kitty image and placement in the terminal. Used only by an
839
+ * explicit destructive display reset: text erases leave untracked placements
840
+ * painted, so per-image bookkeeping cannot guarantee a clean viewport.
841
+ */
842
+ export function encodeKittyDeleteAllImages(): string {
843
+ return wrapTmuxPassthroughIfNeeded("\x1b_Ga=d,d=A,q=2\x1b\\");
844
+ }
828
845
 
829
846
  /**
830
847
  * Delete a single placement of an image (`d=i`, lowercase): removes its cells
package/src/terminal.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { dlopen, FFIType, ptr } from "bun:ffi";
2
2
  import * as fs from "node:fs";
3
+ import { TtyWriter } from "@oh-my-pi/pi-natives";
3
4
  import {
4
5
  $env,
5
6
  isBunTestRuntime,
@@ -230,6 +231,20 @@ function registerPostmortemTerminalRestore(): void {
230
231
  export function setAltScreenActive(active: boolean): void {
231
232
  altScreenActive = active;
232
233
  }
234
+ /**
235
+ * Route an out-of-band escape sequence (e.g. an OSC title update) through the
236
+ * active terminal's output path. While a TUI owns stdout, frame paints go
237
+ * through the off-thread write pump and can split across multiple write(2)
238
+ * calls; a direct main-thread `process.stdout.write` can land between two of
239
+ * them — mid escape sequence — and the host terminal then prints the payload
240
+ * as literal text at the cursor position. Returns false when no terminal has
241
+ * started, in which case the caller owns stdout and may write directly.
242
+ */
243
+ export function writeThroughActiveTerminal(data: string): boolean {
244
+ if (!activeTerminal) return false;
245
+ activeTerminal.write(data);
246
+ return true;
247
+ }
233
248
 
234
249
  const stdoutErrorHandlers = new Set<(err: Error) => void>();
235
250
  let stdoutErrorListenerInstalled = false;
@@ -377,11 +392,37 @@ export function emergencyTerminalRestore(): void {
377
392
  }
378
393
  /** Terminal-reported appearance (dark/light mode). */
379
394
  export type TerminalAppearance = "dark" | "light";
395
+ /** Options for {@link Terminal.start}. */
396
+ export interface TerminalStartOptions {
397
+ /**
398
+ * Paint-only start: skip raw mode, stdin ownership, and every probe that
399
+ * elicits a response on stdin. The host tty keeps cooked-mode line editing
400
+ * (kernel echo lands at the hardware cursor), and typed bytes stay queued
401
+ * in the kernel until {@link Terminal.enableInput} takes ownership and
402
+ * replays them through `onInput`. Used for the startup prepaint so typing
403
+ * echoes even while module loading blocks the event loop.
404
+ */
405
+ deferInput?: boolean;
406
+ }
380
407
  /** Identity of an accepted explicit terminal appearance refresh request. */
381
408
  export type TerminalAppearanceRequestToken = number;
382
409
  export interface Terminal {
383
410
  // Start the terminal with input, resize, and host-disconnect handlers.
384
- start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void;
411
+ start(
412
+ onInput: (data: string) => void,
413
+ onResize: () => void,
414
+ onDisconnect?: () => void,
415
+ options?: TerminalStartOptions,
416
+ ): void;
417
+
418
+ /**
419
+ * Take ownership of stdin after a `deferInput` start: enable raw mode,
420
+ * attach input handlers, and run the capability probes start() skipped.
421
+ * Bytes the user typed in cooked mode meanwhile are replayed through
422
+ * `onInput`. No-op when input was never deferred. Optional so custom
423
+ * Terminals built against older pi-tui versions keep working.
424
+ */
425
+ enableInput?(): void;
385
426
 
386
427
  // Stop the terminal and restore state
387
428
  stop(): void;
@@ -400,6 +441,14 @@ export interface Terminal {
400
441
  // Get terminal dimensions
401
442
  get columns(): number;
402
443
  get rows(): number;
444
+ /**
445
+ * Output bytes accepted but not yet delivered to the terminal, when the
446
+ * implementation can report it. The renderer skips composing new frames
447
+ * while this backlog is deep, so a slow terminal receives only fresh
448
+ * frames instead of a queue of stale ones. Optional so custom Terminals
449
+ * built against older pi-tui versions keep working.
450
+ */
451
+ readonly pendingOutputBytes?: number;
403
452
 
404
453
  // Whether Kitty keyboard protocol is active
405
454
  get kittyProtocolActive(): boolean;
@@ -539,6 +588,8 @@ export class ProcessTerminal implements Terminal {
539
588
  #wasRaw = false;
540
589
  #inputHandler?: (data: string) => void;
541
590
  #resizeHandler?: () => void;
591
+ /** True between a `deferInput` start() and enableInput(). */
592
+ #inputDeferred = false;
542
593
  #stdoutResizeListener?: () => void;
543
594
  #kittyProtocolActive = false;
544
595
  #kittyEnableSeq: string | null = null;
@@ -578,6 +629,12 @@ export class ProcessTerminal implements Terminal {
578
629
  // and the writable buffer grows without bound as cosmetic frames pile up.
579
630
  // See OutputBacklogGuard and #6854.
580
631
  #stdoutBacklog = new OutputBacklogGuard();
632
+ // Off-thread output pump (unix TTYs): Bun's `process.stdout.write` blocks
633
+ // the event loop until the terminal drains, so a slow/occluded emulator
634
+ // froze the whole TUI for the duration of a multi-MB repaint. The pump
635
+ // enqueues frames and performs the blocking write(2) on its own thread;
636
+ // `pendingOutputBytes` exposes the backlog for render-side frame skipping.
637
+ #outputPump?: TtyWriter;
581
638
  #stdoutDrainArmed = false;
582
639
  #stdoutDrainHandler = () => {
583
640
  this.#stdoutDrainArmed = false;
@@ -697,7 +754,12 @@ export class ProcessTerminal implements Terminal {
697
754
  this.#privateModeCallbacks.push(callback);
698
755
  }
699
756
 
700
- start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void {
757
+ start(
758
+ onInput: (data: string) => void,
759
+ onResize: () => void,
760
+ onDisconnect?: () => void,
761
+ options?: TerminalStartOptions,
762
+ ): void {
701
763
  this.#inputHandler = onInput;
702
764
  this.#resizeHandler = onResize;
703
765
  this.#disconnectHandler = onDisconnect;
@@ -715,12 +777,65 @@ export class ProcessTerminal implements Terminal {
715
777
  // Register for emergency cleanup
716
778
  activeTerminal = this;
717
779
  terminalEverStarted = true;
780
+ // Own the blocking write(2) on a pump thread (unix TTYs only). A stale
781
+ // prebuilt natives module without the export falls back to direct writes.
782
+ // Test suites spy on `process.stdout.write` with a faked isTTY, so the
783
+ // pump stays off under `bun test` — same philosophy as isTerminalHeadless.
784
+ if (process.platform !== "win32" && process.stdout.isTTY && !isBunTestRuntime() && !this.#outputPump) {
785
+ try {
786
+ this.#outputPump = new TtyWriter(1);
787
+ } catch (err) {
788
+ logger.debug("tty output pump unavailable; using direct stdout writes", { err: String(err) });
789
+ }
790
+ }
718
791
 
719
792
  // Keep unmanaged fd-2 writes (macOS libmalloc/framework diagnostics) off
720
793
  // the viewport while we own the terminal; released in stop(). See
721
794
  // stderr-guard in pi-utils (mirrors openai/codex#24459).
722
795
  suppressTerminalStderr();
723
796
 
797
+ // Set up resize handler immediately. The OS refreshes process.stdout
798
+ // dimensions before firing `resize`, so it is authoritative for geometry:
799
+ // reconcile any stale cached DEC 2048 report before notifying the renderer.
800
+ this.#stdoutResizeListener = () => {
801
+ // Conservative: some hosts reset modes across a resize/reattach, so
802
+ // re-establish cursor visibility on the next explicit call.
803
+ this.#cursorVisible = undefined;
804
+ this.#reconcileInBandGeometryOnResize();
805
+ this.#resizeHandler?.();
806
+ };
807
+ process.stdout.on("resize", this.#stdoutResizeListener);
808
+
809
+ // Refresh terminal dimensions - they may be stale after suspend/resume
810
+ // (SIGWINCH is lost while process is stopped). Unix only.
811
+ if (process.platform !== "win32") {
812
+ process.kill(process.pid, "SIGWINCH");
813
+ }
814
+
815
+ setHangulCompatibilityJamoWidth(TERMINAL.hangulJamoWidth);
816
+
817
+ if (options?.deferInput) {
818
+ this.#inputDeferred = true;
819
+ return;
820
+ }
821
+ this.#attachInput();
822
+ }
823
+
824
+ enableInput(): void {
825
+ if (!this.#inputDeferred) return;
826
+ this.#inputDeferred = false;
827
+ if (this.#headless || this.#dead) return;
828
+ this.#attachInput();
829
+ }
830
+
831
+ /**
832
+ * Own stdin: raw mode, input listeners, and the capability probes that
833
+ * elicit stdin responses. Split from start() so a `deferInput` prepaint can
834
+ * leave the tty in cooked mode (kernel echo + line editing) while startup
835
+ * module loading blocks the event loop, then adopt the kernel-buffered
836
+ * keystrokes here once the app can process them.
837
+ */
838
+ #attachInput(): void {
724
839
  // A multiplexer or SSH disconnect can leave isTTY true after its pty has
725
840
  // been revoked. Raw mode is then impossible, so take the normal terminal
726
841
  // disconnect path rather than letting Bun abort startup with EIO.
@@ -751,24 +866,6 @@ export class ProcessTerminal implements Terminal {
751
866
  // See #6374.
752
867
  this.#safeWrite("\x1b[?1l\x1b>");
753
868
 
754
- // Set up resize handler immediately. The OS refreshes process.stdout
755
- // dimensions before firing `resize`, so it is authoritative for geometry:
756
- // reconcile any stale cached DEC 2048 report before notifying the renderer.
757
- this.#stdoutResizeListener = () => {
758
- // Conservative: some hosts reset modes across a resize/reattach, so
759
- // re-establish cursor visibility on the next explicit call.
760
- this.#cursorVisible = undefined;
761
- this.#reconcileInBandGeometryOnResize();
762
- this.#resizeHandler?.();
763
- };
764
- process.stdout.on("resize", this.#stdoutResizeListener);
765
-
766
- // Refresh terminal dimensions - they may be stale after suspend/resume
767
- // (SIGWINCH is lost while process is stopped). Unix only.
768
- if (process.platform !== "win32") {
769
- process.kill(process.pid, "SIGWINCH");
770
- }
771
-
772
869
  // On Windows, enable ENABLE_VIRTUAL_TERMINAL_INPUT so the console sends
773
870
  // VT escape sequences (e.g. \x1b[Z for Shift+Tab) instead of raw console
774
871
  // events that lose modifier information. Must run after setRawMode(true)
@@ -781,8 +878,6 @@ export class ProcessTerminal implements Terminal {
781
878
  // Explicit probes are safe only after their response parser and stdin
782
879
  // data handler are installed. Keep this false throughout temporary stops.
783
880
  this.#active = true;
784
- setHangulCompatibilityJamoWidth(TERMINAL.hangulJamoWidth);
785
-
786
881
  // Query terminal background color via OSC 11 for dark/light detection.
787
882
  // Uses DA1 (Primary Device Attributes) as a sentinel: terminals process
788
883
  // sequences in order, so if DA1 arrives before OSC 11 response,
@@ -1530,6 +1625,7 @@ export class ProcessTerminal implements Terminal {
1530
1625
  stop(): void {
1531
1626
  // Suppress observer/timer callbacks before any teardown can yield or throw.
1532
1627
  this.#active = false;
1628
+ this.#inputDeferred = false;
1533
1629
  if (this.#headless) return;
1534
1630
  // Unregister from emergency cleanup
1535
1631
  if (activeTerminal === this) {
@@ -1649,6 +1745,13 @@ export class ProcessTerminal implements Terminal {
1649
1745
  }
1650
1746
  this.#stdoutBacklog.reset();
1651
1747
  this.#resizeHandler = undefined;
1748
+ // Flush the restore sequences enqueued above (bounded — a stalled PTY
1749
+ // must not wedge exit), then retire the pump. Later writes (emergency
1750
+ // restore's showCursor) fall back to direct stdout writes.
1751
+ if (this.#outputPump) {
1752
+ this.#outputPump.stop(1000);
1753
+ this.#outputPump = undefined;
1754
+ }
1652
1755
 
1653
1756
  // Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
1654
1757
  // re-interpreted after raw mode is disabled. This fixes a race condition
@@ -1724,6 +1827,23 @@ export class ProcessTerminal implements Terminal {
1724
1827
  if (!process.stdout.isTTY) return;
1725
1828
  this.#ensureStdoutErrorHandler();
1726
1829
  this.#trackCursorVisibility(data);
1830
+ const pump = this.#outputPump;
1831
+ if (pump) {
1832
+ if (pump.dead) {
1833
+ this.#markTerminalDisconnected("stdout failed; output pump died");
1834
+ return;
1835
+ }
1836
+ try {
1837
+ // Same stalled-consumer bound as the stream path (#6854): a PTY reader
1838
+ // that never drains must tear the terminal down, not grow the queue.
1839
+ if (pump.write(data) > MAX_STDOUT_BACKLOG_BYTES) {
1840
+ this.#markTerminalDisconnected("stdout backlog exceeded cap; PTY consumer stalled");
1841
+ }
1842
+ } catch (err) {
1843
+ this.#markTerminalDisconnected("stdout failed", err);
1844
+ }
1845
+ return;
1846
+ }
1727
1847
  // A console-sharing child process may have flipped the console codepage
1728
1848
  // away from UTF-8; repair it before any bytes hit WriteFile so no frame
1729
1849
  // is ever translated through an OEM codepage. See ensureWindowsConsoleUtf8.
@@ -1771,6 +1891,11 @@ export class ProcessTerminal implements Terminal {
1771
1891
  if (this.#inBandResizeActive && this.#reportedColumns) return this.#reportedColumns;
1772
1892
  return process.stdout.columns || Number(Bun.env.COLUMNS) || 80;
1773
1893
  }
1894
+ get pendingOutputBytes(): number {
1895
+ if (this.#outputPump) return this.#outputPump.pending();
1896
+ // Stream fallback: bytes queued past the high-water mark by refused writes.
1897
+ return process.stdout.writableLength ?? 0;
1898
+ }
1774
1899
 
1775
1900
  get rows(): number {
1776
1901
  if (this.#inBandResizeActive && this.#reportedRows) return this.#reportedRows;