@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.
- package/CHANGELOG.md +29 -0
- package/dist/types/components/box.d.ts +2 -0
- package/dist/types/components/cancellable-loader.d.ts +2 -0
- package/dist/types/components/editor.d.ts +4 -0
- package/dist/types/components/image.d.ts +2 -0
- package/dist/types/components/input.d.ts +2 -0
- package/dist/types/components/loader.d.ts +2 -0
- package/dist/types/components/markdown.d.ts +2 -0
- package/dist/types/components/scroll-view.d.ts +2 -0
- package/dist/types/components/select-list.d.ts +2 -0
- package/dist/types/components/settings-list.d.ts +4 -0
- package/dist/types/components/spacer.d.ts +2 -0
- package/dist/types/components/tab-bar.d.ts +2 -0
- package/dist/types/components/text.d.ts +2 -0
- package/dist/types/components/truncated-text.d.ts +2 -0
- package/dist/types/debug-server.d.ts +27 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/latex-to-unicode.d.ts +4 -4
- package/dist/types/terminal.d.ts +11 -0
- package/dist/types/tui.d.ts +30 -0
- package/package.json +3 -3
- package/src/components/box.ts +10 -0
- package/src/components/cancellable-loader.ts +7 -0
- package/src/components/editor.ts +28 -0
- package/src/components/image.ts +11 -0
- package/src/components/input.ts +13 -0
- package/src/components/loader.ts +21 -5
- package/src/components/markdown.ts +39 -10
- package/src/components/scroll-view.ts +12 -0
- package/src/components/select-list.ts +13 -0
- package/src/components/settings-list.ts +19 -0
- package/src/components/spacer.ts +4 -0
- package/src/components/tab-bar.ts +11 -0
- package/src/components/text.ts +11 -0
- package/src/components/truncated-text.ts +15 -1
- package/src/debug-server.ts +422 -0
- package/src/index.ts +2 -0
- package/src/latex-to-unicode.ts +401 -172
- package/src/terminal.ts +22 -2
- package/src/tui.ts +115 -3
- 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 (
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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 +=
|
|
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
|
-
|
|
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
|
|