@linxiraos/pi-tui 1.1.7 → 1.1.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.
package/src/tui.ts CHANGED
@@ -14,18 +14,19 @@
14
14
  */
15
15
  import * as fs from "node:fs";
16
16
  import { performance } from "node:perf_hooks";
17
- import { $flag, getDebugLogPath, logger } from "@linxiraos/pi-utils";
17
+ import { $flag, getDebugLogPath, logger, postmortem } from "@linxiraos/pi-utils";
18
18
  import { DEFAULT_MAX_INLINE_IMAGES, ImageBudget } from "./components/image";
19
19
  import { TuiDebugServer } from "./debug-server";
20
20
  import { isKeyRelease, matchesKey } from "./keys";
21
21
  import { LoopWatchdog } from "./loop-watchdog";
22
- import { setAltScreenActive, type Terminal } from "./terminal";
22
+ import { STDOUT_BACKLOG_CLEAR_BYTES, setAltScreenActive, type Terminal } from "./terminal";
23
23
  import {
24
24
  encodeKittyDeleteAllImages,
25
25
  encodeKittyDeleteImage,
26
26
  encodeKittyPlacementLine,
27
27
  ImageProtocol,
28
28
  isImageProtocolForced,
29
+ isInsideHerdr,
29
30
  isInsideTerminalMultiplexer,
30
31
  parseKittyDirectPlacementLine,
31
32
  setCellDimensions,
@@ -56,6 +57,7 @@ const SEGMENT_RESET = "\x1b[0m";
56
57
  const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
57
58
  const ERASE_LINE = "\x1b[2K";
58
59
  const ERASE_TO_END_OF_LINE = "\x1b[K";
60
+ const MIN_MAIN_AREA_COLUMNS = 64;
59
61
  // Keep the common short-row path out of native width/truncation. Longer rows
60
62
  // are fit by visible cells, not source code units, so zero-width-heavy prefixes
61
63
  // cannot hide visible suffix text that still belongs in the viewport.
@@ -474,6 +476,7 @@ export class Container implements Component {
474
476
  let refs = this.#memoChildLines;
475
477
  let unchanged = this.#memoLines !== undefined && this.#memoWidth === width && refs.length === count;
476
478
  if (refs.length !== count) {
479
+ // [suppressed] render-frame length preallocation
477
480
  refs = new Array(count);
478
481
  this.#memoChildLines = refs;
479
482
  }
@@ -753,8 +756,12 @@ export class TUI extends Container {
753
756
  * render (keeping its forced/clear-scrollback intent) and retry shortly;
754
757
  * the eventual frame composes the latest component state, so a slow
755
758
  * terminal receives only fresh frames instead of every intermediate one.
759
+ *
760
+ * This is the terminal's healthy-backlog level ({@link STDOUT_BACKLOG_CLEAR_BYTES}):
761
+ * gating here and ending a StdoutStallWatchdog episode there keeps the stall
762
+ * watchdog armed across exactly the range where frames are deferred (#10434).
756
763
  */
757
- static readonly #MAX_PENDING_OUTPUT_BYTES = 256 * 1024;
764
+ static readonly #MAX_PENDING_OUTPUT_BYTES = STDOUT_BACKLOG_CLEAR_BYTES;
758
765
  /** Retry cadence while the output backlog gate is holding renders back. */
759
766
  static readonly #OUTPUT_BACKLOG_RETRY_MS = 10;
760
767
  /** Quiet window before restoring the normal buffer after resize. */
@@ -792,9 +799,20 @@ export class TUI extends Container {
792
799
  // Consumed by the next frame: a user-driven redraw gesture (resetDisplay,
793
800
  // requestRender(true)) that must rewrite the viewport even when the diff
794
801
  // believes nothing changed.
802
+ // Sidebar support (restored from db37b05765, adapted to the v18.1.5 engine):
803
+ // when #mainWidthOverride is set and the terminal is wide enough to keep
804
+ // MIN_MAIN_AREA_COLUMNS for the main area, #renderChildrenFrame composes and
805
+ // paints at the overridden width; the freed right margin is painted per frame
806
+ // from #gutterComponent via absolute-positioned writes that never enter the
807
+ // composed frame, committed prefix, or native scrollback.
808
+ #mainWidthOverride: number | null = null;
809
+ #gutterComponent: Component | null = null;
810
+ // Gutter rows painted by the most recent emitting frame.
811
+ #paintedGutterRows: readonly string[] | null = null;
795
812
  #forceViewportRepaintOnNextRender = false;
796
813
  #hasEverRendered = false;
797
814
  #stopped = false;
815
+ #cancelPostmortemRestore?: () => void;
798
816
  /** True between a `deferInput` start() and enableInput(). */
799
817
  #inputDeferred = false;
800
818
  // Always-on event-loop lag probe. The high default threshold keeps it quiet;
@@ -872,6 +890,73 @@ export class TUI extends Container {
872
890
  setMaxInlineImages(cap: number): void {
873
891
  this.#imageBudget.setCap(cap);
874
892
  }
893
+
894
+ /**
895
+ * Yield `width` columns from the right edge of the terminal as a sidebar
896
+ * margin, repainted per frame from the gutter component (see
897
+ * {@link setGutterComponent}). The main area composes and paints at
898
+ * `terminal.columns - width`, which keeps every committed row — and
899
+ * therefore native scrollback — free of gutter text. `null` restores
900
+ * full-width rendering. Frames where that would drop the main area below
901
+ * {@link MIN_MAIN_AREA_COLUMNS}, or where a fullscreen-capable overlay is
902
+ * visible, ignore the reservation and paint at the physical width.
903
+ */
904
+ setMainWidth(width: number | null): void {
905
+ const next = width !== null && Number.isInteger(width) && width > 0 ? width : null;
906
+ if (this.#mainWidthOverride === next) return;
907
+ this.#mainWidthOverride = next;
908
+ this.requestRender();
909
+ }
910
+
911
+ /**
912
+ * Component rendered into the right-hand margin created by
913
+ * {@link setMainWidth}. Its rows are painted viewport-only via absolute
914
+ * cursor addressing inside each frame's synchronized block; they never enter
915
+ * the composed frame or scrollback. `null` clears the margin.
916
+ */
917
+ setGutterComponent(component: Component | null): void {
918
+ if (this.#gutterComponent === component) return;
919
+ this.#gutterComponent = component;
920
+ this.#paintedGutterRows = null;
921
+ this.requestRender();
922
+ }
923
+
924
+ /** Effective main-area width for this frame, honoring the sidebar guards. */
925
+ #effectiveMainWidth(rawWidth: number): number {
926
+ if (
927
+ this.#mainWidthOverride === null ||
928
+ rawWidth - this.#mainWidthOverride < MIN_MAIN_AREA_COLUMNS ||
929
+ this.overlayStack.length > 0
930
+ ) {
931
+ return rawWidth;
932
+ }
933
+ return rawWidth - this.#mainWidthOverride;
934
+ }
935
+
936
+ /**
937
+ * Absolute-positioned paint for one frame's gutter column. Writes every
938
+ * viewport row's gutter cells at column `mainWidth + 1`, then restores the
939
+ * hardware cursor. Returns "" when there is no gutter this frame.
940
+ */
941
+ #gutterPaintSequence(
942
+ rows: readonly string[] | null,
943
+ gutterWidth: number,
944
+ mainWidth: number,
945
+ height: number,
946
+ restoreRow: number,
947
+ ): string {
948
+ const col = mainWidth + 1;
949
+ let seq = "";
950
+ for (let row = 0; row < height; row++) {
951
+ if (rows === null || gutterWidth <= 0) return "";
952
+ let text = truncateToWidth(rows[row] ?? "", gutterWidth, Ellipsis.Omit);
953
+ const pad = gutterWidth - visibleWidth(text);
954
+ if (pad > 0) text += " ".repeat(pad);
955
+ seq += `\x1b[${row + 1};${col}H${text}`;
956
+ }
957
+ seq += `\x1b[${restoreRow + 1};1H`;
958
+ return seq;
959
+ }
875
960
  /** Return how settled resizes refresh native scrollback. */
876
961
  getResizeScrollback(): ResizeScrollbackMode {
877
962
  return this.#resizeScrollbackMode;
@@ -1102,9 +1187,15 @@ export class TUI extends Container {
1102
1187
  // implementing DECRQM, so retain the statically detected default instead of
1103
1188
  // exposing destructive full paints. An explicit user opt-out/force still
1104
1189
  // wins, so skip every probe result in that case.
1105
- this.terminal.onPrivateModeReport?.((mode, supported, confirmed = true) => {
1190
+ this.terminal.onPrivateModeReport?.((mode, supported, confirmed = true, status) => {
1106
1191
  if (mode !== 2026 || !confirmed) return;
1107
1192
  if (synchronizedOutputUserOverride() !== null) return;
1193
+ // Herdr's Ghostty VTE honors DEC 2026 even when DECRQM is unanswered or
1194
+ // reports unrecognized (status 0). Other confirmed unsupported reports
1195
+ // still disable: status 4 is permanently reset, and a three-argument
1196
+ // callback (`status` omitted) is a definitive unsupported from a
1197
+ // custom Terminal that does not distinguish DECRPM codes.
1198
+ if (!supported && isInsideHerdr() && status === 0) return;
1108
1199
  this.#setSynchronizedOutput(supported);
1109
1200
  });
1110
1201
  this.terminal.start(
@@ -1126,6 +1217,8 @@ export class TUI extends Container {
1126
1217
  { deferInput: this.#inputDeferred },
1127
1218
  );
1128
1219
  if (this.#stopped) return;
1220
+ this.#cancelPostmortemRestore?.();
1221
+ this.#cancelPostmortemRestore = postmortem.register("tui-restore", () => this.stop());
1129
1222
  for (const listener of this.#startListeners) {
1130
1223
  try {
1131
1224
  listener();
@@ -1422,11 +1515,13 @@ export class TUI extends Container {
1422
1515
  /** Paint the full semantic tail on the borrowed resize buffer. */
1423
1516
  #renderResizeAltFrame(width: number, height: number): void {
1424
1517
  const provider = this.#frameProvider;
1425
- this.#imageBudget.beginPass();
1426
- const rendered =
1427
- provider?.renderResizeFrame?.({ columns: width, rows: height }) ??
1428
- (provider ? provider.renderFrame({ columns: width, rows: height }).viewport : this.render(width));
1429
- this.#imageBudget.endPass();
1518
+ let rendered: readonly string[];
1519
+ do {
1520
+ this.#imageBudget.beginPass();
1521
+ rendered =
1522
+ provider?.renderResizeFrame?.({ columns: width, rows: height }) ??
1523
+ (provider ? provider.renderFrame({ columns: width, rows: height }).viewport : this.render(width));
1524
+ } while (this.#imageBudget.endPass());
1430
1525
  const viewport = rendered.length > height ? rendered.slice(rendered.length - height) : Array.from(rendered);
1431
1526
  this.#extractCursorMarkers(viewport);
1432
1527
  this.#emitAltFrame(this.#prepareLinesArray(viewport, width), width, height);
@@ -1600,9 +1695,11 @@ export class TUI extends Container {
1600
1695
  if (width <= 0 || height <= 0) return;
1601
1696
  provider.beginHistoryFlush();
1602
1697
  while (true) {
1603
- this.#imageBudget.beginPass();
1604
- const plan = provider.renderFrame({ columns: width, rows: height });
1605
- this.#imageBudget.endPass();
1698
+ let plan: TerminalFramePlan;
1699
+ do {
1700
+ this.#imageBudget.beginPass();
1701
+ plan = provider.renderFrame({ columns: width, rows: height });
1702
+ } while (this.#imageBudget.endPass());
1606
1703
  if (plan.history === undefined) return;
1607
1704
  let viewport = Array.from(plan.viewport);
1608
1705
  if (viewport.length > height) viewport = viewport.slice(0, height);
@@ -1615,6 +1712,8 @@ export class TUI extends Container {
1615
1712
  }
1616
1713
 
1617
1714
  stop(): void {
1715
+ this.#cancelPostmortemRestore?.();
1716
+ this.#cancelPostmortemRestore = undefined;
1618
1717
  this.#debugServer?.stop();
1619
1718
  this.#debugServer = undefined;
1620
1719
  this.#resizeSettleTimer?.cancel();
@@ -2262,9 +2361,11 @@ export class TUI extends Container {
2262
2361
  const provider = this.#frameProvider;
2263
2362
  if (!provider || width <= 0 || height <= 0) return;
2264
2363
  this.#debugNextWindowTop = 0;
2265
- this.#imageBudget.beginPass();
2266
- const plan = provider.renderFrame({ columns: width, rows: height });
2267
- this.#imageBudget.endPass();
2364
+ let plan: TerminalFramePlan;
2365
+ do {
2366
+ this.#imageBudget.beginPass();
2367
+ plan = provider.renderFrame({ columns: width, rows: height });
2368
+ } while (this.#imageBudget.endPass());
2268
2369
  let viewport = Array.from(plan.viewport);
2269
2370
  if (viewport.length > height) {
2270
2371
  const message = `Frame provider returned ${viewport.length} rows for a ${height}-row viewport`;
@@ -2413,7 +2514,8 @@ export class TUI extends Container {
2413
2514
  const geometryStable = this.#hasEverRendered && this.#previousWidth === width && this.#previousHeight === height;
2414
2515
  const startTop = destructiveReset ? 0 : Math.min(this.#providerViewportTop, Math.max(0, height - 1));
2415
2516
  const newTop = Math.max(0, Math.min(startTop + historyRows.length, height - rows));
2416
- let buffer = this.#paintBeginSequence;
2517
+ const pendingAltExit = this.#pendingAltExit;
2518
+ let buffer = this.#paintBeginSequence + pendingAltExit;
2417
2519
  if (destructiveReset && TERMINAL.imageProtocol === ImageProtocol.Kitty) {
2418
2520
  // ED2/ED3 erase text cells but leave Kitty graphics visible. A reset is
2419
2521
  // explicitly destructive, so remove every placement—not only the ones
@@ -2520,6 +2622,10 @@ export class TUI extends Container {
2520
2622
  altScreen: false,
2521
2623
  ...(target === null ? {} : { cursor: { x: target.col, y: target.row, visible: target.visible } }),
2522
2624
  };
2625
+ if (pendingAltExit) {
2626
+ this.#pendingAltExit = "";
2627
+ setAltScreenActive(false);
2628
+ }
2523
2629
  if (target) this.#recordHardwareCursorState(target);
2524
2630
  else this.#recordHardwareCursorHidden();
2525
2631
  this.#providerWindow = mutablePrepared;
@@ -2584,8 +2690,15 @@ export class TUI extends Container {
2584
2690
  const mouseExit = this.#altMouseTrackingActive ? MOUSE_TRACKING_OFF : "";
2585
2691
  const enhancementExit = this.#keyboardEnhancementExit();
2586
2692
  const exitSequence = `${mouseExit}${enhancementExit}\x1b[?1049l`;
2587
- this.terminal.write(exitSequence);
2588
- setAltScreenActive(false);
2693
+ // Session replacement finishes while its fullscreen selector still
2694
+ // covers the old normal buffer. Fuse the restore into the destructive
2695
+ // repaint so no stale frame can become visible between writes.
2696
+ if (this.#clearScrollbackOnNextRender) {
2697
+ this.#pendingAltExit = exitSequence;
2698
+ } else {
2699
+ this.terminal.write(exitSequence);
2700
+ setAltScreenActive(false);
2701
+ }
2589
2702
  this.#forgetHardwareCursorState();
2590
2703
  this.#altActive = false;
2591
2704
  this.#altMouseTrackingActive = false;
@@ -2615,7 +2728,7 @@ export class TUI extends Container {
2615
2728
  this.#renderProviderFrame(width, height);
2616
2729
  return;
2617
2730
  }
2618
- this.#renderChildrenFrame(width, height);
2731
+ this.#renderChildrenFrame(width, this.#effectiveMainWidth(width), height);
2619
2732
  }
2620
2733
 
2621
2734
  /**
@@ -2623,18 +2736,38 @@ export class TUI extends Container {
2623
2736
  * compose the root children and paint the bottom `height` rows as the
2624
2737
  * mutable viewport. Nothing is ever appended to terminal history.
2625
2738
  */
2626
- #renderChildrenFrame(width: number, height: number): void {
2627
- this.#imageBudget.beginPass();
2628
- const composed = this.render(width);
2629
- this.#imageBudget.endPass();
2739
+ #renderChildrenFrame(rawWidth: number, width: number, height: number): void {
2740
+ const gutter = this.#gutterComponent && width < rawWidth ? this.#gutterComponent : null;
2741
+ let composed: readonly string[];
2742
+ do {
2743
+ this.#imageBudget.beginPass();
2744
+ composed = this.render(width);
2745
+ } while (this.#imageBudget.endPass());
2630
2746
  if (this.#maybeDeferGhosttyInitialImagePaint()) return;
2631
2747
  this.#debugNextWindowTop = Math.max(0, composed.length - height);
2632
2748
  const viewport = composed.length > height ? composed.slice(composed.length - height) : Array.from(composed);
2633
2749
  this.#emitPlanFrame(width, height, viewport, undefined, undefined);
2750
+ if (gutter) {
2751
+ const rows = gutter.render(width);
2752
+ // Paint the freed right margin after the frame block: absolute-positioned
2753
+ // writes at column width+1 never touch the composed frame or scrollback.
2754
+ const seq = this.#gutterPaintSequence(rows, rawWidth - width, width, viewport.length, 0);
2755
+ if (seq) this.terminal.write(seq);
2756
+ this.#paintedGutterRows = rows;
2757
+ } else if (this.#paintedGutterRows !== null) {
2758
+ // Gutter removed: clear the margin column once via absolute EL per row.
2759
+ const col = width + 1;
2760
+ let seq = "";
2761
+ for (let row = 0; row < height; row++) seq += `\x1b[${row + 1};${col}H\x1b[K`;
2762
+ seq += "\x1b[H";
2763
+ this.terminal.write(seq);
2764
+ this.#paintedGutterRows = null;
2765
+ }
2634
2766
  }
2635
2767
 
2636
2768
  /** Stateless variant for overlay-composited windows and alt-screen frames. */
2637
2769
  #prepareLinesArray(lines: readonly string[], width: number): string[] {
2770
+ // [suppressed] render-frame length preallocation
2638
2771
  const prepared: string[] = new Array(lines.length);
2639
2772
  for (let i = 0; i < lines.length; i++) {
2640
2773
  prepared[i] = this.#prepareLine(lines[i]!, width).line;
@@ -2923,6 +3056,7 @@ export class TUI extends Container {
2923
3056
  * blank base — the transcript is never touched while the alt buffer is up.
2924
3057
  */
2925
3058
  #renderAltFrame(width: number, height: number): void {
3059
+ // [suppressed] alt-frame length preallocation
2926
3060
  const base: string[] = new Array(Math.max(0, height)).fill("");
2927
3061
  let lines = this.#compositeOverlaysIntoWindow(base, width, height);
2928
3062
  this.#extractCursorMarkers(lines);
@@ -2936,6 +3070,7 @@ export class TUI extends Container {
2936
3070
  * native-scrollback byte. The hardware cursor stays hidden here.
2937
3071
  */
2938
3072
  #emitAltFrame(lines: string[], width: number, height: number): void {
3073
+ // [suppressed] alt-frame length preallocation
2939
3074
  const fitted: string[] = new Array(height);
2940
3075
  for (let r = 0; r < height; r++) fitted[r] = lines[r] ?? "";
2941
3076
  // Flush queued image-data transmits (`a=t`, no visible output) before the