@oh-my-pi/pi-tui 18.0.7 → 18.0.9

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 (41) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/types/components/box.d.ts +2 -0
  3. package/dist/types/components/cancellable-loader.d.ts +2 -0
  4. package/dist/types/components/editor.d.ts +4 -0
  5. package/dist/types/components/image.d.ts +2 -0
  6. package/dist/types/components/input.d.ts +2 -0
  7. package/dist/types/components/loader.d.ts +2 -0
  8. package/dist/types/components/markdown.d.ts +2 -0
  9. package/dist/types/components/scroll-view.d.ts +2 -0
  10. package/dist/types/components/select-list.d.ts +2 -0
  11. package/dist/types/components/settings-list.d.ts +4 -0
  12. package/dist/types/components/spacer.d.ts +2 -0
  13. package/dist/types/components/tab-bar.d.ts +2 -0
  14. package/dist/types/components/text.d.ts +2 -0
  15. package/dist/types/components/truncated-text.d.ts +2 -0
  16. package/dist/types/debug-server.d.ts +27 -0
  17. package/dist/types/index.d.ts +1 -0
  18. package/dist/types/latex-to-unicode.d.ts +4 -4
  19. package/dist/types/terminal.d.ts +11 -0
  20. package/dist/types/tui.d.ts +30 -0
  21. package/package.json +3 -3
  22. package/src/components/box.ts +10 -0
  23. package/src/components/cancellable-loader.ts +7 -0
  24. package/src/components/editor.ts +28 -0
  25. package/src/components/image.ts +11 -0
  26. package/src/components/input.ts +13 -0
  27. package/src/components/loader.ts +21 -5
  28. package/src/components/markdown.ts +39 -10
  29. package/src/components/scroll-view.ts +12 -0
  30. package/src/components/select-list.ts +13 -0
  31. package/src/components/settings-list.ts +19 -0
  32. package/src/components/spacer.ts +4 -0
  33. package/src/components/tab-bar.ts +11 -0
  34. package/src/components/text.ts +11 -0
  35. package/src/components/truncated-text.ts +15 -1
  36. package/src/debug-server.ts +422 -0
  37. package/src/index.ts +2 -0
  38. package/src/latex-to-unicode.ts +401 -172
  39. package/src/terminal.ts +22 -2
  40. package/src/tui.ts +115 -3
  41. package/src/utils.ts +10 -1
package/src/terminal.ts CHANGED
@@ -581,6 +581,17 @@ function isPrivateModeSupported(status: string): boolean {
581
581
  return status !== "0" && status !== "4";
582
582
  }
583
583
 
584
+ /** Construction-time overrides for {@link ProcessTerminal}. */
585
+ export interface ProcessTerminalOptions {
586
+ /**
587
+ * Force ConPTY-hosted behavior on or off. Defaults to live detection via
588
+ * {@link isConPTYHosted}. Tests set this so the kitty-flag and write-chunking
589
+ * paths stay hermetic regardless of the ambient WSL env (`WSL_DISTRO_NAME` /
590
+ * `WSL_INTEROP`) — the suite must behave identically on WSL and on CI.
591
+ */
592
+ conpty?: boolean;
593
+ }
594
+
584
595
  /**
585
596
  * Real terminal using process.stdin/stdout
586
597
  */
@@ -619,6 +630,11 @@ export class ProcessTerminal implements Terminal {
619
630
  // terminal side effect (writes, probes, raw mode, SIGWINCH, timers) is
620
631
  // suppressed. Defaults on under `bun test` — see isTerminalHeadless().
621
632
  #headless = isTerminalHeadless();
633
+ // Captured once at construction: whether stdout flows through a ConPTY
634
+ // pseudo-console. Gates the kitty-flag choice (#1216) and large-write
635
+ // chunking (#safeWrite). Live-detected by default; tests inject a fixed
636
+ // value so WSL env does not change behavior. See {@link ProcessTerminalOptions}.
637
+ readonly #conpty: boolean;
622
638
  #writeLogPath = $env.PI_TUI_WRITE_LOG || "";
623
639
  #stdoutErrorCleanup?: () => void;
624
640
  #stdoutErrorHandler = (err: Error) => {
@@ -674,6 +690,10 @@ export class ProcessTerminal implements Terminal {
674
690
  #windowsTerminalAppearancePollTimer?: Timer;
675
691
  #progressTimer?: Timer;
676
692
 
693
+ constructor(options?: ProcessTerminalOptions) {
694
+ this.#conpty = options?.conpty ?? isConPTYHosted();
695
+ }
696
+
677
697
  get kittyProtocolActive(): boolean {
678
698
  return this.#kittyProtocolActive;
679
699
  }
@@ -1213,7 +1233,7 @@ export class ProcessTerminal implements Terminal {
1213
1233
  const reportedFlags = parseInt(match[1]!, 10);
1214
1234
  this.#kittyProtocolActive = true;
1215
1235
  setKittyProtocolActive(true);
1216
- if (isConPTYHosted()) {
1236
+ if (this.#conpty) {
1217
1237
  // ConPTY (native Windows and WSL) drops Shift+letter keypresses
1218
1238
  // entirely when flag 4 (report alternate keys) is set. Use flag 1
1219
1239
  // (disambiguate only), preserving flag 2 if already active.
@@ -1863,7 +1883,7 @@ export class ProcessTerminal implements Terminal {
1863
1883
  // threshold. See #2034 and #2095.
1864
1884
  const bytes = Buffer.byteLength(data, "utf8");
1865
1885
  let accepted: boolean;
1866
- if (isConPTYHosted() && bytes > MAX_CONPTY_WRITE_CHUNK_BYTES) {
1886
+ if (this.#conpty && bytes > MAX_CONPTY_WRITE_CHUNK_BYTES) {
1867
1887
  accepted = true;
1868
1888
  for (const chunk of chunkForConPTY(data, MAX_CONPTY_WRITE_CHUNK_BYTES)) {
1869
1889
  if (this.#dead) break;
package/src/tui.ts CHANGED
@@ -16,6 +16,7 @@ import * as fs from "node:fs";
16
16
  import { performance } from "node:perf_hooks";
17
17
  import { $flag, getDebugLogPath, logger } from "@oh-my-pi/pi-utils";
18
18
  import { DEFAULT_MAX_INLINE_IMAGES, ImageBudget } from "./components/image";
19
+ import { TuiDebugServer } from "./debug-server";
19
20
  import { isKeyRelease, matchesKey } from "./keys";
20
21
  import { LoopWatchdog } from "./loop-watchdog";
21
22
  import { setAltScreenActive, type Terminal } from "./terminal";
@@ -181,6 +182,15 @@ const DEFAULT_RENDER_SCHEDULER: RenderScheduler = {
181
182
  * which leading rows survived.
182
183
  */
183
184
  export interface Component {
185
+ /** Stable identifier surfaced in the debug tree as kind#id. */
186
+ debugId?: string;
187
+ /** Override for the tree node kind (default: constructor.name). */
188
+ debugKind?: string;
189
+ /** Widget state for the debug `values`/`tree` ops. JSON-serializable. */
190
+ debugState?(): Record<string, unknown>;
191
+ /** Children for the debug tree when not already exposed as a public `children` array. */
192
+ debugChildren?: readonly Component[];
193
+
184
194
  /**
185
195
  * Render the component to an array of physical rows at the given width.
186
196
  * The result is component-owned and `readonly` to the caller; an unchanged
@@ -699,6 +709,16 @@ export class TUI extends Container {
699
709
  #previousWidth = 0;
700
710
  #previousHeight = 0;
701
711
  #focusedComponent: Component | null = null;
712
+ #debugServer: TuiDebugServer | undefined;
713
+ #debugPaint:
714
+ | {
715
+ lines: readonly string[];
716
+ windowTop: number;
717
+ altScreen: boolean;
718
+ cursor?: { x: number; y: number; visible?: boolean };
719
+ }
720
+ | undefined;
721
+ #debugNextWindowTop = 0;
702
722
  #inputListeners = new Set<InputListener>();
703
723
  #startListeners = new Set<StartListener>();
704
724
 
@@ -901,6 +921,16 @@ export class TUI extends Container {
901
921
  return this.#synchronizedOutputEnabled;
902
922
  }
903
923
 
924
+ /**
925
+ * Cost in milliseconds of the most recently completed frame.
926
+ *
927
+ * Animation components use this to apply proportional backpressure after
928
+ * their render request is asynchronously composed and written.
929
+ */
930
+ get lastFrameCostMs(): number {
931
+ return this.#lastFrameCostMs;
932
+ }
933
+
904
934
  setFocus(component: Component | null): void {
905
935
  const topVisibleOverlay = this.#getTopmostVisibleOverlay();
906
936
  if (topVisibleOverlay && !isOverlayFocusTarget(topVisibleOverlay.component, component)) {
@@ -930,6 +960,27 @@ export class TUI extends Container {
930
960
  getFocused(): Component | null {
931
961
  return this.#focusedComponent;
932
962
  }
963
+ /** Last viewport successfully written by the renderer, for debug inspection. */
964
+ getDebugPaint():
965
+ | {
966
+ lines: readonly string[];
967
+ windowTop: number;
968
+ altScreen: boolean;
969
+ cursor?: { x: number; y: number; visible?: boolean };
970
+ }
971
+ | undefined {
972
+ return this.#debugPaint;
973
+ }
974
+
975
+ /** Render the current root document at the live terminal width for debug inspection. */
976
+ getDebugDocument(): readonly string[] {
977
+ return this.render(Math.max(1, this.terminal.columns));
978
+ }
979
+
980
+ /** Feed debug and test input through the same pipeline as terminal stdin. */
981
+ injectDebugInput(data: string): void {
982
+ this.#handleInput(data);
983
+ }
933
984
 
934
985
  /**
935
986
  * Show an overlay component with configurable positioning and sizing.
@@ -1032,6 +1083,14 @@ export class TUI extends Container {
1032
1083
 
1033
1084
  start(options?: TUIStartOptions): void {
1034
1085
  this.#stopped = false;
1086
+ this.#debugPaint = undefined;
1087
+ this.#debugServer?.stop();
1088
+ this.#debugServer = undefined;
1089
+ const debugPath = process.env.OMP_TUI_DEBUG;
1090
+ if (debugPath !== undefined && debugPath.length > 0) {
1091
+ this.#debugServer = new TuiDebugServer(this, debugPath);
1092
+ this.#debugServer.start();
1093
+ }
1035
1094
  this.#inputDeferred = options?.deferInput === true;
1036
1095
  this.#watchdog.start();
1037
1096
  this.#ghosttyInitialImageDelayDone = false;
@@ -1138,7 +1197,7 @@ export class TUI extends Container {
1138
1197
  this.terminal.columns,
1139
1198
  );
1140
1199
  const top = Math.max(0, Math.min(this.#providerViewportTop, this.terminal.rows - staleRows));
1141
- erase = `\x1b[?25l\x1b[${top + 1};1H\x1b[J`;
1200
+ erase = `\x1b[?25l${this.#eraseBelowRow(top, this.terminal.rows)}`;
1142
1201
  } else {
1143
1202
  const up = this.#reflowedRowCount(
1144
1203
  this.#providerWindow,
@@ -1146,7 +1205,8 @@ export class TUI extends Container {
1146
1205
  this.#parkedViewportOffset,
1147
1206
  this.terminal.columns,
1148
1207
  );
1149
- erase = `\x1b[?25l${up > 0 ? `\x1b[${up}A` : ""}\r\x1b[J`;
1208
+ const eraseBelow = this.#eraseBelowCursorRow(this.terminal.columns, this.terminal.rows);
1209
+ erase = `\x1b[?25l${up > 0 ? `\x1b[${up}A` : ""}${eraseBelow}`;
1150
1210
  }
1151
1211
  // Both erase paths leave the cursor on the viewport's top row, so the
1152
1212
  // parked offset no longer applies; carrying a stale nonzero offset
@@ -1555,6 +1615,8 @@ export class TUI extends Container {
1555
1615
  }
1556
1616
 
1557
1617
  stop(): void {
1618
+ this.#debugServer?.stop();
1619
+ this.#debugServer = undefined;
1558
1620
  this.#resizeSettleTimer?.cancel();
1559
1621
  this.#resizeSettleTimer = undefined;
1560
1622
  this.#cancelResizeProbe();
@@ -2199,6 +2261,7 @@ export class TUI extends Container {
2199
2261
  #renderProviderFrame(width: number, height: number): void {
2200
2262
  const provider = this.#frameProvider;
2201
2263
  if (!provider || width <= 0 || height <= 0) return;
2264
+ this.#debugNextWindowTop = 0;
2202
2265
  this.#imageBudget.beginPass();
2203
2266
  const plan = provider.renderFrame({ columns: width, rows: height });
2204
2267
  this.#imageBudget.endPass();
@@ -2253,6 +2316,47 @@ export class TUI extends Container {
2253
2316
  this.#forceViewportRepaintOnNextRender = true;
2254
2317
  }
2255
2318
 
2319
+ /**
2320
+ * Erase `row` and everything below it, absolute-addressed, without ever
2321
+ * issuing a full-screen clear. tmux and Windows conhost (#9597) archive a
2322
+ * screen-wide erase into pane history instead of discarding it, so an erase
2323
+ * anchored on the first row would preserve the unfinished frame it exists to
2324
+ * remove (#9780). Below existing history a plain ED0 cannot span the screen;
2325
+ * on the first row, EL2 that row and ED0 from the second down touch the same
2326
+ * cells with no full-screen clear. `row` is clamped because a caller's
2327
+ * viewport top can predate a height shrink.
2328
+ * Every form leaves the cursor on the clamped row at column zero.
2329
+ */
2330
+ #eraseBelowRow(row: number, height: number): string {
2331
+ const top = Math.max(0, Math.min(row, Math.max(0, height - 1)));
2332
+ if (top > 0) return `\x1b[${top + 1};1H\x1b[J`;
2333
+ if (height <= 1) return `\x1b[1;1H${ERASE_LINE}`;
2334
+ return `\x1b[1;1H${ERASE_LINE}\x1b[2;1H\x1b[J\x1b[1;1H`;
2335
+ }
2336
+
2337
+ /**
2338
+ * Erase the cursor's row and everything below it, cursor-relative, without
2339
+ * ever issuing a full-screen clear. Used where the resize path must stay
2340
+ * cursor-relative: the terminal reflowed the normal buffer, so absolute rows
2341
+ * are stale and only the parked cursor still tracks the viewport's logical
2342
+ * line. Every form leaves the cursor on column zero of that row, exactly
2343
+ * where a plain `\r\x1b[J` left it.
2344
+ *
2345
+ * The erase must never run from the first cell, which is what makes tmux and
2346
+ * Windows conhost (#9597) archive the screen instead of discarding it
2347
+ * (#9780), and a clamped CUU can put the cursor on the first row without
2348
+ * naming it. Stepping one column right is enough and needs no row movement,
2349
+ * but at one column CUF cannot leave the first cell, so step one row down
2350
+ * instead: from the first row that can only reach row 1, and DECSC/DECRC
2351
+ * restores the anchor even when CUD clamps at the last row. A one-row screen
2352
+ * has nothing below the cursor, so EL2 alone clears everything.
2353
+ */
2354
+ #eraseBelowCursorRow(width: number, height: number): string {
2355
+ if (height <= 1) return `\r${ERASE_LINE}`;
2356
+ if (width > 1) return `\r${ERASE_LINE}\x1b[C\x1b[J\r`;
2357
+ return `\r${ERASE_LINE}\x1b7\x1b[B\x1b[J\x1b8`;
2358
+ }
2359
+
2256
2360
  /**
2257
2361
  * Physical write transaction: append an ordinary batch, or bottom-split one
2258
2362
  * complete replay into a history remainder and final viewport, then serialize
@@ -2361,7 +2465,7 @@ export class TUI extends Container {
2361
2465
  // committed rows and blanks, never an unfinished frame.
2362
2466
  const pushed = Math.max(0, startTop + preparedHistory.length + rows - height);
2363
2467
  if (pushed > this.#providerViewportTop && this.#providerWindow.length > 0) {
2364
- buffer += `\x1b[${this.#providerViewportTop + 1};1H\x1b[J`;
2468
+ buffer += this.#eraseBelowRow(this.#providerViewportTop, height);
2365
2469
  }
2366
2470
  buffer += `\x1b[${startTop + 1};1H`;
2367
2471
  let screenRow = startTop;
@@ -2410,6 +2514,12 @@ export class TUI extends Container {
2410
2514
  }
2411
2515
  buffer += this.#paintEndSequence;
2412
2516
  this.terminal.write(buffer);
2517
+ this.#debugPaint = {
2518
+ lines: prepared,
2519
+ windowTop: this.#debugNextWindowTop,
2520
+ altScreen: false,
2521
+ ...(target === null ? {} : { cursor: { x: target.col, y: target.row, visible: target.visible } }),
2522
+ };
2413
2523
  if (target) this.#recordHardwareCursorState(target);
2414
2524
  else this.#recordHardwareCursorHidden();
2415
2525
  this.#providerWindow = mutablePrepared;
@@ -2518,6 +2628,7 @@ export class TUI extends Container {
2518
2628
  const composed = this.render(width);
2519
2629
  this.#imageBudget.endPass();
2520
2630
  if (this.#maybeDeferGhosttyInitialImagePaint()) return;
2631
+ this.#debugNextWindowTop = Math.max(0, composed.length - height);
2521
2632
  const viewport = composed.length > height ? composed.slice(composed.length - height) : Array.from(composed);
2522
2633
  this.#emitPlanFrame(width, height, viewport, undefined, undefined);
2523
2634
  }
@@ -2863,6 +2974,7 @@ export class TUI extends Container {
2863
2974
  buffer += this.#paintEndSequence;
2864
2975
  this.terminal.write(buffer);
2865
2976
  this.#altPreviousLines = fitted;
2977
+ this.#debugPaint = { lines: fitted, windowTop: 0, altScreen: true };
2866
2978
  this.#fullRedrawCount += 1;
2867
2979
  }
2868
2980
  }
package/src/utils.ts CHANGED
@@ -623,7 +623,16 @@ export function applyBackgroundToLine(line: string, width: number, bgFn: (text:
623
623
  const paddingNeeded = Math.max(0, width - visibleLen);
624
624
 
625
625
  // Apply background to content + padding
626
- const withPadding = line + padding(paddingNeeded);
626
+ let withPadding = line + padding(paddingNeeded);
627
+ // Nested background resets (e.g. inline color chips closing with \x1b[49m)
628
+ // would terminate a plain open…close background wrapper early; re-open the
629
+ // line background after each one (same trick as Theme.bgFill).
630
+ if (line.includes("\x1b[49m")) {
631
+ const probe = bgFn("\x01");
632
+ const probeIdx = probe.indexOf("\x01");
633
+ const open = probeIdx > 0 ? probe.slice(0, probeIdx) : "";
634
+ if (open) withPadding = withPadding.replaceAll("\x1b[49m", `\x1b[49m${open}`);
635
+ }
627
636
  return bgFn(withPadding);
628
637
  }
629
638