@oh-my-pi/pi-tui 18.0.8 → 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 (39) hide show
  1. package/CHANGELOG.md +18 -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/tui.d.ts +23 -0
  20. package/package.json +3 -3
  21. package/src/components/box.ts +10 -0
  22. package/src/components/cancellable-loader.ts +7 -0
  23. package/src/components/editor.ts +28 -0
  24. package/src/components/image.ts +11 -0
  25. package/src/components/input.ts +13 -0
  26. package/src/components/loader.ts +10 -0
  27. package/src/components/markdown.ts +39 -10
  28. package/src/components/scroll-view.ts +12 -0
  29. package/src/components/select-list.ts +13 -0
  30. package/src/components/settings-list.ts +19 -0
  31. package/src/components/spacer.ts +4 -0
  32. package/src/components/tab-bar.ts +11 -0
  33. package/src/components/text.ts +11 -0
  34. package/src/components/truncated-text.ts +15 -1
  35. package/src/debug-server.ts +422 -0
  36. package/src/index.ts +2 -0
  37. package/src/latex-to-unicode.ts +401 -172
  38. package/src/tui.ts +105 -12
  39. package/src/utils.ts +10 -1
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
 
@@ -940,6 +960,27 @@ export class TUI extends Container {
940
960
  getFocused(): Component | null {
941
961
  return this.#focusedComponent;
942
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
+ }
943
984
 
944
985
  /**
945
986
  * Show an overlay component with configurable positioning and sizing.
@@ -1042,6 +1083,14 @@ export class TUI extends Container {
1042
1083
 
1043
1084
  start(options?: TUIStartOptions): void {
1044
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
+ }
1045
1094
  this.#inputDeferred = options?.deferInput === true;
1046
1095
  this.#watchdog.start();
1047
1096
  this.#ghosttyInitialImageDelayDone = false;
@@ -1148,7 +1197,7 @@ export class TUI extends Container {
1148
1197
  this.terminal.columns,
1149
1198
  );
1150
1199
  const top = Math.max(0, Math.min(this.#providerViewportTop, this.terminal.rows - staleRows));
1151
- erase = `\x1b[?25l\x1b[${top + 1};1H\x1b[J`;
1200
+ erase = `\x1b[?25l${this.#eraseBelowRow(top, this.terminal.rows)}`;
1152
1201
  } else {
1153
1202
  const up = this.#reflowedRowCount(
1154
1203
  this.#providerWindow,
@@ -1156,7 +1205,8 @@ export class TUI extends Container {
1156
1205
  this.#parkedViewportOffset,
1157
1206
  this.terminal.columns,
1158
1207
  );
1159
- 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}`;
1160
1210
  }
1161
1211
  // Both erase paths leave the cursor on the viewport's top row, so the
1162
1212
  // parked offset no longer applies; carrying a stale nonzero offset
@@ -1565,6 +1615,8 @@ export class TUI extends Container {
1565
1615
  }
1566
1616
 
1567
1617
  stop(): void {
1618
+ this.#debugServer?.stop();
1619
+ this.#debugServer = undefined;
1568
1620
  this.#resizeSettleTimer?.cancel();
1569
1621
  this.#resizeSettleTimer = undefined;
1570
1622
  this.#cancelResizeProbe();
@@ -2209,6 +2261,7 @@ export class TUI extends Container {
2209
2261
  #renderProviderFrame(width: number, height: number): void {
2210
2262
  const provider = this.#frameProvider;
2211
2263
  if (!provider || width <= 0 || height <= 0) return;
2264
+ this.#debugNextWindowTop = 0;
2212
2265
  this.#imageBudget.beginPass();
2213
2266
  const plan = provider.renderFrame({ columns: width, rows: height });
2214
2267
  this.#imageBudget.endPass();
@@ -2263,6 +2316,47 @@ export class TUI extends Container {
2263
2316
  this.#forceViewportRepaintOnNextRender = true;
2264
2317
  }
2265
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
+
2266
2360
  /**
2267
2361
  * Physical write transaction: append an ordinary batch, or bottom-split one
2268
2362
  * complete replay into a history remainder and final viewport, then serialize
@@ -2371,16 +2465,7 @@ export class TUI extends Container {
2371
2465
  // committed rows and blanks, never an unfinished frame.
2372
2466
  const pushed = Math.max(0, startTop + preparedHistory.length + rows - height);
2373
2467
  if (pushed > this.#providerViewportTop && this.#providerWindow.length > 0) {
2374
- const eraseTop = this.#providerViewportTop;
2375
- if (eraseTop > 0) {
2376
- // Below existing history: ED0 here never spans the whole screen.
2377
- buffer += `\x1b[${eraseTop + 1};1H\x1b[J`;
2378
- } else {
2379
- // Full-screen erase makes tmux preserve the live rows (#9780);
2380
- // EL2 the first row + ED0 the rest clears the same cells, no full clear.
2381
- buffer += "\x1b[1;1H\x1b[2K";
2382
- if (height > 1) buffer += "\x1b[2;1H\x1b[J";
2383
- }
2468
+ buffer += this.#eraseBelowRow(this.#providerViewportTop, height);
2384
2469
  }
2385
2470
  buffer += `\x1b[${startTop + 1};1H`;
2386
2471
  let screenRow = startTop;
@@ -2429,6 +2514,12 @@ export class TUI extends Container {
2429
2514
  }
2430
2515
  buffer += this.#paintEndSequence;
2431
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
+ };
2432
2523
  if (target) this.#recordHardwareCursorState(target);
2433
2524
  else this.#recordHardwareCursorHidden();
2434
2525
  this.#providerWindow = mutablePrepared;
@@ -2537,6 +2628,7 @@ export class TUI extends Container {
2537
2628
  const composed = this.render(width);
2538
2629
  this.#imageBudget.endPass();
2539
2630
  if (this.#maybeDeferGhosttyInitialImagePaint()) return;
2631
+ this.#debugNextWindowTop = Math.max(0, composed.length - height);
2540
2632
  const viewport = composed.length > height ? composed.slice(composed.length - height) : Array.from(composed);
2541
2633
  this.#emitPlanFrame(width, height, viewport, undefined, undefined);
2542
2634
  }
@@ -2882,6 +2974,7 @@ export class TUI extends Container {
2882
2974
  buffer += this.#paintEndSequence;
2883
2975
  this.terminal.write(buffer);
2884
2976
  this.#altPreviousLines = fitted;
2977
+ this.#debugPaint = { lines: fitted, windowTop: 0, altScreen: true };
2885
2978
  this.#fullRedrawCount += 1;
2886
2979
  }
2887
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