@oh-my-pi/pi-tui 18.0.7 → 18.0.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/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.0.8] - 2026-08-27
6
+
7
+ ### Added
8
+
9
+ - `ProcessTerminal` accepts a `conpty` option to force ConPTY-hosted behavior on or off, keeping terminal tests hermetic on WSL where live env detection would otherwise flip kitty-keyboard flags and write chunking ([#9887](https://github.com/can1357/oh-my-pi/issues/9887)).
10
+
11
+ ### Fixed
12
+
13
+ - Fixed pending-work animations repeatedly composing expensive frames without applying their full render cost to CPU backpressure.
14
+ - Fixed unfinished live viewport rows entering tmux pane history and duplicating streamed output ([#9780](https://github.com/can1357/oh-my-pi/issues/9780)).
15
+
5
16
  ## [18.0.7] - 2026-08-26
6
17
 
7
18
  ### Breaking Changes
@@ -172,11 +172,22 @@ export interface Terminal {
172
172
  * single predicate.
173
173
  */
174
174
  export declare function isConPTYHosted(): boolean;
175
+ /** Construction-time overrides for {@link ProcessTerminal}. */
176
+ export interface ProcessTerminalOptions {
177
+ /**
178
+ * Force ConPTY-hosted behavior on or off. Defaults to live detection via
179
+ * {@link isConPTYHosted}. Tests set this so the kitty-flag and write-chunking
180
+ * paths stay hermetic regardless of the ambient WSL env (`WSL_DISTRO_NAME` /
181
+ * `WSL_INTEROP`) — the suite must behave identically on WSL and on CI.
182
+ */
183
+ conpty?: boolean;
184
+ }
175
185
  /**
176
186
  * Real terminal using process.stdin/stdout
177
187
  */
178
188
  export declare class ProcessTerminal implements Terminal {
179
189
  #private;
190
+ constructor(options?: ProcessTerminalOptions);
180
191
  get kittyProtocolActive(): boolean;
181
192
  get kittyEnableSequence(): string | null;
182
193
  get keyboardEnhancementEnterSequence(): string | null;
@@ -292,6 +292,13 @@ export declare class TUI extends Container {
292
292
  * positive report, disabled on a negative one.
293
293
  */
294
294
  get synchronizedOutput(): boolean;
295
+ /**
296
+ * Cost in milliseconds of the most recently completed frame.
297
+ *
298
+ * Animation components use this to apply proportional backpressure after
299
+ * their render request is asynchronously composed and written.
300
+ */
301
+ get lastFrameCostMs(): number;
295
302
  setFocus(component: Component | null): void;
296
303
  /** Component currently receiving keyboard input, if any. */
297
304
  getFocused(): Component | null;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.0.7",
4
+ "version": "18.0.8",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "18.0.7",
41
- "@oh-my-pi/pi-utils": "18.0.7"
40
+ "@oh-my-pi/pi-natives": "18.0.8",
41
+ "@oh-my-pi/pi-utils": "18.0.8"
42
42
  },
43
43
  "devDependencies": {
44
44
  "kitty-vt-wasm": "^0.2.0"
@@ -5,6 +5,7 @@ import { Text } from "./text";
5
5
  const RENDER_INTERVAL_MS = 1000 / 30;
6
6
  const SPINNER_ADVANCE_MS = 80;
7
7
  const RENDER_BACKPRESSURE_MULTIPLIER = 9;
8
+ const MAX_BACKPRESSURE_FRAME_COST_MS = 200;
8
9
 
9
10
  type ColorFn = (str: string) => string;
10
11
 
@@ -139,12 +140,17 @@ export class Loader extends Text {
139
140
  this.#requestPaint();
140
141
  }
141
142
 
142
- const frameCostMs = performance.now() - startedAt;
143
+ const completedFrameCostMs = this.#ui?.lastFrameCostMs ?? 0;
144
+ const requestCostMs = performance.now() - startedAt;
143
145
  if (this.#intervalId !== timer) return;
144
- const cadenceDelayMs = Math.max(0, intervalMs - frameCostMs);
145
- // Idle for nine times the paint cost to keep animation at or below
146
- // 10% CPU, even when a slow ConPTY write exceeds the normal cadence.
147
- const backpressureDelayMs = frameCostMs * RENDER_BACKPRESSURE_MULTIPLIER;
146
+ const cadenceDelayMs = Math.max(0, intervalMs - requestCostMs);
147
+ // Idle for nine times the full frame cost to keep animation at or
148
+ // below 10% CPU even though requestComponentRender() only enqueues.
149
+ const boundedFrameCostMs = Math.min(
150
+ MAX_BACKPRESSURE_FRAME_COST_MS,
151
+ Math.max(completedFrameCostMs, requestCostMs),
152
+ );
153
+ const backpressureDelayMs = boundedFrameCostMs * RENDER_BACKPRESSURE_MULTIPLIER;
148
154
  this.#scheduleTick(intervalMs, Math.max(cadenceDelayMs, backpressureDelayMs));
149
155
  }, delayMs);
150
156
  this.#intervalId = timer;
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
@@ -901,6 +901,16 @@ export class TUI extends Container {
901
901
  return this.#synchronizedOutputEnabled;
902
902
  }
903
903
 
904
+ /**
905
+ * Cost in milliseconds of the most recently completed frame.
906
+ *
907
+ * Animation components use this to apply proportional backpressure after
908
+ * their render request is asynchronously composed and written.
909
+ */
910
+ get lastFrameCostMs(): number {
911
+ return this.#lastFrameCostMs;
912
+ }
913
+
904
914
  setFocus(component: Component | null): void {
905
915
  const topVisibleOverlay = this.#getTopmostVisibleOverlay();
906
916
  if (topVisibleOverlay && !isOverlayFocusTarget(topVisibleOverlay.component, component)) {
@@ -2361,7 +2371,16 @@ export class TUI extends Container {
2361
2371
  // committed rows and blanks, never an unfinished frame.
2362
2372
  const pushed = Math.max(0, startTop + preparedHistory.length + rows - height);
2363
2373
  if (pushed > this.#providerViewportTop && this.#providerWindow.length > 0) {
2364
- buffer += `\x1b[${this.#providerViewportTop + 1};1H\x1b[J`;
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
+ }
2365
2384
  }
2366
2385
  buffer += `\x1b[${startTop + 1};1H`;
2367
2386
  let screenRow = startTop;