@oh-my-pi/pi-tui 17.4.2 → 18.0.0
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 +41 -0
- package/README.md +1 -1
- package/dist/types/autocomplete.d.ts +10 -1
- package/dist/types/components/editor.d.ts +40 -1
- package/dist/types/components/markdown.d.ts +17 -0
- package/dist/types/components/select-list.d.ts +9 -0
- package/dist/types/keybindings.d.ts +5 -0
- package/dist/types/terminal.d.ts +32 -2
- package/dist/types/tui.d.ts +43 -0
- package/dist/types/utils.d.ts +3 -2
- package/package.json +4 -4
- package/src/autocomplete.ts +84 -54
- package/src/components/editor.ts +273 -26
- package/src/components/markdown.ts +111 -36
- package/src/components/select-list.ts +67 -15
- package/src/keybindings.ts +5 -0
- package/src/terminal-capabilities.ts +7 -1
- package/src/terminal.ts +133 -22
- package/src/tui.ts +177 -23
- package/src/utils.ts +12 -3
package/src/terminal.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { dlopen, FFIType, ptr } from "bun:ffi";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
|
+
import { TtyWriter } from "@oh-my-pi/pi-natives";
|
|
3
4
|
import {
|
|
4
5
|
$env,
|
|
5
6
|
isBunTestRuntime,
|
|
@@ -377,11 +378,37 @@ export function emergencyTerminalRestore(): void {
|
|
|
377
378
|
}
|
|
378
379
|
/** Terminal-reported appearance (dark/light mode). */
|
|
379
380
|
export type TerminalAppearance = "dark" | "light";
|
|
381
|
+
/** Options for {@link Terminal.start}. */
|
|
382
|
+
export interface TerminalStartOptions {
|
|
383
|
+
/**
|
|
384
|
+
* Paint-only start: skip raw mode, stdin ownership, and every probe that
|
|
385
|
+
* elicits a response on stdin. The host tty keeps cooked-mode line editing
|
|
386
|
+
* (kernel echo lands at the hardware cursor), and typed bytes stay queued
|
|
387
|
+
* in the kernel until {@link Terminal.enableInput} takes ownership and
|
|
388
|
+
* replays them through `onInput`. Used for the startup prepaint so typing
|
|
389
|
+
* echoes even while module loading blocks the event loop.
|
|
390
|
+
*/
|
|
391
|
+
deferInput?: boolean;
|
|
392
|
+
}
|
|
380
393
|
/** Identity of an accepted explicit terminal appearance refresh request. */
|
|
381
394
|
export type TerminalAppearanceRequestToken = number;
|
|
382
395
|
export interface Terminal {
|
|
383
396
|
// Start the terminal with input, resize, and host-disconnect handlers.
|
|
384
|
-
start(
|
|
397
|
+
start(
|
|
398
|
+
onInput: (data: string) => void,
|
|
399
|
+
onResize: () => void,
|
|
400
|
+
onDisconnect?: () => void,
|
|
401
|
+
options?: TerminalStartOptions,
|
|
402
|
+
): void;
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Take ownership of stdin after a `deferInput` start: enable raw mode,
|
|
406
|
+
* attach input handlers, and run the capability probes start() skipped.
|
|
407
|
+
* Bytes the user typed in cooked mode meanwhile are replayed through
|
|
408
|
+
* `onInput`. No-op when input was never deferred. Optional so custom
|
|
409
|
+
* Terminals built against older pi-tui versions keep working.
|
|
410
|
+
*/
|
|
411
|
+
enableInput?(): void;
|
|
385
412
|
|
|
386
413
|
// Stop the terminal and restore state
|
|
387
414
|
stop(): void;
|
|
@@ -400,6 +427,14 @@ export interface Terminal {
|
|
|
400
427
|
// Get terminal dimensions
|
|
401
428
|
get columns(): number;
|
|
402
429
|
get rows(): number;
|
|
430
|
+
/**
|
|
431
|
+
* Output bytes accepted but not yet delivered to the terminal, when the
|
|
432
|
+
* implementation can report it. The renderer skips composing new frames
|
|
433
|
+
* while this backlog is deep, so a slow terminal receives only fresh
|
|
434
|
+
* frames instead of a queue of stale ones. Optional so custom Terminals
|
|
435
|
+
* built against older pi-tui versions keep working.
|
|
436
|
+
*/
|
|
437
|
+
readonly pendingOutputBytes?: number;
|
|
403
438
|
|
|
404
439
|
// Whether Kitty keyboard protocol is active
|
|
405
440
|
get kittyProtocolActive(): boolean;
|
|
@@ -539,6 +574,8 @@ export class ProcessTerminal implements Terminal {
|
|
|
539
574
|
#wasRaw = false;
|
|
540
575
|
#inputHandler?: (data: string) => void;
|
|
541
576
|
#resizeHandler?: () => void;
|
|
577
|
+
/** True between a `deferInput` start() and enableInput(). */
|
|
578
|
+
#inputDeferred = false;
|
|
542
579
|
#stdoutResizeListener?: () => void;
|
|
543
580
|
#kittyProtocolActive = false;
|
|
544
581
|
#kittyEnableSeq: string | null = null;
|
|
@@ -578,6 +615,12 @@ export class ProcessTerminal implements Terminal {
|
|
|
578
615
|
// and the writable buffer grows without bound as cosmetic frames pile up.
|
|
579
616
|
// See OutputBacklogGuard and #6854.
|
|
580
617
|
#stdoutBacklog = new OutputBacklogGuard();
|
|
618
|
+
// Off-thread output pump (unix TTYs): Bun's `process.stdout.write` blocks
|
|
619
|
+
// the event loop until the terminal drains, so a slow/occluded emulator
|
|
620
|
+
// froze the whole TUI for the duration of a multi-MB repaint. The pump
|
|
621
|
+
// enqueues frames and performs the blocking write(2) on its own thread;
|
|
622
|
+
// `pendingOutputBytes` exposes the backlog for render-side frame skipping.
|
|
623
|
+
#outputPump?: TtyWriter;
|
|
581
624
|
#stdoutDrainArmed = false;
|
|
582
625
|
#stdoutDrainHandler = () => {
|
|
583
626
|
this.#stdoutDrainArmed = false;
|
|
@@ -697,7 +740,12 @@ export class ProcessTerminal implements Terminal {
|
|
|
697
740
|
this.#privateModeCallbacks.push(callback);
|
|
698
741
|
}
|
|
699
742
|
|
|
700
|
-
start(
|
|
743
|
+
start(
|
|
744
|
+
onInput: (data: string) => void,
|
|
745
|
+
onResize: () => void,
|
|
746
|
+
onDisconnect?: () => void,
|
|
747
|
+
options?: TerminalStartOptions,
|
|
748
|
+
): void {
|
|
701
749
|
this.#inputHandler = onInput;
|
|
702
750
|
this.#resizeHandler = onResize;
|
|
703
751
|
this.#disconnectHandler = onDisconnect;
|
|
@@ -715,12 +763,65 @@ export class ProcessTerminal implements Terminal {
|
|
|
715
763
|
// Register for emergency cleanup
|
|
716
764
|
activeTerminal = this;
|
|
717
765
|
terminalEverStarted = true;
|
|
766
|
+
// Own the blocking write(2) on a pump thread (unix TTYs only). A stale
|
|
767
|
+
// prebuilt natives module without the export falls back to direct writes.
|
|
768
|
+
// Test suites spy on `process.stdout.write` with a faked isTTY, so the
|
|
769
|
+
// pump stays off under `bun test` — same philosophy as isTerminalHeadless.
|
|
770
|
+
if (process.platform !== "win32" && process.stdout.isTTY && !isBunTestRuntime() && !this.#outputPump) {
|
|
771
|
+
try {
|
|
772
|
+
this.#outputPump = new TtyWriter(1);
|
|
773
|
+
} catch (err) {
|
|
774
|
+
logger.debug("tty output pump unavailable; using direct stdout writes", { err: String(err) });
|
|
775
|
+
}
|
|
776
|
+
}
|
|
718
777
|
|
|
719
778
|
// Keep unmanaged fd-2 writes (macOS libmalloc/framework diagnostics) off
|
|
720
779
|
// the viewport while we own the terminal; released in stop(). See
|
|
721
780
|
// stderr-guard in pi-utils (mirrors openai/codex#24459).
|
|
722
781
|
suppressTerminalStderr();
|
|
723
782
|
|
|
783
|
+
// Set up resize handler immediately. The OS refreshes process.stdout
|
|
784
|
+
// dimensions before firing `resize`, so it is authoritative for geometry:
|
|
785
|
+
// reconcile any stale cached DEC 2048 report before notifying the renderer.
|
|
786
|
+
this.#stdoutResizeListener = () => {
|
|
787
|
+
// Conservative: some hosts reset modes across a resize/reattach, so
|
|
788
|
+
// re-establish cursor visibility on the next explicit call.
|
|
789
|
+
this.#cursorVisible = undefined;
|
|
790
|
+
this.#reconcileInBandGeometryOnResize();
|
|
791
|
+
this.#resizeHandler?.();
|
|
792
|
+
};
|
|
793
|
+
process.stdout.on("resize", this.#stdoutResizeListener);
|
|
794
|
+
|
|
795
|
+
// Refresh terminal dimensions - they may be stale after suspend/resume
|
|
796
|
+
// (SIGWINCH is lost while process is stopped). Unix only.
|
|
797
|
+
if (process.platform !== "win32") {
|
|
798
|
+
process.kill(process.pid, "SIGWINCH");
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
setHangulCompatibilityJamoWidth(TERMINAL.hangulJamoWidth);
|
|
802
|
+
|
|
803
|
+
if (options?.deferInput) {
|
|
804
|
+
this.#inputDeferred = true;
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
this.#attachInput();
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
enableInput(): void {
|
|
811
|
+
if (!this.#inputDeferred) return;
|
|
812
|
+
this.#inputDeferred = false;
|
|
813
|
+
if (this.#headless || this.#dead) return;
|
|
814
|
+
this.#attachInput();
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Own stdin: raw mode, input listeners, and the capability probes that
|
|
819
|
+
* elicit stdin responses. Split from start() so a `deferInput` prepaint can
|
|
820
|
+
* leave the tty in cooked mode (kernel echo + line editing) while startup
|
|
821
|
+
* module loading blocks the event loop, then adopt the kernel-buffered
|
|
822
|
+
* keystrokes here once the app can process them.
|
|
823
|
+
*/
|
|
824
|
+
#attachInput(): void {
|
|
724
825
|
// A multiplexer or SSH disconnect can leave isTTY true after its pty has
|
|
725
826
|
// been revoked. Raw mode is then impossible, so take the normal terminal
|
|
726
827
|
// disconnect path rather than letting Bun abort startup with EIO.
|
|
@@ -751,24 +852,6 @@ export class ProcessTerminal implements Terminal {
|
|
|
751
852
|
// See #6374.
|
|
752
853
|
this.#safeWrite("\x1b[?1l\x1b>");
|
|
753
854
|
|
|
754
|
-
// Set up resize handler immediately. The OS refreshes process.stdout
|
|
755
|
-
// dimensions before firing `resize`, so it is authoritative for geometry:
|
|
756
|
-
// reconcile any stale cached DEC 2048 report before notifying the renderer.
|
|
757
|
-
this.#stdoutResizeListener = () => {
|
|
758
|
-
// Conservative: some hosts reset modes across a resize/reattach, so
|
|
759
|
-
// re-establish cursor visibility on the next explicit call.
|
|
760
|
-
this.#cursorVisible = undefined;
|
|
761
|
-
this.#reconcileInBandGeometryOnResize();
|
|
762
|
-
this.#resizeHandler?.();
|
|
763
|
-
};
|
|
764
|
-
process.stdout.on("resize", this.#stdoutResizeListener);
|
|
765
|
-
|
|
766
|
-
// Refresh terminal dimensions - they may be stale after suspend/resume
|
|
767
|
-
// (SIGWINCH is lost while process is stopped). Unix only.
|
|
768
|
-
if (process.platform !== "win32") {
|
|
769
|
-
process.kill(process.pid, "SIGWINCH");
|
|
770
|
-
}
|
|
771
|
-
|
|
772
855
|
// On Windows, enable ENABLE_VIRTUAL_TERMINAL_INPUT so the console sends
|
|
773
856
|
// VT escape sequences (e.g. \x1b[Z for Shift+Tab) instead of raw console
|
|
774
857
|
// events that lose modifier information. Must run after setRawMode(true)
|
|
@@ -781,8 +864,6 @@ export class ProcessTerminal implements Terminal {
|
|
|
781
864
|
// Explicit probes are safe only after their response parser and stdin
|
|
782
865
|
// data handler are installed. Keep this false throughout temporary stops.
|
|
783
866
|
this.#active = true;
|
|
784
|
-
setHangulCompatibilityJamoWidth(TERMINAL.hangulJamoWidth);
|
|
785
|
-
|
|
786
867
|
// Query terminal background color via OSC 11 for dark/light detection.
|
|
787
868
|
// Uses DA1 (Primary Device Attributes) as a sentinel: terminals process
|
|
788
869
|
// sequences in order, so if DA1 arrives before OSC 11 response,
|
|
@@ -1530,6 +1611,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
1530
1611
|
stop(): void {
|
|
1531
1612
|
// Suppress observer/timer callbacks before any teardown can yield or throw.
|
|
1532
1613
|
this.#active = false;
|
|
1614
|
+
this.#inputDeferred = false;
|
|
1533
1615
|
if (this.#headless) return;
|
|
1534
1616
|
// Unregister from emergency cleanup
|
|
1535
1617
|
if (activeTerminal === this) {
|
|
@@ -1649,6 +1731,13 @@ export class ProcessTerminal implements Terminal {
|
|
|
1649
1731
|
}
|
|
1650
1732
|
this.#stdoutBacklog.reset();
|
|
1651
1733
|
this.#resizeHandler = undefined;
|
|
1734
|
+
// Flush the restore sequences enqueued above (bounded — a stalled PTY
|
|
1735
|
+
// must not wedge exit), then retire the pump. Later writes (emergency
|
|
1736
|
+
// restore's showCursor) fall back to direct stdout writes.
|
|
1737
|
+
if (this.#outputPump) {
|
|
1738
|
+
this.#outputPump.stop(1000);
|
|
1739
|
+
this.#outputPump = undefined;
|
|
1740
|
+
}
|
|
1652
1741
|
|
|
1653
1742
|
// Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
|
|
1654
1743
|
// re-interpreted after raw mode is disabled. This fixes a race condition
|
|
@@ -1724,6 +1813,23 @@ export class ProcessTerminal implements Terminal {
|
|
|
1724
1813
|
if (!process.stdout.isTTY) return;
|
|
1725
1814
|
this.#ensureStdoutErrorHandler();
|
|
1726
1815
|
this.#trackCursorVisibility(data);
|
|
1816
|
+
const pump = this.#outputPump;
|
|
1817
|
+
if (pump) {
|
|
1818
|
+
if (pump.dead) {
|
|
1819
|
+
this.#markTerminalDisconnected("stdout failed; output pump died");
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
try {
|
|
1823
|
+
// Same stalled-consumer bound as the stream path (#6854): a PTY reader
|
|
1824
|
+
// that never drains must tear the terminal down, not grow the queue.
|
|
1825
|
+
if (pump.write(data) > MAX_STDOUT_BACKLOG_BYTES) {
|
|
1826
|
+
this.#markTerminalDisconnected("stdout backlog exceeded cap; PTY consumer stalled");
|
|
1827
|
+
}
|
|
1828
|
+
} catch (err) {
|
|
1829
|
+
this.#markTerminalDisconnected("stdout failed", err);
|
|
1830
|
+
}
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1727
1833
|
// A console-sharing child process may have flipped the console codepage
|
|
1728
1834
|
// away from UTF-8; repair it before any bytes hit WriteFile so no frame
|
|
1729
1835
|
// is ever translated through an OEM codepage. See ensureWindowsConsoleUtf8.
|
|
@@ -1771,6 +1877,11 @@ export class ProcessTerminal implements Terminal {
|
|
|
1771
1877
|
if (this.#inBandResizeActive && this.#reportedColumns) return this.#reportedColumns;
|
|
1772
1878
|
return process.stdout.columns || Number(Bun.env.COLUMNS) || 80;
|
|
1773
1879
|
}
|
|
1880
|
+
get pendingOutputBytes(): number {
|
|
1881
|
+
if (this.#outputPump) return this.#outputPump.pending();
|
|
1882
|
+
// Stream fallback: bytes queued past the high-water mark by refused writes.
|
|
1883
|
+
return process.stdout.writableLength ?? 0;
|
|
1884
|
+
}
|
|
1774
1885
|
|
|
1775
1886
|
get rows(): number {
|
|
1776
1887
|
if (this.#inBandResizeActive && this.#reportedRows) return this.#reportedRows;
|
package/src/tui.ts
CHANGED
|
@@ -117,6 +117,12 @@ export interface TUIOptions {
|
|
|
117
117
|
export interface TUIStartOptions {
|
|
118
118
|
/** Clear saved native scrollback before the first paint. */
|
|
119
119
|
clearScrollback?: boolean;
|
|
120
|
+
/**
|
|
121
|
+
* Paint without owning stdin: the terminal stays in cooked mode (kernel
|
|
122
|
+
* echo + line editing at the hardware cursor) until {@link TUI.enableInput}
|
|
123
|
+
* switches to raw input and replays the kernel-buffered keystrokes.
|
|
124
|
+
*/
|
|
125
|
+
deferInput?: boolean;
|
|
120
126
|
}
|
|
121
127
|
|
|
122
128
|
const DEFAULT_RENDER_SCHEDULER: RenderScheduler = {
|
|
@@ -374,6 +380,27 @@ export interface RenderRequestOptions {
|
|
|
374
380
|
clearScrollback?: boolean;
|
|
375
381
|
}
|
|
376
382
|
|
|
383
|
+
/**
|
|
384
|
+
* What a settled in-place width resize (multiplexer pane or an in-place-latched
|
|
385
|
+
* direct terminal) does to native scrollback, which the host rewrapped at the
|
|
386
|
+
* old width:
|
|
387
|
+
* - `append`: replay the transcript at the current width below the old-wrap
|
|
388
|
+
* history — one fresh copy per settled resize, nothing destroyed.
|
|
389
|
+
* - `rebuild`: clear native history first (ED3) and replay — history holds the
|
|
390
|
+
* transcript exactly once at the current width. Requires a host that honors
|
|
391
|
+
* an inner ED3 (tmux does; GNU screen ignores it, degrading to `append`),
|
|
392
|
+
* and erases pre-session pane history.
|
|
393
|
+
* - `preserve`: repaint the viewport only — zero history growth; scrollback
|
|
394
|
+
* keeps the old-width wrap until content next scrolls off.
|
|
395
|
+
*
|
|
396
|
+
* The raw engine defaults to `preserve` (append-only native scrollback, the
|
|
397
|
+
* engine's baseline contract); `PI_TUI_RESIZE_SCROLLBACK` overrides that
|
|
398
|
+
* initial value. The coding agent applies its `tui.resizeScrollback` setting
|
|
399
|
+
* (default `append`) on top at startup, so interactive sessions refresh
|
|
400
|
+
* stale-width history out of the box.
|
|
401
|
+
*/
|
|
402
|
+
export type ResizeScrollbackMode = "rebuild" | "append" | "preserve";
|
|
403
|
+
|
|
377
404
|
/** Type guard to check if a component implements Focusable */
|
|
378
405
|
export function isFocusable(component: Component | null): component is Component & Focusable {
|
|
379
406
|
return component !== null && "focused" in component;
|
|
@@ -720,12 +747,18 @@ export class Container
|
|
|
720
747
|
}
|
|
721
748
|
for (let leadingIndex = 0; leadingIndex < marker.leading.length; leadingIndex++) {
|
|
722
749
|
const captured = marker.leading[leadingIndex]!;
|
|
723
|
-
|
|
750
|
+
// Resolution runs only inside a width epoch, where a leading child's
|
|
751
|
+
// physical row count legitimately changes with the new wrap — comparing
|
|
752
|
+
// it to the captured count conflates reflow with mutation and fails
|
|
753
|
+
// resolution for every wrapping revisionless child. Identity plus the
|
|
754
|
+
// width-independent revision (when the component reports one) is the
|
|
755
|
+
// stability proof; a revisionless leading child that mutated in the
|
|
756
|
+
// settle window degrades to the accepted stale-history tradeoff, the
|
|
757
|
+
// same as any off-window mutation of committed rows.
|
|
724
758
|
if (
|
|
725
759
|
this.#memoChildren[leadingIndex] !== captured.component ||
|
|
726
|
-
(captured.revision
|
|
727
|
-
|
|
728
|
-
: getNativeScrollbackWidthEpochRevision(captured.component) !== captured.revision)
|
|
760
|
+
(captured.revision !== undefined &&
|
|
761
|
+
getNativeScrollbackWidthEpochRevision(captured.component) !== captured.revision)
|
|
729
762
|
) {
|
|
730
763
|
return undefined;
|
|
731
764
|
}
|
|
@@ -1207,6 +1240,18 @@ export class TUI extends Container {
|
|
|
1207
1240
|
* feels dead to the user and no longer justifies further CPU savings.
|
|
1208
1241
|
*/
|
|
1209
1242
|
static readonly #MAX_ADAPTIVE_RENDER_MS = 200;
|
|
1243
|
+
/**
|
|
1244
|
+
* Output backpressure gate. While the terminal still owes more than this
|
|
1245
|
+
* many bytes, composing another frame would only queue a stale paint
|
|
1246
|
+
* behind the backlog — and once the kernel PTY buffer is full, handing the
|
|
1247
|
+
* runtime more bytes degrades into thread-blocking writes. Defer the
|
|
1248
|
+
* render (keeping its forced/clear-scrollback intent) and retry shortly;
|
|
1249
|
+
* the eventual frame composes the latest component state, so a slow
|
|
1250
|
+
* terminal receives only fresh frames instead of every intermediate one.
|
|
1251
|
+
*/
|
|
1252
|
+
static readonly #MAX_PENDING_OUTPUT_BYTES = 256 * 1024;
|
|
1253
|
+
/** Retry cadence while the output backlog gate is holding renders back. */
|
|
1254
|
+
static readonly #OUTPUT_BACKLOG_RETRY_MS = 10;
|
|
1210
1255
|
#inputRenderGraceUntilMs = 0;
|
|
1211
1256
|
// Pane-reflow settle window for tmux/screen/zellij. The host process gets
|
|
1212
1257
|
// SIGWINCH (and `process.stdout` already reports the new geometry) before
|
|
@@ -1373,6 +1418,20 @@ export class TUI extends Container {
|
|
|
1373
1418
|
#hasEverRendered = false;
|
|
1374
1419
|
#scrollbackRebuildEnabled =
|
|
1375
1420
|
Bun.env.PI_TUI_SCROLLBACK_REBUILD === "1" || Bun.env.PI_TUI_SCROLLBACK_REBUILD === "true";
|
|
1421
|
+
#resizeScrollbackMode: ResizeScrollbackMode = TUI.#initialResizeScrollbackMode();
|
|
1422
|
+
static #initialResizeScrollbackMode(): ResizeScrollbackMode {
|
|
1423
|
+
const raw = Bun.env.PI_TUI_RESIZE_SCROLLBACK;
|
|
1424
|
+
return raw === "rebuild" || raw === "preserve" || raw === "append" ? raw : "preserve";
|
|
1425
|
+
}
|
|
1426
|
+
// A width epoch settled while a visible overlay froze commits, so the
|
|
1427
|
+
// resize-scrollback refresh could not run. Consumed by the first uncovered
|
|
1428
|
+
// authoritative normal-screen render so the stale old-width history is
|
|
1429
|
+
// repaired even if no further resize arrives. Any full paint clears it,
|
|
1430
|
+
// including one that fires while an overlay is still visible (session
|
|
1431
|
+
// replace, divergence rebuild): a full paint re-emits the committed prefix
|
|
1432
|
+
// from the recomposed current-width frame, which is exactly the refresh
|
|
1433
|
+
// this latch is waiting for — it supersedes the pending replay.
|
|
1434
|
+
#resizeScrollbackReplayPending = false;
|
|
1376
1435
|
// Set by the terminal resize callback; consumed by the next render. A resize
|
|
1377
1436
|
// event invalidates the committed screen even when the dimensions net out
|
|
1378
1437
|
// unchanged by render time (e.g. a 6→4→6 round trip coalesced into one frame
|
|
@@ -1421,6 +1480,8 @@ export class TUI extends Container {
|
|
|
1421
1480
|
// {@link #resizeRepaintsInPlace} routes resizes through the in-place path.
|
|
1422
1481
|
#altToggleResizesInPlace = false;
|
|
1423
1482
|
#stopped = false;
|
|
1483
|
+
/** True between a `deferInput` start() and enableInput(). */
|
|
1484
|
+
#inputDeferred = false;
|
|
1424
1485
|
// Always-on event-loop lag probe. The high default threshold keeps it quiet;
|
|
1425
1486
|
// it only logs `ui.loop-blocked` (with the current loop phase) when a frame
|
|
1426
1487
|
// budget is genuinely starved. Armed in start(), disarmed in stop().
|
|
@@ -1551,11 +1612,17 @@ export class TUI extends Container {
|
|
|
1551
1612
|
for (let index = 0; index < marker.leading.length; index++) {
|
|
1552
1613
|
const captured = marker.leading[index]!;
|
|
1553
1614
|
const current = this.#frameSegments[index];
|
|
1615
|
+
// Width epoch context: a leading root child's row count legitimately
|
|
1616
|
+
// changes with the new wrap (the startup banner and warning texts wrap
|
|
1617
|
+
// differently per width), so a captured-vs-current row count comparison
|
|
1618
|
+
// conflates reflow with mutation and forces the conservative replay on
|
|
1619
|
+
// every width change. Identity plus the width-independent revision
|
|
1620
|
+
// (when reported) proves stability; a revisionless leading child that
|
|
1621
|
+
// mutated inside the settle window degrades to the accepted
|
|
1622
|
+
// stale-history tradeoff instead of failing resolution.
|
|
1554
1623
|
if (
|
|
1555
1624
|
current?.component !== captured.component ||
|
|
1556
|
-
(captured.revision
|
|
1557
|
-
? current.rowCount !== captured.rowCount
|
|
1558
|
-
: current.widthEpochRevision !== captured.revision)
|
|
1625
|
+
(captured.revision !== undefined && current.widthEpochRevision !== captured.revision)
|
|
1559
1626
|
) {
|
|
1560
1627
|
return undefined;
|
|
1561
1628
|
}
|
|
@@ -1973,6 +2040,22 @@ export class TUI extends Container {
|
|
|
1973
2040
|
this.#scrollbackRebuildEnabled = enabled;
|
|
1974
2041
|
}
|
|
1975
2042
|
|
|
2043
|
+
/**
|
|
2044
|
+
* Get how a settled in-place width resize refreshes native scrollback.
|
|
2045
|
+
*/
|
|
2046
|
+
getResizeScrollback(): ResizeScrollbackMode {
|
|
2047
|
+
return this.#resizeScrollbackMode;
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
/**
|
|
2051
|
+
* Set how a settled in-place width resize refreshes native scrollback
|
|
2052
|
+
* (see {@link ResizeScrollbackMode}; engine default `preserve` — the coding
|
|
2053
|
+
* agent applies its `tui.resizeScrollback` setting, default `append`).
|
|
2054
|
+
*/
|
|
2055
|
+
setResizeScrollback(mode: ResizeScrollbackMode): void {
|
|
2056
|
+
this.#resizeScrollbackMode = mode;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
1976
2059
|
getShowHardwareCursor(): boolean {
|
|
1977
2060
|
return this.#showHardwareCursor;
|
|
1978
2061
|
}
|
|
@@ -2134,6 +2217,7 @@ export class TUI extends Container {
|
|
|
2134
2217
|
|
|
2135
2218
|
start(options?: TUIStartOptions): void {
|
|
2136
2219
|
this.#stopped = false;
|
|
2220
|
+
this.#inputDeferred = options?.deferInput === true;
|
|
2137
2221
|
this.#watchdog.start();
|
|
2138
2222
|
this.#ghosttyInitialImageDelayDone = false;
|
|
2139
2223
|
this.#ghosttyImageReadyAtMs = this.#renderScheduler.now() + TUI.#GHOSTTY_INITIAL_IMAGE_DELAY_MS;
|
|
@@ -2216,6 +2300,7 @@ export class TUI extends Container {
|
|
|
2216
2300
|
});
|
|
2217
2301
|
},
|
|
2218
2302
|
() => this.stop(),
|
|
2303
|
+
{ deferInput: this.#inputDeferred },
|
|
2219
2304
|
);
|
|
2220
2305
|
if (this.#stopped) return;
|
|
2221
2306
|
for (const listener of this.#startListeners) {
|
|
@@ -2227,9 +2312,24 @@ export class TUI extends Container {
|
|
|
2227
2312
|
}
|
|
2228
2313
|
this.terminal.hideCursor();
|
|
2229
2314
|
this.#recordHardwareCursorHidden();
|
|
2315
|
+
if (!this.#inputDeferred) {
|
|
2316
|
+
this.#querySixelSupport();
|
|
2317
|
+
this.#queryCellSize();
|
|
2318
|
+
}
|
|
2319
|
+
this.requestRender(true, { clearScrollback: options?.clearScrollback === true });
|
|
2320
|
+
}
|
|
2321
|
+
/**
|
|
2322
|
+
* Take ownership of stdin after a `deferInput` start: raw mode, input
|
|
2323
|
+
* handlers, and the response-eliciting capability probes start() skipped.
|
|
2324
|
+
* Keystrokes typed in cooked mode meanwhile arrive through the normal input
|
|
2325
|
+
* path. Idempotent; no-op when input was never deferred.
|
|
2326
|
+
*/
|
|
2327
|
+
enableInput(): void {
|
|
2328
|
+
if (!this.#inputDeferred || this.#stopped) return;
|
|
2329
|
+
this.#inputDeferred = false;
|
|
2330
|
+
this.terminal.enableInput?.();
|
|
2230
2331
|
this.#querySixelSupport();
|
|
2231
2332
|
this.#queryCellSize();
|
|
2232
|
-
this.requestRender(true, { clearScrollback: options?.clearScrollback === true });
|
|
2233
2333
|
}
|
|
2234
2334
|
|
|
2235
2335
|
addStartListener(listener: StartListener): () => void {
|
|
@@ -2925,6 +3025,18 @@ export class TUI extends Container {
|
|
|
2925
3025
|
}
|
|
2926
3026
|
}
|
|
2927
3027
|
|
|
3028
|
+
#runScheduledRender = (): void => {
|
|
3029
|
+
this.#renderTimer = undefined;
|
|
3030
|
+
if (this.#stopped || !this.#renderRequested) {
|
|
3031
|
+
return;
|
|
3032
|
+
}
|
|
3033
|
+
this.#renderRequested = false;
|
|
3034
|
+
this.#executeRender();
|
|
3035
|
+
if (this.#renderRequested) {
|
|
3036
|
+
this.#scheduleRender();
|
|
3037
|
+
}
|
|
3038
|
+
};
|
|
3039
|
+
|
|
2928
3040
|
#scheduleRender(): void {
|
|
2929
3041
|
if (this.#stopped || this.#renderTimer || !this.#renderRequested) {
|
|
2930
3042
|
return;
|
|
@@ -2949,17 +3061,7 @@ export class TUI extends Container {
|
|
|
2949
3061
|
const adaptiveDelay = Math.max(0, adaptiveFloor - elapsed);
|
|
2950
3062
|
const inputGraceDelay = Math.max(0, this.#inputRenderGraceUntilMs - now);
|
|
2951
3063
|
const delay = Math.max(cadenceDelay, adaptiveDelay, inputGraceDelay);
|
|
2952
|
-
this.#renderTimer = this.#renderScheduler.scheduleRender(
|
|
2953
|
-
this.#renderTimer = undefined;
|
|
2954
|
-
if (this.#stopped || !this.#renderRequested) {
|
|
2955
|
-
return;
|
|
2956
|
-
}
|
|
2957
|
-
this.#renderRequested = false;
|
|
2958
|
-
this.#executeRender();
|
|
2959
|
-
if (this.#renderRequested) {
|
|
2960
|
-
this.#scheduleRender();
|
|
2961
|
-
}
|
|
2962
|
-
}, delay);
|
|
3064
|
+
this.#renderTimer = this.#renderScheduler.scheduleRender(this.#runScheduledRender, delay);
|
|
2963
3065
|
}
|
|
2964
3066
|
|
|
2965
3067
|
/**
|
|
@@ -2968,11 +3070,28 @@ export class TUI extends Container {
|
|
|
2968
3070
|
* reads it re-entrantly) and compute the cost once the paint returns.
|
|
2969
3071
|
*/
|
|
2970
3072
|
#executeRender(): void {
|
|
3073
|
+
if (this.#deferRenderForOutputBacklog()) return;
|
|
2971
3074
|
const start = this.#renderScheduler.now();
|
|
2972
3075
|
this.#lastRenderAt = start;
|
|
2973
3076
|
this.#doRender();
|
|
2974
3077
|
this.#lastFrameCostMs = this.#renderScheduler.now() - start;
|
|
2975
3078
|
}
|
|
3079
|
+
/**
|
|
3080
|
+
* True when the frame was deferred because the terminal's output backlog
|
|
3081
|
+
* exceeds {@link TUI.#MAX_PENDING_OUTPUT_BYTES}. Re-arms a retry render;
|
|
3082
|
+
* one-shot paint intents (`#clearScrollbackOnNextRender`,
|
|
3083
|
+
* `#forceViewportRepaintOnNextRender`) survive untouched for it.
|
|
3084
|
+
*/
|
|
3085
|
+
#deferRenderForOutputBacklog(): boolean {
|
|
3086
|
+
const pending = this.terminal.pendingOutputBytes;
|
|
3087
|
+
if (pending === undefined || pending <= TUI.#MAX_PENDING_OUTPUT_BYTES) return false;
|
|
3088
|
+
this.#renderRequested = true;
|
|
3089
|
+
this.#renderTimer ??= this.#renderScheduler.scheduleRender(
|
|
3090
|
+
this.#runScheduledRender,
|
|
3091
|
+
TUI.#OUTPUT_BACKLOG_RETRY_MS,
|
|
3092
|
+
);
|
|
3093
|
+
return true;
|
|
3094
|
+
}
|
|
2976
3095
|
|
|
2977
3096
|
#handleInput(data: string): void {
|
|
2978
3097
|
// Ctrl+C/Esc use app-level double-press windows. Give those gestures one
|
|
@@ -3762,6 +3881,13 @@ export class TUI extends Container {
|
|
|
3762
3881
|
if (widthEpochReset && hasVisibleOverlay && this.#widthEpochOverlayBoundary === undefined) {
|
|
3763
3882
|
this.#widthEpochOverlayBoundary = capturedWidthEpochBoundary;
|
|
3764
3883
|
}
|
|
3884
|
+
// A width epoch settled while an overlay covered the transcript: the
|
|
3885
|
+
// scrollback refresh cannot run now (overlays freeze commits), and no
|
|
3886
|
+
// second width resize may ever come. Latch it and consume it on the
|
|
3887
|
+
// first uncovered authoritative normal-screen render.
|
|
3888
|
+
if (widthEpochReset && hasVisibleOverlay && this.#resizeScrollbackMode !== "preserve") {
|
|
3889
|
+
this.#resizeScrollbackReplayPending = true;
|
|
3890
|
+
}
|
|
3765
3891
|
const replayUnresolvedOverlayFrame = widthEpochReset && this.#widthEpochOverlayReplayPending;
|
|
3766
3892
|
const replayUnresolvedWidthEpoch =
|
|
3767
3893
|
replayUnresolvedOverlayFrame ||
|
|
@@ -3795,7 +3921,21 @@ export class TUI extends Container {
|
|
|
3795
3921
|
!geometryChanged &&
|
|
3796
3922
|
!isMultiplexerSession() &&
|
|
3797
3923
|
(committedRowsResynced || frameLength <= this.#committedRows);
|
|
3798
|
-
|
|
3924
|
+
// A settled in-place width resize left native history wrapped at the old
|
|
3925
|
+
// width (the host rewraps its own scrollback; long lines stay shredded at
|
|
3926
|
+
// the old boundaries). Unless the mode is `preserve`, refresh it with one
|
|
3927
|
+
// full paint of the recomposed current-width frame: `append` leaves the
|
|
3928
|
+
// old-wrap copy above (one fresh copy per settled resize, never loss);
|
|
3929
|
+
// `rebuild` clears native history first so it holds the transcript
|
|
3930
|
+
// exactly once. Epochs settled under a visible overlay keep the deferred
|
|
3931
|
+
// bounded path for that frame and consume the latched refresh here once
|
|
3932
|
+
// the overlay closes.
|
|
3933
|
+
const resizeScrollbackReplay =
|
|
3934
|
+
(widthEpochReset || this.#resizeScrollbackReplayPending) &&
|
|
3935
|
+
!hasVisibleOverlay &&
|
|
3936
|
+
this.#resizeScrollbackMode !== "preserve";
|
|
3937
|
+
const fullPaint =
|
|
3938
|
+
firstPaint || replaceRequested || geometryRebuild || divergenceRebuild || resizeScrollbackReplay;
|
|
3799
3939
|
// Height-only mux resizes move rows between the pane's scrollback and its
|
|
3800
3940
|
// grid. A shrink with a full grid pushes the grid-top rows into pane
|
|
3801
3941
|
// scrollback without a commit; a grow pulls the scrollback tail back into
|
|
@@ -3969,7 +4109,10 @@ export class TUI extends Container {
|
|
|
3969
4109
|
const intent: RenderIntent = fullPaint
|
|
3970
4110
|
? {
|
|
3971
4111
|
kind: "fullPaint",
|
|
3972
|
-
clearScrollback:
|
|
4112
|
+
clearScrollback:
|
|
4113
|
+
divergenceRebuild ||
|
|
4114
|
+
(resizeScrollbackReplay && this.#resizeScrollbackMode === "rebuild") ||
|
|
4115
|
+
((replaceRequested || geometryRebuild) && !isMultiplexerSession()),
|
|
3973
4116
|
}
|
|
3974
4117
|
: { kind: "update", chunkTo, windowTop };
|
|
3975
4118
|
this.#logRedraw(intent, frameLength, height);
|
|
@@ -3997,7 +4140,15 @@ export class TUI extends Container {
|
|
|
3997
4140
|
// re-emission. Width epochs retain an opaque native-row ledger, so close
|
|
3998
4141
|
// the old placement-coordinate epoch with its captured seam on reset and
|
|
3999
4142
|
// use the current-width commit seam calculated below thereafter.
|
|
4000
|
-
|
|
4143
|
+
// An `append` scrollback replay is exactly such a reset: the old-width
|
|
4144
|
+
// attach rows must not be compared against the current-width commit seam,
|
|
4145
|
+
// so the coordinate epoch transitions here and the full paint re-anchors
|
|
4146
|
+
// placements at current-width rows (archived cells keep their identity
|
|
4147
|
+
// and advance their placement id on the next emit). Only the ED3
|
|
4148
|
+
// `rebuild` replay skips this: the clear destroys every placement cell
|
|
4149
|
+
// and the emitter restarts the epochs via `resetPlacementEpochs()`, same
|
|
4150
|
+
// as a direct-terminal geometry rebuild.
|
|
4151
|
+
if (widthEpochReset && !(intent.kind === "fullPaint" && intent.clearScrollback)) {
|
|
4001
4152
|
this.#imageBudget.observeCommitWatermark(placementEpochWatermark);
|
|
4002
4153
|
this.#imageBudget.beginPlacementCoordinateEpoch();
|
|
4003
4154
|
} else if (intent.kind === "fullPaint" || this.#widthEpochBaselineRows === undefined) {
|
|
@@ -4013,7 +4164,9 @@ export class TUI extends Container {
|
|
|
4013
4164
|
cursorTrackingLineCount,
|
|
4014
4165
|
boundConptyPaint: !unboundedConptyPaint,
|
|
4015
4166
|
leadingSequence: deferredAltExit,
|
|
4016
|
-
|
|
4167
|
+
// A width-epoch replay must not push the invalidated old-width
|
|
4168
|
+
// viewport into native history on terminals that support it.
|
|
4169
|
+
copyScreenToScrollback: !resizeScrollbackReplay,
|
|
4017
4170
|
});
|
|
4018
4171
|
this.#pendingAltExit = "";
|
|
4019
4172
|
this.#committedPrefix = rawFrame.slice(0, chunkTo);
|
|
@@ -4025,6 +4178,7 @@ export class TUI extends Container {
|
|
|
4025
4178
|
this.#widthEpochOverlayReplayPending = false;
|
|
4026
4179
|
this.#widthEpochOverlayBoundary = undefined;
|
|
4027
4180
|
this.#widthEpochCommittedPrefix = undefined;
|
|
4181
|
+
this.#resizeScrollbackReplayPending = false;
|
|
4028
4182
|
this.#publishCommittedRows();
|
|
4029
4183
|
if (!firstPaint && frameLength > height) this.#armPostFullPaintSettle();
|
|
4030
4184
|
return;
|
package/src/utils.ts
CHANGED
|
@@ -225,6 +225,13 @@ export function getSegmenter(): Intl.Segmenter {
|
|
|
225
225
|
// added back so width matches the native truncate/slice/wrap helpers.
|
|
226
226
|
const OSC66_SPAN_REGEX = /\x1b\]66;([^;]*);([\s\S]*?)(?:\x07|\x1b\\)/g;
|
|
227
227
|
const OSC66_PREFIX = "\x1b]66;";
|
|
228
|
+
// APC sequences (`ESC _ ... ST|BEL`) — Kitty graphics commands such as the
|
|
229
|
+
// virtual-placement prefix on Unicode-placeholder image lines, or the TUI's
|
|
230
|
+
// BEL-terminated cursor marker. `Bun.stringWidth` strips CSI/OSC but counts APC
|
|
231
|
+
// payloads as printable text, so they are removed before measuring (they occupy
|
|
232
|
+
// zero cells — matching the native width engine in pi-natives/text.rs).
|
|
233
|
+
const APC_SPAN_REGEX = /\x1b_[\s\S]*?(?:\x07|\x1b\\)/g;
|
|
234
|
+
const APC_PREFIX = "\x1b_";
|
|
228
235
|
const PRINTABLE_ASCII_REGEX = /^[\u0020-\u007e]*$/;
|
|
229
236
|
|
|
230
237
|
// Pin Bun.stringWidth semantics to the native width engine and guard against Bun
|
|
@@ -296,8 +303,9 @@ let visibleWidthCacheEpoch = widthConfigEpoch;
|
|
|
296
303
|
* Visible width of a string in terminal columns, excluding ANSI/OSC escapes.
|
|
297
304
|
*
|
|
298
305
|
* `Bun.stringWidth` does the heavy lifting (UAX#11 width tables + ANSI/OSC
|
|
299
|
-
* stripping); this adds the
|
|
300
|
-
* `tabWidth` cells)
|
|
306
|
+
* stripping); this adds the corrections it omits — tabs (expanded to
|
|
307
|
+
* `tabWidth` cells), OSC 66 text-sizing payloads (scaled by `s=`), and APC
|
|
308
|
+
* sequences (counted as printable by Bun, actually zero cells).
|
|
301
309
|
*/
|
|
302
310
|
export function visibleWidth(str: string): number {
|
|
303
311
|
if (!str) return 0;
|
|
@@ -337,7 +345,8 @@ export function visibleWidth(str: string): number {
|
|
|
337
345
|
}
|
|
338
346
|
}
|
|
339
347
|
|
|
340
|
-
|
|
348
|
+
const measurable = hasEsc && str.includes(APC_PREFIX) ? str.replace(APC_SPAN_REGEX, "") : str;
|
|
349
|
+
let width = Bun.stringWidth(measurable, STRING_WIDTH_OPTS);
|
|
341
350
|
if (tabCount > 0) width += tabCount * DEFAULT_TAB_WIDTH;
|
|
342
351
|
|
|
343
352
|
if (hasEsc && str.includes(OSC66_PREFIX)) {
|