- Changed render methods to return component-owned `readonly string[]` rows. - Added `RenderStablePrefix` row reuse to avoid repainting unchanged streaming content. - Stabilized streaming gutters and spinner placement to reduce preview jitter and flicker. - Finalized commit-safe transcript behavior for tool-call previews and added streaming edge-case tests.
2783 lines
106 KiB
TypeScript
2783 lines
106 KiB
TypeScript
/**
|
|
* Minimal TUI implementation with differential rendering.
|
|
*
|
|
* Append-only render contract: rows committed to native scrollback are
|
|
* immutable. All mutation is confined to the visible window; rows enter
|
|
* history exactly once, in order, when the component-reported commit boundary
|
|
* (`NativeScrollbackLiveRegion`) says they are final. ED3 (`CSI 3 J`) is
|
|
* emitted only for gesture-driven replays (session replace, resize,
|
|
* resetDisplay) where snapping the viewport is acceptable. The engine never
|
|
* probes or guesses the terminal's scroll position, and the hot path clamps
|
|
* over-wide lines instead of throwing. See `docs/tui-core-renderer.md`.
|
|
*/
|
|
import * as fs from "node:fs";
|
|
import { performance } from "node:perf_hooks";
|
|
import { $flag, getDebugLogPath } from "@oh-my-pi/pi-utils";
|
|
import { DEFAULT_MAX_INLINE_IMAGES, ImageBudget } from "./components/image";
|
|
import { planDeccaraFills } from "./deccara";
|
|
import { isKeyRelease, matchesKey } from "./keys";
|
|
import { isConPTYHosted, type Terminal } from "./terminal";
|
|
import {
|
|
encodeKittyDeleteImage,
|
|
ImageProtocol,
|
|
setCellDimensions,
|
|
setTerminalImageProtocol,
|
|
shouldEnableSynchronizedOutputByDefault,
|
|
synchronizedOutputUserOverride,
|
|
TERMINAL,
|
|
} from "./terminal-capabilities";
|
|
import {
|
|
Ellipsis,
|
|
extractSegments,
|
|
normalizeTerminalOutput,
|
|
sliceByColumn,
|
|
sliceWithWidth,
|
|
truncateToWidth,
|
|
visibleWidth,
|
|
} from "./utils";
|
|
|
|
const SEGMENT_RESET = "\x1b[0m";
|
|
/**
|
|
* Per-line terminator written after every non-image content row. It closes both
|
|
* SGR state and any in-flight OSC 8 hyperlink so styles/links cannot bleed
|
|
* across lines in scrollback. Kept out of the diff/width cache because reset
|
|
* bytes are deterministic write framing, not content.
|
|
*/
|
|
const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
|
|
const ERASE_LINE = "\x1b[2K";
|
|
const ERASE_TO_END_OF_LINE = "\x1b[K";
|
|
// Keep the common short-row path out of native width/truncation. Longer rows
|
|
// are fit by visible cells, not source code units, so zero-width-heavy prefixes
|
|
// cannot hide visible suffix text that still belongs in the viewport.
|
|
const LINE_FIT_MIN_SOURCE_CODE_UNITS = 4096;
|
|
const LINE_FIT_MAX_SOURCE_CODE_UNITS = 65536;
|
|
const LINE_FIT_SOURCE_WIDTH_MULTIPLIER = 64;
|
|
// Hide the hardware cursor before each paint/move write. Ghostty-style bar
|
|
// cursors can otherwise leave visual afterimages while the TUI repaints the
|
|
// row under a visible cursor. Paint writes also disable terminal autowrap:
|
|
// several terminals keep a "pending wrap" flag after an exact-width row, so a
|
|
// following cursor move can first wrap to the next row and produce staircase
|
|
// trails. The TUI emits explicit CRLFs and restores autowrap before leaving the
|
|
// paint. Synchronized output can be disabled for terminals with broken DEC 2026
|
|
// implementations; autowrap discipline stays on either way.
|
|
const HIDE_CURSOR = "\x1b[?25l";
|
|
const SYNC_OUTPUT_BEGIN = "\x1b[?2026h";
|
|
const SYNC_OUTPUT_END = "\x1b[?2026l";
|
|
const DISABLE_AUTOWRAP = "\x1b[?7l";
|
|
const ENABLE_AUTOWRAP = "\x1b[?7h";
|
|
const PAINT_BEGIN = `${HIDE_CURSOR}${SYNC_OUTPUT_BEGIN}${DISABLE_AUTOWRAP}`;
|
|
const PAINT_END = `${ENABLE_AUTOWRAP}${SYNC_OUTPUT_END}`;
|
|
const PAINT_BEGIN_NO_SYNC = `${HIDE_CURSOR}${DISABLE_AUTOWRAP}`;
|
|
const PAINT_END_NO_SYNC = ENABLE_AUTOWRAP;
|
|
const CURSOR_BEGIN = `${HIDE_CURSOR}${SYNC_OUTPUT_BEGIN}`;
|
|
const CURSOR_BEGIN_NO_SYNC = HIDE_CURSOR;
|
|
const CURSOR_END = SYNC_OUTPUT_END;
|
|
const CURSOR_END_NO_SYNC = "";
|
|
// Mouse reporting, enabled only for the lifetime of a fullscreen overlay so the
|
|
// rest of the app keeps the terminal's native text selection. 1000h = button
|
|
// click tracking, 1003h = any-motion tracking so overlays can light up hover
|
|
// targets (the pointer moving with no button held), 1006h = SGR extended
|
|
// coordinates so columns/rows past 223 are reported.
|
|
const MOUSE_TRACKING_ON = "\x1b[?1000h\x1b[?1003h\x1b[?1006h";
|
|
const MOUSE_TRACKING_OFF = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
|
|
|
|
type InputListenerResult = { consume?: boolean; data?: string } | undefined;
|
|
type InputListener = (data: string) => InputListenerResult;
|
|
type StartListener = () => void;
|
|
|
|
export interface RenderTimer {
|
|
cancel(): void;
|
|
}
|
|
|
|
export interface RenderScheduler {
|
|
now(): number;
|
|
scheduleImmediate(callback: () => void): void;
|
|
scheduleRender(callback: () => void, delayMs: number): RenderTimer;
|
|
}
|
|
|
|
export interface TUIOptions {
|
|
renderScheduler?: RenderScheduler;
|
|
}
|
|
|
|
export interface TUIStartOptions {
|
|
/** Clear saved native scrollback before the first paint. */
|
|
clearScrollback?: boolean;
|
|
}
|
|
|
|
const DEFAULT_RENDER_SCHEDULER: RenderScheduler = {
|
|
now: () => performance.now(),
|
|
scheduleImmediate: callback => {
|
|
process.nextTick(callback);
|
|
},
|
|
scheduleRender: (callback, delayMs) => {
|
|
const timer = setTimeout(callback, delayMs);
|
|
return {
|
|
cancel: () => {
|
|
clearTimeout(timer);
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Component interface - all components must implement this
|
|
*
|
|
* Render contract: the returned array (and its rows) belongs to the component.
|
|
* Callers MUST NOT mutate it — components are allowed to return a cached array
|
|
* and will return the exact same reference for as long as their rendered
|
|
* content is unchanged. Conversely, a component MUST return a fresh array
|
|
* reference whenever its content changed; reference equality across two
|
|
* render() calls is the engine's proof that the rows are byte-identical
|
|
* (containers memoize their concatenation on it, and the TUI derives the
|
|
* frame's stable prefix from it). A component that mutates a previously
|
|
* returned array in place must implement {@link RenderStablePrefix} to declare
|
|
* which leading rows survived.
|
|
*/
|
|
export interface Component {
|
|
/**
|
|
* Render the component to an array of physical rows at the given width.
|
|
* The result is component-owned and `readonly` to the caller; an unchanged
|
|
* component may (and should) return the same array reference it returned
|
|
* last time.
|
|
*/
|
|
render(width: number): readonly string[];
|
|
|
|
/**
|
|
* Optional handler for keyboard input when component has focus
|
|
*/
|
|
handleInput?(data: string): void;
|
|
|
|
/**
|
|
* If true, component receives key release events (Kitty protocol).
|
|
* Default is false - release events are filtered out.
|
|
*/
|
|
wantsKeyRelease?: boolean;
|
|
|
|
/**
|
|
* Optional hook to invalidate any cached rendering state.
|
|
* Called when theme changes or when component needs to re-render from scratch.
|
|
*/
|
|
invalidate?(): void;
|
|
|
|
/**
|
|
* Optional teardown. Called when the component is permanently removed from
|
|
* the live tree (e.g. a transcript reset). Release timers, intervals, and
|
|
* subscriptions here. Must be idempotent. Containers propagate dispose to
|
|
* their children; leaf components without resources may omit it.
|
|
*/
|
|
dispose?(): void;
|
|
}
|
|
|
|
/**
|
|
* Component seam for append-only native-scrollback commits. A component that
|
|
* renders a finalized prefix followed by a live/mutating suffix reports the
|
|
* local line index where that suffix begins after each render. The engine
|
|
* commits rows to native scrollback only up to that boundary; everything
|
|
* below repaints in place inside the visible window and never enters history
|
|
* until it finalizes.
|
|
*
|
|
* `getNativeScrollbackCommitSafeEnd` optionally reports a *deeper* boundary
|
|
* inside the live suffix: the line index up to which the live region is
|
|
* append-only (earlier rows never re-layout — a streaming assistant message).
|
|
* Rows in `[liveRegionStart, commitSafeEnd)` may commit even though they are
|
|
* technically live, because they will never change. Without it, a single live
|
|
* block that alone overflows the window would hold its scrolled-off head out
|
|
* of history until it finalizes. Volatile live blocks (tool previews that
|
|
* collapse) omit it. Defaults to `liveRegionStart` when absent; a root that
|
|
* reports no seam at all commits everything that scrolls (shell semantics).
|
|
*/
|
|
export interface NativeScrollbackLiveRegion {
|
|
getNativeScrollbackLiveRegionStart(): number | undefined;
|
|
getNativeScrollbackCommitSafeEnd?(): number | undefined;
|
|
}
|
|
|
|
function getNativeScrollbackLiveRegionStart(component: Component): number | undefined {
|
|
return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackLiveRegionStart?.();
|
|
}
|
|
|
|
function getNativeScrollbackCommitSafeEnd(component: Component): number | undefined {
|
|
return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackCommitSafeEnd?.();
|
|
}
|
|
|
|
/**
|
|
* Opt-in stability report for components that mutate their returned render
|
|
* array in place across frames (instead of returning a fresh array per
|
|
* change). The engine reads it right after the component's `render()` returns:
|
|
* the report counts the leading rows of the just-returned array that are
|
|
* byte-identical to the array state the reader last observed. The engine uses
|
|
* it to reuse the composed frame's prefix — skipping marker extraction, line
|
|
* preparation, and the committed-prefix audit for those rows.
|
|
*
|
|
* Contract:
|
|
* - Reading CONSUMES the report: it re-bases the baseline to the current
|
|
* array state. The accumulated count therefore covers every render since
|
|
* the previous read, so out-of-band `render()` calls between engine frames
|
|
* (an exporter walking the tree) can only lower the report, never inflate
|
|
* it past what the engine actually has.
|
|
* - An implementer that cannot prove stability for a frame must lower the
|
|
* accumulated count to 0 for that render.
|
|
* - Rows at or beyond the report may have been mutated in place; rows before
|
|
* it must be the identical string values at the identical indices.
|
|
*/
|
|
export interface RenderStablePrefix {
|
|
getRenderStablePrefixRows(): number;
|
|
}
|
|
|
|
function getRenderStablePrefixRows(component: Component): number | undefined {
|
|
return (component as Component & Partial<RenderStablePrefix>).getRenderStablePrefixRows?.();
|
|
}
|
|
|
|
/**
|
|
* Interface for components that can receive focus and display a cursor.
|
|
* When focused, the component should emit CURSOR_MARKER at the cursor position
|
|
* in its render output. TUI will find this marker and position the hardware
|
|
* cursor there for proper IME candidate window positioning.
|
|
*
|
|
* Components that can switch between terminal-cursor and software-cursor
|
|
* rendering expose `setUseTerminalCursor`; TUI keeps that mode in sync with
|
|
* its resolved hardware-cursor preference whenever focus or the preference
|
|
* changes.
|
|
*/
|
|
export interface Focusable {
|
|
/** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */
|
|
focused: boolean;
|
|
/** Set by TUI when hardware cursor rendering is enabled or disabled. */
|
|
setUseTerminalCursor?(useTerminalCursor: boolean): void;
|
|
}
|
|
|
|
/** Options for scheduling a TUI render. */
|
|
export interface RenderRequestOptions {
|
|
/** Clear terminal scrollback for intentional transcript replacement. */
|
|
clearScrollback?: boolean;
|
|
}
|
|
/** Type guard to check if a component implements Focusable */
|
|
export function isFocusable(component: Component | null): component is Component & Focusable {
|
|
return component !== null && "focused" in component;
|
|
}
|
|
|
|
/**
|
|
* Cursor position marker - APC (Application Program Command) sequence.
|
|
* This is a zero-width escape sequence that terminals ignore.
|
|
* Components emit this at the cursor position when focused.
|
|
* TUI finds and strips this marker, then positions the hardware cursor there.
|
|
*/
|
|
export const CURSOR_MARKER = "\x1b_pi:c\x07";
|
|
|
|
export { visibleWidth };
|
|
|
|
/**
|
|
* Anchor position for overlays
|
|
*/
|
|
export type OverlayAnchor =
|
|
| "center"
|
|
| "top-left"
|
|
| "top-right"
|
|
| "bottom-left"
|
|
| "bottom-right"
|
|
| "top-center"
|
|
| "bottom-center"
|
|
| "left-center"
|
|
| "right-center";
|
|
|
|
/**
|
|
* Margin configuration for overlays
|
|
*/
|
|
export interface OverlayMargin {
|
|
top?: number;
|
|
right?: number;
|
|
bottom?: number;
|
|
left?: number;
|
|
}
|
|
|
|
/** Value that can be absolute (number) or percentage (string like "50%") */
|
|
export type SizeValue = number | `${number}%`;
|
|
|
|
/** Parse a SizeValue into absolute value given a reference size */
|
|
function parseSizeValue(value: SizeValue | undefined, referenceSize: number): number | undefined {
|
|
if (value === undefined) return undefined;
|
|
if (typeof value === "number") return value;
|
|
// Parse percentage string like "50%"
|
|
const match = value.match(/^(\d+(?:\.\d+)?)%$/);
|
|
if (match) {
|
|
return Math.floor((referenceSize * parseFloat(match[1])) / 100);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
|
|
function isMultiplexerSession(): boolean {
|
|
return Boolean(Bun.env.TMUX || Bun.env.STY || Bun.env.ZELLIJ);
|
|
}
|
|
|
|
/**
|
|
* Options for overlay positioning and sizing.
|
|
* Values can be absolute numbers or percentage strings (e.g., "50%").
|
|
*/
|
|
export interface OverlayOptions {
|
|
// === Sizing ===
|
|
/** Width in columns, or percentage of terminal width (e.g., "50%") */
|
|
width?: SizeValue;
|
|
/** Minimum width in columns */
|
|
minWidth?: number;
|
|
/** Maximum height in rows, or percentage of terminal height (e.g., "50%") */
|
|
maxHeight?: SizeValue;
|
|
|
|
// === Positioning - anchor-based ===
|
|
/** Anchor point for positioning (default: 'center') */
|
|
anchor?: OverlayAnchor;
|
|
/** Horizontal offset from anchor position (positive = right) */
|
|
offsetX?: number;
|
|
/** Vertical offset from anchor position (positive = down) */
|
|
offsetY?: number;
|
|
|
|
// === Positioning - percentage or absolute ===
|
|
/** Row position: absolute number, or percentage (e.g., "25%" = 25% from top) */
|
|
row?: SizeValue;
|
|
/** Column position: absolute number, or percentage (e.g., "50%" = centered horizontally) */
|
|
col?: SizeValue;
|
|
|
|
// === Margin from terminal edges ===
|
|
/** Margin from terminal edges. Number applies to all sides. */
|
|
margin?: OverlayMargin | number;
|
|
|
|
// === Visibility ===
|
|
/**
|
|
* Control overlay visibility based on terminal dimensions.
|
|
* If provided, overlay is only rendered when this returns true.
|
|
* Called each render cycle with current terminal dimensions.
|
|
*/
|
|
visible?: (termWidth: number, termHeight: number) => boolean;
|
|
|
|
// === Fullscreen ===
|
|
/**
|
|
* Borrow the terminal's alternate screen buffer for this overlay's lifetime
|
|
* (vim/less idiom). While the topmost visible overlay sets this, the engine
|
|
* paints only the modal on the alt screen and emits no ED3 / scrollback
|
|
* bytes, so the transcript on the normal screen stays untouched and is not
|
|
* scrollable behind the modal. Defaults off — all other overlays are
|
|
* unchanged and still draw over the transcript on the normal screen.
|
|
*/
|
|
fullscreen?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Handle returned by showOverlay for controlling the overlay
|
|
*/
|
|
export interface OverlayHandle {
|
|
/** Permanently remove the overlay (cannot be shown again) */
|
|
hide(): void;
|
|
/** Temporarily hide or show the overlay */
|
|
setHidden(hidden: boolean): void;
|
|
/** Check if overlay is temporarily hidden */
|
|
isHidden(): boolean;
|
|
}
|
|
|
|
/**
|
|
* Container - a component that contains other components
|
|
*/
|
|
export class Container implements Component {
|
|
children: Component[] = [];
|
|
|
|
// Memoized concatenation of the children's latest renders. Children are
|
|
// still rendered every frame (renders carry side effects: image placement
|
|
// registration, seam/stability reports); the memo only skips rebuilding
|
|
// the concatenated array when every child returned the exact same array
|
|
// reference at the same width — which, per the Component render contract,
|
|
// proves the rows are byte-identical. Cleared on any child-list change and
|
|
// on invalidate().
|
|
#memoLines: string[] | undefined;
|
|
#memoChildLines: (readonly string[])[] = [];
|
|
#memoWidth = -1;
|
|
|
|
addChild(component: Component): void {
|
|
this.children.push(component);
|
|
this.#memoLines = undefined;
|
|
}
|
|
|
|
removeChild(component: Component): void {
|
|
const index = this.children.indexOf(component);
|
|
if (index !== -1) {
|
|
this.children.splice(index, 1);
|
|
this.#memoLines = undefined;
|
|
}
|
|
}
|
|
|
|
clear(): void {
|
|
this.children = [];
|
|
this.#memoLines = undefined;
|
|
}
|
|
|
|
invalidate(): void {
|
|
this.#memoLines = undefined;
|
|
for (const child of this.children) {
|
|
child.invalidate?.();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Propagate teardown to children. Call when the container's children are
|
|
* being permanently discarded (not when they are detached for reuse — use
|
|
* {@link clear} for that). Idempotent per child via each child's own dispose.
|
|
*/
|
|
dispose(): void {
|
|
for (const child of this.children) {
|
|
child.dispose?.();
|
|
}
|
|
}
|
|
|
|
render(width: number): readonly string[] {
|
|
width = Math.max(1, width);
|
|
const children = this.children;
|
|
const count = children.length;
|
|
let refs = this.#memoChildLines;
|
|
let unchanged = this.#memoLines !== undefined && this.#memoWidth === width && refs.length === count;
|
|
if (refs.length !== count) {
|
|
refs = new Array(count);
|
|
this.#memoChildLines = refs;
|
|
}
|
|
for (let i = 0; i < count; i++) {
|
|
const childLines = children[i]!.render(width);
|
|
if (refs[i] !== childLines) {
|
|
unchanged = false;
|
|
refs[i] = childLines;
|
|
}
|
|
}
|
|
this.#memoWidth = width;
|
|
if (unchanged) return this.#memoLines!;
|
|
const lines: string[] = [];
|
|
for (let i = 0; i < count; i++) {
|
|
const childLines = refs[i]!;
|
|
for (let j = 0; j < childLines.length; j++) lines.push(childLines[j]!);
|
|
}
|
|
this.#memoLines = lines;
|
|
return lines;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render intent. `#doRender` classifies each frame, and the matching `#emit*`
|
|
* method owns the bytes written and the state update.
|
|
*
|
|
* - `fullPaint`: gesture-driven replay — initial paint, session replacement,
|
|
* resize, resetDisplay. Clears the viewport and (for destructive replaces,
|
|
* outside multiplexers) native scrollback via ED3, then writes the
|
|
* committed prefix and the visible window. The only ED3 callsite in the
|
|
* engine.
|
|
* - `update`: ordinary frame. Commits the newly settled chunk at the
|
|
* scrollback seam (if any) and repaints the window with relative moves.
|
|
*/
|
|
type RenderIntent =
|
|
| { kind: "fullPaint"; clearScrollback: boolean }
|
|
| { kind: "update"; chunkTo: number; windowTop: number };
|
|
|
|
interface HardwareCursorState {
|
|
row: number;
|
|
col: number;
|
|
visible: boolean;
|
|
}
|
|
|
|
interface HardwareCursorUpdate {
|
|
toRow: number;
|
|
state: HardwareCursorState | null;
|
|
visible?: boolean;
|
|
}
|
|
|
|
interface CursorControlResult extends HardwareCursorUpdate {
|
|
seq: string;
|
|
toCol: number;
|
|
visible: boolean;
|
|
}
|
|
|
|
/**
|
|
* One root child's contribution to the composed frame: the array reference its
|
|
* render() returned, the frame row it starts at, and the row count recorded at
|
|
* compose time (in-place mutators keep the reference but may change length).
|
|
*/
|
|
interface FrameSegment {
|
|
component: Component;
|
|
lines: readonly string[];
|
|
start: number;
|
|
rowCount: number;
|
|
}
|
|
|
|
interface PreparedLine {
|
|
raw: string;
|
|
width: number;
|
|
line: string;
|
|
}
|
|
|
|
const SGR_SEQUENCE = /\x1b\[[0-9;:]*m/g;
|
|
|
|
/** Compare two rows ignoring SGR styling (theme restyles keep alignment). */
|
|
function rowsEquivalent(a: string, b: string): boolean {
|
|
if (a === b) return true;
|
|
return a.replace(SGR_SEQUENCE, "") === b.replace(SGR_SEQUENCE, "");
|
|
}
|
|
|
|
function isBlankRow(row: string): boolean {
|
|
if (row.length === 0) return true;
|
|
return row.replace(SGR_SEQUENCE, "").trim().length === 0;
|
|
}
|
|
|
|
// Tail-alignment sampling bounds: look back through up to LOOKBACK rows of
|
|
// the committed prefix to collect SAMPLES non-blank comparisons.
|
|
const RESYNC_TAIL_LOOKBACK = 24;
|
|
const RESYNC_TAIL_SAMPLES = 8;
|
|
|
|
/**
|
|
* Decide whether `frame` still aligns with the committed prefix, and where to
|
|
* re-anchor the commit index when it does not. Returns the resync row index,
|
|
* or -1 when no resync is needed.
|
|
*
|
|
* The detector exploits the asymmetry between the two mutation classes: an
|
|
* in-place edit or restyle of committed rows disturbs only the touched rows
|
|
* (alignment below them is intact — the stale copy in history is the
|
|
* long-accepted artifact), while any insertion or deletion shifts EVERY row
|
|
* below it, including the rows just above the commit boundary. So the prefix
|
|
* *tail* is sampled (up to 8 non-blank rows within the last 24, compared
|
|
* SGR-stripped so theme changes stay quiet, tolerating one mismatch for a
|
|
* legitimate single-row edit): aligned ⇒ no resync; misaligned ⇒ resync at
|
|
* the first non-equivalent row, recommitting from there — duplication, never
|
|
* loss. Highly repetitive tails (identical filler rows) can mask a shift, in
|
|
* which case the skipped rows are content-identical to the committed ones —
|
|
* observationally harmless. Exported for the render-stress harness, whose
|
|
* shadow commit ledger must mirror the engine's law exactly.
|
|
*/
|
|
export function findCommittedPrefixResync(frame: readonly string[], prefix: readonly string[]): number {
|
|
const committed = prefix.length;
|
|
if (committed === 0) return -1;
|
|
if (frame.length >= committed) {
|
|
let samples = 0;
|
|
let mismatches = 0;
|
|
const lookback = Math.min(RESYNC_TAIL_LOOKBACK, committed);
|
|
for (let j = 1; j <= lookback && samples < RESYNC_TAIL_SAMPLES; j++) {
|
|
const row = frame[committed - j]!;
|
|
const old = prefix[committed - j]!;
|
|
if (row === old) {
|
|
if (!isBlankRow(row)) samples++;
|
|
continue;
|
|
}
|
|
if (isBlankRow(row) && isBlankRow(old)) continue;
|
|
samples++;
|
|
if (!rowsEquivalent(row, old)) mismatches++;
|
|
}
|
|
// No signal (all-blank tail) or at most one edited row: aligned.
|
|
if (samples === 0 || mismatches <= 1) return -1;
|
|
}
|
|
// Misaligned (or the frame no longer covers the prefix): re-anchor at the
|
|
// first row whose content actually changed.
|
|
const limit = Math.min(committed, frame.length);
|
|
for (let i = 0; i < limit; i++) {
|
|
if (!rowsEquivalent(frame[i]!, prefix[i]!)) return i;
|
|
}
|
|
return limit < committed ? limit : -1;
|
|
}
|
|
|
|
/**
|
|
* TUI - Main class for managing terminal UI with differential rendering
|
|
*/
|
|
export class TUI extends Container {
|
|
terminal: Terminal;
|
|
#previousFrameLength = 0;
|
|
#previousWidth = 0;
|
|
#previousHeight = 0;
|
|
#focusedComponent: Component | null = null;
|
|
#inputListeners = new Set<InputListener>();
|
|
#startListeners = new Set<StartListener>();
|
|
|
|
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
|
|
onDebug?: () => void;
|
|
#renderRequested = false;
|
|
#renderTimer: RenderTimer | undefined;
|
|
#renderScheduler: RenderScheduler;
|
|
#lastRenderAt = 0;
|
|
static readonly #MIN_RENDER_INTERVAL_MS = 1000 / 30;
|
|
// Pane-reflow settle window for tmux/screen/zellij. The host process gets
|
|
// SIGWINCH (and `process.stdout` already reports the new geometry) before
|
|
// the multiplexer finishes repainting the pane at the new size, and
|
|
// drag-resize/pane-close animations fire several events in flight. A forced
|
|
// render on each SIGWINCH races those mid-reflow paints — the multiplexer's
|
|
// catch-up paint then partially overwrites the TUI output, which the user
|
|
// sees as a viewport flash or blank screen before the next throttled frame
|
|
// arrives (issue #2088). Coalescing every SIGWINCH inside this window into
|
|
// a single forced render lets the multiplexer settle first.
|
|
static readonly #MULTIPLEXER_RESIZE_DEBOUNCE_MS = 50;
|
|
// Ghostty can drop Kitty graphics commands sent during its first post-startup
|
|
// settle window, leaving only Unicode placeholder cells. Hold the first image
|
|
// paint until that window has passed; later images render normally.
|
|
static readonly #GHOSTTY_INITIAL_IMAGE_DELAY_MS = 100;
|
|
// Post-paint settle window for ConPTY hosts. The `sessionReplace` /
|
|
// `historyRebuild` / `overlayRebuild` intents drive `#emitFullPaint` over
|
|
// a transcript that overflows the viewport, scroll-pushing everything past
|
|
// the last `height` rows into native scrollback. Windows Terminal's
|
|
// viewport-follow logic gets lossy during that burst: spinner/blink-driven
|
|
// `requestRender(false)` calls firing inside the window each produce another
|
|
// diff write, and the WT host processes them faster than its viewport
|
|
// tracker can keep up — the visible tail ends up parked a few rows above
|
|
// the actual last row until any focus event (Alt+Tab) forces a host repaint.
|
|
// Coalescing every non-forced render inside this window into a single
|
|
// trailing render lets the host fully settle the big paint before any
|
|
// follow-up writes touch the buffer. The first-ever `initial` paint is
|
|
// deliberately exempt: nothing has been on screen yet, so no drift can
|
|
// have accumulated, and tests that start the TUI over an over-tall
|
|
// component depend on the next paint firing without delay. Only armed on
|
|
// ConPTY hosts (`isConPTYHosted()`); other terminals do not exhibit the
|
|
// drift and would just see an unnecessary post-paint latency. See #2095.
|
|
static readonly #CONPTY_POST_FULL_PAINT_SETTLE_MS = 150;
|
|
#postFullPaintSettleUntilMs = 0;
|
|
#postFullPaintSettleTimer: RenderTimer | undefined;
|
|
#hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
|
#hardwareCursorState: HardwareCursorState | null = null;
|
|
#hardwareCursorVisibilityKnown = false;
|
|
#hardwareCursorVisible = false;
|
|
#sixelProbePendingDa = false;
|
|
#sixelProbePendingGraphics = false;
|
|
#sixelProbeBuffer = "";
|
|
#sixelProbeTimeout?: NodeJS.Timeout;
|
|
#sixelProbeUnsubscribe?: () => void;
|
|
#showHardwareCursor = $flag("PI_HARDWARE_CURSOR");
|
|
#synchronizedOutputEnabled = shouldEnableSynchronizedOutputByDefault();
|
|
#paintBeginSequence = this.#synchronizedOutputEnabled ? PAINT_BEGIN : PAINT_BEGIN_NO_SYNC;
|
|
#paintEndSequence = this.#synchronizedOutputEnabled ? PAINT_END : PAINT_END_NO_SYNC;
|
|
#cursorBeginSequence = this.#synchronizedOutputEnabled ? CURSOR_BEGIN : CURSOR_BEGIN_NO_SYNC;
|
|
#cursorEndSequence = this.#synchronizedOutputEnabled ? CURSOR_END : CURSOR_END_NO_SYNC;
|
|
// Rows of the current frame physically committed to the terminal tape
|
|
// (native scrollback or scrolled past the window top). Immutable by
|
|
// contract: the engine never rewrites them, and components keep mutable
|
|
// rows below the `NativeScrollbackLiveRegion` boundary so they never get
|
|
// here while they can still change.
|
|
#committedRows = 0;
|
|
// Raw rows mirroring [0, #committedRows) — the engine's claim of what it
|
|
// committed, audited each ordinary frame against the current render to
|
|
// detect components re-laying-out committed content (see
|
|
// #auditCommittedPrefix). Holds references to component-cached strings, so
|
|
// the audit is a pointer walk in the common case.
|
|
#committedPrefix: string[] = [];
|
|
// Frame row currently mapped to screen row 0. Monotonic between full
|
|
// paints: a shrink never re-exposes scrolled-off rows (they cannot be
|
|
// un-scrolled without rewriting history); live rows repaint at fixed
|
|
// positions with blank rows below the shrunken tail.
|
|
#windowTopRow = 0;
|
|
// Exactly what is painted on the screen rows (post-composite, prepared).
|
|
#previousWindow: string[] = [];
|
|
#nativeScrollbackLiveRegionStart: number | undefined;
|
|
#nativeScrollbackCommitSafeEnd: number | undefined;
|
|
#fullRedrawCount = 0;
|
|
// Caps how many inline images render as live graphics; older ones fall back
|
|
// to text via a purge + full redraw. Cap is configured by the host app.
|
|
#imageBudget = new ImageBudget(DEFAULT_MAX_INLINE_IMAGES, () => this.requestRender());
|
|
#ghosttyInitialImageDelayDone = false;
|
|
#ghosttyInitialImageDelayTimer: RenderTimer | undefined;
|
|
#ghosttyImageReadyAtMs = 0;
|
|
#clearScrollbackOnNextRender = false;
|
|
#forceViewportRepaintOnNextRender = false;
|
|
#hasEverRendered = false;
|
|
// Set by the terminal resize callback; consumed by the next render. A resize
|
|
// event invalidates the committed screen even when the dimensions net out
|
|
// unchanged by render time (e.g. a 6→4→6 round trip coalesced into one frame
|
|
// budget): the terminal reflowed its buffer on each event, moving rows
|
|
// between the viewport and scrollback, so the previous frame no longer
|
|
// describes the screen. Tracking only the dimension delta misses this.
|
|
#resizeEventPending = false;
|
|
// Active multiplexer SIGWINCH debounce. Reset on each event so the timer
|
|
// only fires once the pane stops resizing. Forced renders (resetDisplay,
|
|
// finishSixelProbe, …) issued during the settle window route through the
|
|
// same timer; their `clearScrollback` intent is OR'd into the deferred
|
|
// flag below so the settled paint still honours every caller's request.
|
|
#multiplexerResizeTimer: RenderTimer | undefined;
|
|
#deferredForcedClearScrollback = false;
|
|
#stopped = false;
|
|
|
|
// Transient alternate-screen state for a fullscreen overlay. While active, the
|
|
// engine paints only the modal on the alt buffer and leaves every
|
|
// normal-screen accounting field (#previousFrameLength, #viewportTopRow, …)
|
|
// untouched, so exiting reconciles cleanly against the terminal-restored
|
|
// normal screen. #altPreviousLines is the last alt frame, for repaint-skip.
|
|
#altActive = false;
|
|
#altPreviousLines: string[] = [];
|
|
#altEnterWidth = 0;
|
|
#altEnterHeight = 0;
|
|
|
|
// Persistent composed frame. The render override splices only rows at/after
|
|
// the stable prefix each frame; cursor markers are stripped at ingestion so
|
|
// the frame never carries them. Returned to render() callers — treated as
|
|
// immutable by them per the Component render contract.
|
|
#composedFrame: string[] = [];
|
|
// Per-root-child segment ledger backing the stable-prefix computation.
|
|
#frameSegments: FrameSegment[] = [];
|
|
#composeWidth = -1;
|
|
// Cursor markers stripped at ingestion, ascending by frame row.
|
|
#frameCursorMarkers: { row: number; col: number }[] = [];
|
|
// Leading rows of #composedFrame byte-identical to the previous compose.
|
|
#renderStablePrefixRows = 0;
|
|
|
|
// Persistent prepared frame, row-aligned with #composedFrame. Entries store
|
|
// normalized, width-fitted content rows without the per-line terminal
|
|
// terminator; terminators are appended only at write time so width checks
|
|
// stay on content, not reset bytes. #preparedValidRows counts the leading
|
|
// rows known prepared against the CURRENT composed frame: a compose lowers
|
|
// it to the stable prefix, a completed prepare raises it to the frame
|
|
// length, and an abandoned frame (ghostty image defer) leaves it lowered so
|
|
// the next prepare revalidates the splice.
|
|
#preparedFrame: string[] = [];
|
|
#preparedMeta: PreparedLine[] = [];
|
|
#preparedValidRows = 0;
|
|
|
|
// Overlay stack for modal components rendered on top of base content
|
|
overlayStack: {
|
|
component: Component;
|
|
options?: OverlayOptions;
|
|
preFocus: Component | null;
|
|
hidden: boolean;
|
|
}[] = [];
|
|
|
|
constructor(terminal: Terminal, showHardwareCursor?: boolean, options?: TUIOptions) {
|
|
super();
|
|
this.terminal = terminal;
|
|
this.#renderScheduler = options?.renderScheduler ?? DEFAULT_RENDER_SCHEDULER;
|
|
this.#showHardwareCursor = showHardwareCursor === undefined ? this.#showHardwareCursor : showHardwareCursor;
|
|
}
|
|
|
|
override render(width: number): readonly string[] {
|
|
width = Math.max(1, width);
|
|
this.#nativeScrollbackLiveRegionStart = undefined;
|
|
this.#nativeScrollbackCommitSafeEnd = undefined;
|
|
const children = this.children;
|
|
const previousSegments = this.#frameSegments;
|
|
const segments: FrameSegment[] = new Array(children.length);
|
|
// A width change re-renders every child; nothing carries over.
|
|
let chainStable = this.#composeWidth === width;
|
|
this.#composeWidth = width;
|
|
let offset = 0;
|
|
let stableRows = 0;
|
|
for (let index = 0; index < children.length; index++) {
|
|
const child = children[index]!;
|
|
const childLines = child.render(width);
|
|
const liveRegionStart = getNativeScrollbackLiveRegionStart(child);
|
|
if (liveRegionStart !== undefined) {
|
|
const boundedStart = Number.isFinite(liveRegionStart)
|
|
? Math.max(0, Math.min(childLines.length, Math.trunc(liveRegionStart)))
|
|
: childLines.length;
|
|
this.#nativeScrollbackLiveRegionStart = offset + boundedStart;
|
|
const commitSafeEnd = getNativeScrollbackCommitSafeEnd(child);
|
|
if (commitSafeEnd !== undefined) {
|
|
const boundedEnd = Number.isFinite(commitSafeEnd)
|
|
? Math.max(boundedStart, Math.min(childLines.length, Math.trunc(commitSafeEnd)))
|
|
: childLines.length;
|
|
this.#nativeScrollbackCommitSafeEnd = offset + boundedEnd;
|
|
}
|
|
}
|
|
// Consume the stability report unconditionally for implementers:
|
|
// reading re-bases the component's baseline to the state this
|
|
// compose is about to ingest (used or not, the current rows are
|
|
// what ends up in the composed frame).
|
|
const reported = getRenderStablePrefixRows(child);
|
|
if (chainStable) {
|
|
const previous = previousSegments[index];
|
|
if (previous !== undefined && previous.component === child && previous.start === offset) {
|
|
let stableCount = 0;
|
|
if (reported !== undefined) {
|
|
// In-place mutator: its report overrides reference equality.
|
|
// Rows beyond the previous row count cannot be "unchanged".
|
|
stableCount = Number.isFinite(reported)
|
|
? Math.max(0, Math.min(childLines.length, previous.rowCount, Math.trunc(reported)))
|
|
: 0;
|
|
} else if (previous.lines === childLines) {
|
|
stableCount = childLines.length;
|
|
}
|
|
stableRows += stableCount;
|
|
// The chain survives only a fully stable segment: identical rows
|
|
// AND identical row count (a grown/shrunk segment shifts every
|
|
// row below it).
|
|
if (stableCount < childLines.length || previous.rowCount !== childLines.length) chainStable = false;
|
|
} else {
|
|
chainStable = false;
|
|
}
|
|
}
|
|
segments[index] = { component: child, lines: childLines, start: offset, rowCount: childLines.length };
|
|
offset += childLines.length;
|
|
}
|
|
this.#frameSegments = segments;
|
|
|
|
const frame = this.#composedFrame;
|
|
// Defensive clamp: stable rows can never exceed what the previous
|
|
// compose actually materialized (only reachable if a child render threw
|
|
// mid-compose on the previous frame).
|
|
if (stableRows > frame.length) stableRows = frame.length;
|
|
if (stableRows !== offset || frame.length !== offset) {
|
|
// Re-ingest every row at/after the stable prefix: truncate, strip
|
|
// cursor markers, record their positions.
|
|
frame.length = stableRows;
|
|
this.#pruneFrameCursorMarkers(stableRows);
|
|
for (const segment of segments) {
|
|
const lines = segment.lines;
|
|
const from = segment.start >= stableRows ? 0 : stableRows - segment.start;
|
|
for (let i = from; i < lines.length; i++) this.#ingestFrameRow(lines[i]!);
|
|
}
|
|
}
|
|
this.#renderStablePrefixRows = stableRows;
|
|
this.#preparedValidRows = Math.min(this.#preparedValidRows, stableRows);
|
|
return frame;
|
|
}
|
|
|
|
/** Drop cached cursor markers at/after `fromRow` (those rows re-ingest). */
|
|
#pruneFrameCursorMarkers(fromRow: number): void {
|
|
const markers = this.#frameCursorMarkers;
|
|
let keep = markers.length;
|
|
while (keep > 0 && markers[keep - 1]!.row >= fromRow) keep--;
|
|
markers.length = keep;
|
|
}
|
|
|
|
/**
|
|
* Append one row to the composed frame, stripping CURSOR_MARKER occurrences
|
|
* (internal sentinels that must never reach the terminal, the committed
|
|
* prefix, or the resync audit) and recording the first marker's position.
|
|
*/
|
|
#ingestFrameRow(line: string): void {
|
|
let markerIndex = line.indexOf(CURSOR_MARKER);
|
|
if (markerIndex === -1) {
|
|
this.#composedFrame.push(line);
|
|
return;
|
|
}
|
|
this.#frameCursorMarkers.push({
|
|
row: this.#composedFrame.length,
|
|
col: visibleWidth(line.slice(0, markerIndex)),
|
|
});
|
|
let stripped = line;
|
|
while (markerIndex !== -1) {
|
|
stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length);
|
|
markerIndex = stripped.indexOf(CURSOR_MARKER, markerIndex);
|
|
}
|
|
this.#composedFrame.push(stripped);
|
|
}
|
|
|
|
#syncTerminalCursorMode(component: Component | null): void {
|
|
if (isFocusable(component)) {
|
|
component.setUseTerminalCursor?.(this.#showHardwareCursor);
|
|
}
|
|
}
|
|
|
|
get fullRedraws(): number {
|
|
return this.#fullRedrawCount;
|
|
}
|
|
|
|
/** Shared budget that caps how many inline images render as live graphics. */
|
|
get imageBudget(): ImageBudget {
|
|
return this.#imageBudget;
|
|
}
|
|
|
|
/**
|
|
* Set how many inline images stay live graphics before older ones fall back
|
|
* to text (`0` disables the cap). Older images are hidden via a graphics purge
|
|
* plus a full redraw on the frame after a new image exceeds the cap.
|
|
*/
|
|
setMaxInlineImages(cap: number): void {
|
|
this.#imageBudget.setCap(cap);
|
|
}
|
|
|
|
getShowHardwareCursor(): boolean {
|
|
return this.#showHardwareCursor;
|
|
}
|
|
|
|
setShowHardwareCursor(enabled: boolean): void {
|
|
if (this.#showHardwareCursor === enabled) return;
|
|
this.#showHardwareCursor = enabled;
|
|
this.#syncTerminalCursorMode(this.#focusedComponent);
|
|
if (!enabled) {
|
|
this.terminal.hideCursor();
|
|
this.#recordHardwareCursorHidden();
|
|
}
|
|
this.requestRender();
|
|
}
|
|
|
|
/**
|
|
* Whether DEC 2026 synchronized-output wrappers are currently emitted around
|
|
* paints. Starts from conservative terminal/env detection and is reconciled at
|
|
* runtime against the terminal's DECRQM mode-2026 report — enabled on a
|
|
* positive report, disabled on a negative one.
|
|
*/
|
|
get synchronizedOutput(): boolean {
|
|
return this.#synchronizedOutputEnabled;
|
|
}
|
|
#deccaraFillsEnabled(): boolean {
|
|
// DECCARA fill rectangles arrive after shortened row text; synchronized
|
|
// output hides that intermediate default-background state from users.
|
|
return TERMINAL.deccara && this.#synchronizedOutputEnabled;
|
|
}
|
|
|
|
setFocus(component: Component | null): void {
|
|
const previousFocusedComponent = this.#focusedComponent;
|
|
// Clear focused flag on old component
|
|
if (isFocusable(previousFocusedComponent)) {
|
|
previousFocusedComponent.focused = false;
|
|
}
|
|
|
|
this.#focusedComponent = component;
|
|
|
|
// Set focused flag on new component and keep its software/hardware cursor
|
|
// rendering mode aligned with TUI's single cursor-visibility preference.
|
|
if (isFocusable(component)) {
|
|
component.focused = true;
|
|
this.#syncTerminalCursorMode(component);
|
|
}
|
|
}
|
|
|
|
/** Component currently receiving keyboard input, if any. */
|
|
getFocused(): Component | null {
|
|
return this.#focusedComponent;
|
|
}
|
|
|
|
/**
|
|
* Show an overlay component with configurable positioning and sizing.
|
|
* Returns a handle to control the overlay's visibility.
|
|
*/
|
|
showOverlay(component: Component, options?: OverlayOptions): OverlayHandle {
|
|
const entry = { component, options, preFocus: this.#focusedComponent, hidden: false };
|
|
this.overlayStack.push(entry);
|
|
// Only focus if overlay is actually visible
|
|
if (this.#isOverlayVisible(entry)) {
|
|
this.setFocus(component);
|
|
}
|
|
this.terminal.hideCursor();
|
|
this.#recordHardwareCursorHidden();
|
|
this.requestRender();
|
|
|
|
// Return handle for controlling this overlay
|
|
return {
|
|
hide: () => {
|
|
const index = this.overlayStack.indexOf(entry);
|
|
if (index !== -1) {
|
|
this.overlayStack.splice(index, 1);
|
|
// Restore focus if this overlay had focus
|
|
if (this.#focusedComponent === component) {
|
|
const topVisible = this.#getTopmostVisibleOverlay();
|
|
this.setFocus(topVisible?.component ?? entry.preFocus);
|
|
}
|
|
if (this.overlayStack.length === 0) {
|
|
this.terminal.hideCursor();
|
|
this.#recordHardwareCursorHidden();
|
|
}
|
|
this.requestRender();
|
|
}
|
|
},
|
|
setHidden: (hidden: boolean) => {
|
|
if (entry.hidden === hidden) return;
|
|
entry.hidden = hidden;
|
|
// Update focus when hiding/showing
|
|
if (hidden) {
|
|
// If this overlay had focus, move focus to next visible or preFocus
|
|
if (this.#focusedComponent === component) {
|
|
const topVisible = this.#getTopmostVisibleOverlay();
|
|
this.setFocus(topVisible?.component ?? entry.preFocus);
|
|
}
|
|
} else {
|
|
// Restore focus to this overlay when showing (if it's actually visible)
|
|
if (this.#isOverlayVisible(entry)) {
|
|
this.setFocus(component);
|
|
}
|
|
}
|
|
this.requestRender();
|
|
},
|
|
isHidden: () => entry.hidden,
|
|
};
|
|
}
|
|
|
|
/** Hide the topmost overlay and restore previous focus. */
|
|
hideOverlay(): void {
|
|
const overlay = this.overlayStack.pop();
|
|
if (!overlay) return;
|
|
// Find topmost visible overlay, or fall back to preFocus
|
|
const topVisible = this.#getTopmostVisibleOverlay();
|
|
this.setFocus(topVisible?.component ?? overlay.preFocus);
|
|
if (this.overlayStack.length === 0) {
|
|
this.terminal.hideCursor();
|
|
this.#recordHardwareCursorHidden();
|
|
}
|
|
this.requestRender();
|
|
}
|
|
|
|
/** Check if there are any visible overlays */
|
|
hasOverlay(): boolean {
|
|
return this.overlayStack.some(o => this.#isOverlayVisible(o));
|
|
}
|
|
|
|
/** Check if an overlay entry is currently visible */
|
|
#isOverlayVisible(entry: (typeof this.overlayStack)[number]): boolean {
|
|
if (entry.hidden) return false;
|
|
if (entry.options?.visible) {
|
|
return entry.options.visible(this.terminal.columns, this.terminal.rows);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Find the topmost visible overlay, if any */
|
|
#getTopmostVisibleOverlay(): (typeof this.overlayStack)[number] | undefined {
|
|
for (let i = this.overlayStack.length - 1; i >= 0; i--) {
|
|
if (this.#isOverlayVisible(this.overlayStack[i])) {
|
|
return this.overlayStack[i];
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
override invalidate(): void {
|
|
super.invalidate();
|
|
for (const overlay of this.overlayStack) overlay.component.invalidate?.();
|
|
}
|
|
|
|
start(options?: TUIStartOptions): void {
|
|
this.#stopped = false;
|
|
this.#ghosttyInitialImageDelayDone = false;
|
|
this.#ghosttyImageReadyAtMs = this.#renderScheduler.now() + TUI.#GHOSTTY_INITIAL_IMAGE_DELAY_MS;
|
|
// A DECRQM report for mode 2026 is authoritative: enable synchronized
|
|
// output when the terminal reports support (upgrading conservatively
|
|
// defaulted-off hosts like zellij/tmux-master/foot) and disable it when
|
|
// the terminal reports it unsupported. An explicit user opt-out/force
|
|
// (resolved at construction) still wins, so skip the probe in that case.
|
|
this.terminal.onPrivateModeReport?.((mode, supported) => {
|
|
if (mode !== 2026) return;
|
|
if (synchronizedOutputUserOverride() !== null) return;
|
|
this.#setSynchronizedOutput(supported);
|
|
});
|
|
this.terminal.start(
|
|
data => this.#handleInput(data),
|
|
() => {
|
|
// Real terminals deliver SIGWINCH (and the equivalent ConPTY
|
|
// notification) atomically with the new `process.stdout` geometry, so
|
|
// a forced render must fire immediately: it clears and replays at the
|
|
// fresh size before the terminal's reflow settles into a state a
|
|
// throttled frame would race. Multiplexer panes (tmux/screen/zellij)
|
|
// do not give that guarantee. The host receives SIGWINCH while the
|
|
// multiplexer is still mid-reflow — it has not finished repainting
|
|
// the pane buffer at the new size — and a drag-resize or pane-close
|
|
// animation fires several events in flight. Forcing a render on each
|
|
// event races those mid-reflow paints: the multiplexer's catch-up
|
|
// paint then partially overwrites the TUI output, which the user sees
|
|
// as a viewport flash or blank screen before the next throttled
|
|
// frame arrives (issue #2088). `#armMultiplexerResizeTimer` coalesces
|
|
// SIGWINCHes (and any forced repaints arriving during the settle
|
|
// window) into a single render once the pane is quiet —
|
|
// `#resizeEventPending` is set first so the eventual render still
|
|
// classifies as a resize.
|
|
this.#resizeEventPending = true;
|
|
if (!isMultiplexerSession()) {
|
|
this.requestRender(true);
|
|
return;
|
|
}
|
|
this.#armMultiplexerResizeTimer(false);
|
|
},
|
|
);
|
|
for (const listener of this.#startListeners) {
|
|
try {
|
|
listener();
|
|
} catch {
|
|
// Startup listeners are feature hooks; one broken hook must not prevent rendering.
|
|
}
|
|
}
|
|
this.terminal.hideCursor();
|
|
this.#recordHardwareCursorHidden();
|
|
this.#querySixelSupport();
|
|
this.#queryCellSize();
|
|
this.requestRender(true, { clearScrollback: options?.clearScrollback === true });
|
|
}
|
|
|
|
addStartListener(listener: StartListener): () => void {
|
|
this.#startListeners.add(listener);
|
|
return () => {
|
|
this.#startListeners.delete(listener);
|
|
};
|
|
}
|
|
|
|
addInputListener(listener: InputListener): () => void {
|
|
this.#inputListeners.add(listener);
|
|
return () => {
|
|
this.#inputListeners.delete(listener);
|
|
};
|
|
}
|
|
|
|
removeInputListener(listener: InputListener): void {
|
|
this.#inputListeners.delete(listener);
|
|
}
|
|
|
|
#querySixelSupport(): void {
|
|
if (TERMINAL.imageProtocol) return;
|
|
if (process.platform !== "win32") return;
|
|
if (!Bun.env.WT_SESSION) return;
|
|
if (!process.stdin.isTTY || !process.stdout.isTTY) return;
|
|
|
|
this.#clearSixelProbeState();
|
|
this.#sixelProbePendingDa = true;
|
|
this.#sixelProbePendingGraphics = true;
|
|
this.#sixelProbeUnsubscribe = this.addInputListener(data => this.#handleSixelProbeInput(data));
|
|
this.terminal.write("\x1b[c");
|
|
this.terminal.write("\x1b[?2;1;0S");
|
|
this.#sixelProbeTimeout = setTimeout(() => {
|
|
this.#finishSixelProbe(false);
|
|
}, 250);
|
|
}
|
|
|
|
#handleSixelProbeInput(data: string): InputListenerResult {
|
|
if (!this.#sixelProbePendingDa && !this.#sixelProbePendingGraphics) {
|
|
return undefined;
|
|
}
|
|
|
|
this.#sixelProbeBuffer += data;
|
|
let passthrough = "";
|
|
let probeOutcome: boolean | null = null;
|
|
|
|
while (this.#sixelProbeBuffer.length > 0) {
|
|
const daMatch = this.#sixelProbeBuffer.match(/\x1b\[\?([0-9;]+)c/u);
|
|
const graphicsMatch = this.#sixelProbeBuffer.match(/\x1b\[\?2;(\d+);([0-9;]+)S/u);
|
|
|
|
if (!daMatch && !graphicsMatch) break;
|
|
|
|
const daIndex = daMatch?.index ?? Number.POSITIVE_INFINITY;
|
|
const graphicsIndex = graphicsMatch?.index ?? Number.POSITIVE_INFINITY;
|
|
const useDa = daIndex <= graphicsIndex;
|
|
const match = useDa ? daMatch : graphicsMatch;
|
|
if (!match || match.index === undefined) break;
|
|
|
|
passthrough += this.#sixelProbeBuffer.slice(0, match.index);
|
|
this.#sixelProbeBuffer = this.#sixelProbeBuffer.slice(match.index + match[0].length);
|
|
|
|
if (useDa && this.#sixelProbePendingDa) {
|
|
this.#sixelProbePendingDa = false;
|
|
const attributes = (match[1] ?? "")
|
|
.split(";")
|
|
.map(value => Number.parseInt(value, 10))
|
|
.filter(value => Number.isFinite(value));
|
|
const hasSixelAttribute = attributes.includes(4);
|
|
if (hasSixelAttribute) {
|
|
this.#sixelProbePendingGraphics = false;
|
|
probeOutcome = true;
|
|
} else if (!this.#sixelProbePendingGraphics) {
|
|
probeOutcome = false;
|
|
}
|
|
} else if (!useDa && this.#sixelProbePendingGraphics) {
|
|
this.#sixelProbePendingGraphics = false;
|
|
const status = Number.parseInt(match[1] ?? "", 10);
|
|
const supportsSixel = !Number.isNaN(status) && status !== 0;
|
|
if (supportsSixel) {
|
|
this.#sixelProbePendingDa = false;
|
|
probeOutcome = true;
|
|
} else if (!this.#sixelProbePendingDa) {
|
|
probeOutcome = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.#sixelProbePendingDa || this.#sixelProbePendingGraphics) {
|
|
const partialStart = this.#getSixelProbePartialStart(this.#sixelProbeBuffer);
|
|
if (partialStart >= 0) {
|
|
passthrough += this.#sixelProbeBuffer.slice(0, partialStart);
|
|
this.#sixelProbeBuffer = this.#sixelProbeBuffer.slice(partialStart);
|
|
} else {
|
|
passthrough += this.#sixelProbeBuffer;
|
|
this.#sixelProbeBuffer = "";
|
|
}
|
|
} else {
|
|
passthrough += this.#sixelProbeBuffer;
|
|
this.#sixelProbeBuffer = "";
|
|
}
|
|
|
|
if (probeOutcome !== null) {
|
|
this.#finishSixelProbe(probeOutcome);
|
|
}
|
|
|
|
if (passthrough.length === 0) {
|
|
return { consume: true };
|
|
}
|
|
|
|
return { data: passthrough };
|
|
}
|
|
|
|
#getSixelProbePartialStart(buffer: string): number {
|
|
const lastEsc = buffer.lastIndexOf("\x1b");
|
|
if (lastEsc < 0) return -1;
|
|
const tail = buffer.slice(lastEsc);
|
|
if (/^\x1b\[\?[0-9;]*$/u.test(tail)) {
|
|
return lastEsc;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
#clearSixelProbeState(): void {
|
|
if (this.#sixelProbeTimeout) {
|
|
clearTimeout(this.#sixelProbeTimeout);
|
|
this.#sixelProbeTimeout = undefined;
|
|
}
|
|
if (this.#sixelProbeUnsubscribe) {
|
|
this.#sixelProbeUnsubscribe();
|
|
this.#sixelProbeUnsubscribe = undefined;
|
|
}
|
|
this.#sixelProbePendingDa = false;
|
|
this.#sixelProbePendingGraphics = false;
|
|
this.#sixelProbeBuffer = "";
|
|
}
|
|
|
|
#finishSixelProbe(supported: boolean): void {
|
|
this.#clearSixelProbeState();
|
|
if (!supported || TERMINAL.imageProtocol) return;
|
|
|
|
setTerminalImageProtocol(ImageProtocol.Sixel);
|
|
this.#queryCellSize();
|
|
this.invalidate();
|
|
this.requestRender(true);
|
|
}
|
|
#queryCellSize(): void {
|
|
// Only query if terminal supports images (cell size is only used for image rendering)
|
|
if (!TERMINAL.imageProtocol) {
|
|
return;
|
|
}
|
|
// Query terminal for cell size in pixels: CSI 16 t
|
|
// Response format: CSI 6 ; height ; width t
|
|
this.terminal.write("\x1b[16t");
|
|
}
|
|
|
|
/**
|
|
* Toggle synchronized-output (DEC 2026) wrappers on paint/cursor writes and
|
|
* recompute the cached begin/end sequences. Driven by the terminal's DECRQM
|
|
* mode-2026 report (#1765 covers the static env opt-out).
|
|
*/
|
|
#setSynchronizedOutput(enabled: boolean): void {
|
|
if (this.#synchronizedOutputEnabled === enabled) return;
|
|
this.#synchronizedOutputEnabled = enabled;
|
|
this.#paintBeginSequence = enabled ? PAINT_BEGIN : PAINT_BEGIN_NO_SYNC;
|
|
this.#paintEndSequence = enabled ? PAINT_END : PAINT_END_NO_SYNC;
|
|
this.#cursorBeginSequence = enabled ? CURSOR_BEGIN : CURSOR_BEGIN_NO_SYNC;
|
|
this.#cursorEndSequence = enabled ? CURSOR_END : CURSOR_END_NO_SYNC;
|
|
}
|
|
|
|
stop(): void {
|
|
// Leave the alt buffer first so the teardown cursor math below runs against
|
|
// the restored normal screen (which #previousLines still describes).
|
|
if (this.#altActive) {
|
|
this.terminal.write(`${MOUSE_TRACKING_OFF}\x1b[?1049l`);
|
|
this.#altActive = false;
|
|
this.#altPreviousLines = [];
|
|
}
|
|
if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
|
|
for (const id of this.#imageBudget.takeAllTransmittedIds()) {
|
|
this.terminal.write(encodeKittyDeleteImage(id));
|
|
}
|
|
}
|
|
this.#clearSixelProbeState();
|
|
this.#stopped = true;
|
|
if (this.#renderTimer) {
|
|
this.#renderTimer.cancel();
|
|
this.#renderTimer = undefined;
|
|
}
|
|
if (this.#ghosttyInitialImageDelayTimer) {
|
|
this.#ghosttyInitialImageDelayTimer.cancel();
|
|
this.#ghosttyInitialImageDelayTimer = undefined;
|
|
}
|
|
if (this.#multiplexerResizeTimer) {
|
|
this.#multiplexerResizeTimer.cancel();
|
|
this.#multiplexerResizeTimer = undefined;
|
|
}
|
|
this.#clearPostFullPaintSettle();
|
|
this.#deferredForcedClearScrollback = false;
|
|
// Place the parent shell on the first line after the rendered content. When
|
|
// that line is still inside the viewport, moving there and writing `\r` is
|
|
// enough; emitting `\r\n` would create an extra blank row. If the content
|
|
// already reaches the viewport bottom, scroll exactly once so the prompt
|
|
// lands directly below the last visible TUI row.
|
|
if (this.#previousFrameLength > 0) {
|
|
const targetRow = this.#previousFrameLength;
|
|
const viewportBottom = this.#windowTopRow + this.terminal.rows - 1;
|
|
const clampedCursorRow = Math.max(this.#windowTopRow, Math.min(this.#hardwareCursorRow, viewportBottom));
|
|
const moveTargetRow = Math.min(targetRow, viewportBottom);
|
|
const lineDiff = moveTargetRow - clampedCursorRow;
|
|
if (lineDiff > 0) {
|
|
this.terminal.write(`\x1b[${lineDiff}B`);
|
|
} else if (lineDiff < 0) {
|
|
this.terminal.write(`\x1b[${-lineDiff}A`);
|
|
}
|
|
this.terminal.write(targetRow <= viewportBottom ? "\r" : "\r\n");
|
|
}
|
|
|
|
this.terminal.showCursor();
|
|
this.#forgetHardwareCursorState();
|
|
this.terminal.stop();
|
|
}
|
|
|
|
/**
|
|
* Force an immediate full replay of the current frame, including native
|
|
* scrollback. This is the keyboard-accessible equivalent of the resize reset:
|
|
* no queued diff frame or terminal scrollback probe can downgrade it to a
|
|
* viewport-only repaint.
|
|
*
|
|
* Invalidates every component first so the replay reflects current state. A
|
|
* geometry-driven reset thaws frozen scrollback snapshots implicitly (the new
|
|
* width misses every cached snapshot), but a same-width reset would otherwise
|
|
* replay stale snapshots — leaving host-frozen blocks (e.g. a transcript whose
|
|
* committed rows are immutable on ED3-risk terminals) showing pre-mutation
|
|
* content. Invalidation is the generic signal those containers use to retire
|
|
* their snapshots, which is exactly what a user-driven display reset wants.
|
|
*/
|
|
resetDisplay(): void {
|
|
if (this.#stopped) return;
|
|
this.invalidate();
|
|
// A reset that lands inside a tmux/screen/zellij resize burst would
|
|
// paint mid-reflow and re-introduce the flash race (issue #2088).
|
|
// Fold it into the in-flight debounce instead; the settled paint runs
|
|
// the same `#prepareForcedRender(!isMultiplexerSession())` path via
|
|
// `requestRender(true)`, so the clear-scrollback intent is preserved.
|
|
if (this.#multiplexerResizeTimer) {
|
|
this.#armMultiplexerResizeTimer(!isMultiplexerSession());
|
|
return;
|
|
}
|
|
this.#prepareForcedRender(!isMultiplexerSession());
|
|
this.#resizeEventPending = true;
|
|
this.#renderRequested = false;
|
|
this.#lastRenderAt = this.#renderScheduler.now();
|
|
this.#doRender();
|
|
}
|
|
|
|
requestRender(force = false, options?: RenderRequestOptions): void {
|
|
if (force) {
|
|
// Forced repaints landing inside the multiplexer resize debounce
|
|
// (e.g. `#finishSixelProbe`, image-budget eviction, a programmatic
|
|
// `requestRender(true)`) would paint into a still-reflowing pane
|
|
// and reintroduce the flash race. Fold them into the in-flight
|
|
// debounce while preserving the caller's `clearScrollback` intent
|
|
// for the settled paint. The timer's own callback clears
|
|
// `#multiplexerResizeTimer` before re-entering `requestRender(true)`,
|
|
// so this guard only catches external callers — the deferred render
|
|
// itself proceeds straight to `#prepareForcedRender`.
|
|
if (this.#multiplexerResizeTimer) {
|
|
this.#armMultiplexerResizeTimer(options?.clearScrollback === true);
|
|
return;
|
|
}
|
|
// A forced render preempts the post-full-paint ConPTY settle: it owns
|
|
// the next paint and is going to redraw the buffer anyway, so the
|
|
// trailing coalesced render queued by the settle would only race it.
|
|
this.#clearPostFullPaintSettle();
|
|
this.#prepareForcedRender(options?.clearScrollback === true);
|
|
this.#renderRequested = true;
|
|
this.#renderScheduler.scheduleImmediate(() => {
|
|
if (this.#stopped || !this.#renderRequested) {
|
|
return;
|
|
}
|
|
this.#renderRequested = false;
|
|
this.#lastRenderAt = this.#renderScheduler.now();
|
|
this.#doRender();
|
|
});
|
|
return;
|
|
}
|
|
// Coalesce non-forced renders inside the post-full-paint ConPTY settle
|
|
// window into one trailing render. Spinner/blink/streaming components
|
|
// otherwise fire `requestRender(false)` at 30 Hz while the host is still
|
|
// catching up with the previous big paint, and each follow-up viewport
|
|
// repaint nudges Windows Terminal's viewport tracker further off the
|
|
// last row (see #2095).
|
|
if (this.#postFullPaintSettleUntilMs > 0) {
|
|
const now = this.#renderScheduler.now();
|
|
if (now < this.#postFullPaintSettleUntilMs) {
|
|
if (this.#postFullPaintSettleTimer === undefined) {
|
|
this.#postFullPaintSettleTimer = this.#renderScheduler.scheduleRender(() => {
|
|
this.#postFullPaintSettleTimer = undefined;
|
|
this.#postFullPaintSettleUntilMs = 0;
|
|
if (this.#stopped) return;
|
|
this.requestRender(false);
|
|
}, this.#postFullPaintSettleUntilMs - now);
|
|
}
|
|
return;
|
|
}
|
|
this.#postFullPaintSettleUntilMs = 0;
|
|
}
|
|
if (this.#renderRequested) return;
|
|
this.#renderRequested = true;
|
|
this.#renderScheduler.scheduleImmediate(() => this.#scheduleRender());
|
|
}
|
|
|
|
/**
|
|
* Arm or extend the multiplexer-resize debounce so a single forced render
|
|
* fires once the pane is quiet. Called by the SIGWINCH callback on every
|
|
* resize event, and by `requestRender(true)` / `resetDisplay()` when they
|
|
* land inside an in-flight settle window. Each call cancels the prior
|
|
* timer, supersedes any queued throttled render (otherwise it would race
|
|
* tmux's mid-reflow paint), and OR's the caller's `clearScrollback`
|
|
* intent into `#deferredForcedClearScrollback` — the timer's callback
|
|
* consumes that flag exactly once when it re-enters `requestRender(true)`.
|
|
*/
|
|
#armMultiplexerResizeTimer(clearScrollback: boolean): void {
|
|
this.#deferredForcedClearScrollback ||= clearScrollback;
|
|
if (this.#renderTimer) {
|
|
this.#renderTimer.cancel();
|
|
this.#renderTimer = undefined;
|
|
}
|
|
this.#renderRequested = false;
|
|
if (this.#multiplexerResizeTimer) {
|
|
this.#multiplexerResizeTimer.cancel();
|
|
}
|
|
this.#multiplexerResizeTimer = this.#renderScheduler.scheduleRender(() => {
|
|
this.#multiplexerResizeTimer = undefined;
|
|
if (this.#stopped) {
|
|
this.#deferredForcedClearScrollback = false;
|
|
return;
|
|
}
|
|
const deferredClearScrollback = this.#deferredForcedClearScrollback;
|
|
this.#deferredForcedClearScrollback = false;
|
|
this.requestRender(true, { clearScrollback: deferredClearScrollback });
|
|
}, TUI.#MULTIPLEXER_RESIZE_DEBOUNCE_MS);
|
|
}
|
|
|
|
/**
|
|
* Arm the post-full-paint settle window after an `#emitFullPaint` that
|
|
* pushed content into native scrollback on a ConPTY host. Idempotent inside
|
|
* the window: a later overflowing paint extends `until` to the later
|
|
* deadline so back-to-back big paints do not double-fire the trailing
|
|
* coalesced render, and the existing deferred timer is rescheduled to the
|
|
* later deadline.
|
|
*
|
|
* Mid-composition callers (most notably `ImageBudget.endPass()`, which can
|
|
* call `requestRender()` from inside the in-flight paint when a new image
|
|
* trips the budget) queue their render *before* the settle exists, so they
|
|
* fall through the gate and set `#renderRequested` / `#renderTimer` on the
|
|
* 30 Hz throttle. Without absorbing those, the throttled follow-up fires
|
|
* inside the 150 ms quiet window and reintroduces the cascade the settle
|
|
* was meant to stop. Cancel both, then eagerly arm the trailing settle
|
|
* timer so the in-flight request still rides one coalesced render at the
|
|
* end of the window. See #2095.
|
|
*/
|
|
#armPostFullPaintSettle(): void {
|
|
if (!isConPTYHosted()) return;
|
|
const until = this.#renderScheduler.now() + TUI.#CONPTY_POST_FULL_PAINT_SETTLE_MS;
|
|
if (until <= this.#postFullPaintSettleUntilMs) return;
|
|
this.#postFullPaintSettleUntilMs = until;
|
|
const hadPendingRender = this.#renderRequested || this.#renderTimer !== undefined;
|
|
// Reclaim any render that was queued during the in-flight composition:
|
|
// `#renderRequested` was set before the settle existed and would
|
|
// otherwise fire on the standard throttle inside the window.
|
|
this.#renderRequested = false;
|
|
if (this.#renderTimer) {
|
|
this.#renderTimer.cancel();
|
|
this.#renderTimer = undefined;
|
|
}
|
|
if (this.#postFullPaintSettleTimer) {
|
|
this.#postFullPaintSettleTimer.cancel();
|
|
this.#postFullPaintSettleTimer = undefined;
|
|
}
|
|
if (hadPendingRender) {
|
|
// Replay the absorbed request via the trailing settle timer so the
|
|
// caller's render still happens — just deferred to the end of the
|
|
// window. Subsequent `requestRender(false)` calls during the
|
|
// settle see this timer and fold into it (existing gate at L1263).
|
|
this.#postFullPaintSettleTimer = this.#renderScheduler.scheduleRender(() => {
|
|
this.#postFullPaintSettleTimer = undefined;
|
|
this.#postFullPaintSettleUntilMs = 0;
|
|
if (this.#stopped) return;
|
|
this.requestRender(false);
|
|
}, TUI.#CONPTY_POST_FULL_PAINT_SETTLE_MS);
|
|
}
|
|
}
|
|
|
|
#clearPostFullPaintSettle(): void {
|
|
if (this.#postFullPaintSettleTimer) {
|
|
this.#postFullPaintSettleTimer.cancel();
|
|
this.#postFullPaintSettleTimer = undefined;
|
|
}
|
|
this.#postFullPaintSettleUntilMs = 0;
|
|
}
|
|
|
|
#maybeDeferGhosttyInitialImagePaint(): boolean {
|
|
if (this.#ghosttyInitialImageDelayDone) return false;
|
|
if (TERMINAL.id !== "ghostty" || TERMINAL.imageProtocol !== ImageProtocol.Kitty) {
|
|
this.#ghosttyInitialImageDelayDone = true;
|
|
return false;
|
|
}
|
|
if (!this.#imageBudget.hasPendingTransmits()) return false;
|
|
if (this.#ghosttyInitialImageDelayTimer) return true;
|
|
|
|
const delayMs = Math.max(0, this.#ghosttyImageReadyAtMs - this.#renderScheduler.now());
|
|
if (delayMs === 0) {
|
|
this.#ghosttyInitialImageDelayDone = true;
|
|
return false;
|
|
}
|
|
|
|
this.#ghosttyInitialImageDelayTimer = this.#renderScheduler.scheduleRender(() => {
|
|
this.#ghosttyInitialImageDelayTimer = undefined;
|
|
this.#ghosttyInitialImageDelayDone = true;
|
|
if (this.#stopped) return;
|
|
this.#lastRenderAt = this.#renderScheduler.now();
|
|
this.#doRender();
|
|
if (this.#renderRequested) this.#scheduleRender();
|
|
}, delayMs);
|
|
return true;
|
|
}
|
|
#prepareForcedRender(clearScrollback: boolean): void {
|
|
this.#clearScrollbackOnNextRender ||= clearScrollback;
|
|
this.#forceViewportRepaintOnNextRender = true;
|
|
if (this.#renderTimer) {
|
|
this.#renderTimer.cancel();
|
|
this.#renderTimer = undefined;
|
|
}
|
|
}
|
|
|
|
#scheduleRender(): void {
|
|
if (this.#stopped || this.#renderTimer || !this.#renderRequested) {
|
|
return;
|
|
}
|
|
// Defer any new throttled render scheduled inside the multiplexer
|
|
// resize settle window: it would race tmux's mid-reflow pane repaint.
|
|
// `#renderRequested` stays set so the eventual forced render — armed
|
|
// by the SIGWINCH callback — picks up the latest component state.
|
|
if (this.#multiplexerResizeTimer) {
|
|
return;
|
|
}
|
|
const elapsed = this.#renderScheduler.now() - this.#lastRenderAt;
|
|
const delay = Math.max(0, TUI.#MIN_RENDER_INTERVAL_MS - elapsed);
|
|
this.#renderTimer = this.#renderScheduler.scheduleRender(() => {
|
|
this.#renderTimer = undefined;
|
|
if (this.#stopped || !this.#renderRequested) {
|
|
return;
|
|
}
|
|
this.#renderRequested = false;
|
|
this.#lastRenderAt = this.#renderScheduler.now();
|
|
this.#doRender();
|
|
if (this.#renderRequested) {
|
|
this.#scheduleRender();
|
|
}
|
|
}, delay);
|
|
}
|
|
|
|
#handleInput(data: string): void {
|
|
if (this.#inputListeners.size > 0) {
|
|
let current = data;
|
|
for (const listener of this.#inputListeners) {
|
|
const result = listener(current);
|
|
if (result?.consume) {
|
|
return;
|
|
}
|
|
if (result?.data !== undefined) {
|
|
current = result.data;
|
|
}
|
|
}
|
|
if (current.length === 0) {
|
|
return;
|
|
}
|
|
data = current;
|
|
}
|
|
|
|
// Consume terminal cell size responses without blocking unrelated input.
|
|
if (this.#consumeCellSizeResponse(data)) {
|
|
return;
|
|
}
|
|
|
|
// Global debug key handler (Shift+Ctrl+D)
|
|
if (matchesKey(data, "shift+ctrl+d") && this.onDebug) {
|
|
this.onDebug();
|
|
return;
|
|
}
|
|
|
|
// If focused component is an overlay, verify it's still visible
|
|
// (visibility can change due to terminal resize or visible() callback)
|
|
const focusedOverlay = this.overlayStack.find(o => o.component === this.#focusedComponent);
|
|
if (focusedOverlay && !this.#isOverlayVisible(focusedOverlay)) {
|
|
// Focused overlay is no longer visible, redirect to topmost visible overlay
|
|
const topVisible = this.#getTopmostVisibleOverlay();
|
|
if (topVisible) {
|
|
this.setFocus(topVisible.component);
|
|
} else {
|
|
// No visible overlays, restore to preFocus
|
|
this.setFocus(focusedOverlay.preFocus);
|
|
}
|
|
}
|
|
|
|
// Pass input to focused component (including Ctrl+C)
|
|
// The focused component can decide how to handle Ctrl+C
|
|
if (this.#focusedComponent?.handleInput) {
|
|
// Filter out key release events unless component opts in
|
|
if (isKeyRelease(data) && !this.#focusedComponent.wantsKeyRelease) {
|
|
return;
|
|
}
|
|
this.#focusedComponent.handleInput(data);
|
|
this.requestRender();
|
|
}
|
|
}
|
|
|
|
#consumeCellSizeResponse(data: string): boolean {
|
|
// Response format: ESC [ 6 ; height ; width t
|
|
const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
|
|
if (!match) {
|
|
return false;
|
|
}
|
|
|
|
const heightPx = parseInt(match[1], 10);
|
|
const widthPx = parseInt(match[2], 10);
|
|
if (heightPx <= 0 || widthPx <= 0) {
|
|
return true;
|
|
}
|
|
|
|
setCellDimensions({ widthPx, heightPx });
|
|
// Invalidate all components so images re-render with correct dimensions.
|
|
this.invalidate();
|
|
this.requestRender();
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Resolve overlay layout from options.
|
|
* Returns { width, row, col, maxHeight } for rendering.
|
|
*/
|
|
#resolveOverlayLayout(
|
|
options: OverlayOptions | undefined,
|
|
overlayHeight: number,
|
|
termWidth: number,
|
|
termHeight: number,
|
|
): { width: number; row: number; col: number; maxHeight: number | undefined } {
|
|
const opt = options ?? {};
|
|
|
|
// Parse margin (clamp to non-negative)
|
|
const margin =
|
|
typeof opt.margin === "number"
|
|
? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin }
|
|
: (opt.margin ?? {});
|
|
const marginTop = Math.max(0, margin.top ?? 0);
|
|
const marginRight = Math.max(0, margin.right ?? 0);
|
|
const marginBottom = Math.max(0, margin.bottom ?? 0);
|
|
const marginLeft = Math.max(0, margin.left ?? 0);
|
|
|
|
// Available space after margins
|
|
const availWidth = Math.max(1, termWidth - marginLeft - marginRight);
|
|
const availHeight = Math.max(1, termHeight - marginTop - marginBottom);
|
|
|
|
// === Resolve width ===
|
|
let width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth);
|
|
// Apply minWidth
|
|
if (opt.minWidth !== undefined) {
|
|
width = Math.max(width, opt.minWidth);
|
|
}
|
|
// Clamp to available space
|
|
width = Math.max(1, Math.min(width, availWidth));
|
|
|
|
// === Resolve maxHeight ===
|
|
let maxHeight = parseSizeValue(opt.maxHeight, termHeight);
|
|
// Clamp to available space
|
|
if (maxHeight !== undefined) {
|
|
maxHeight = Math.max(1, Math.min(maxHeight, availHeight));
|
|
}
|
|
|
|
// Effective overlay height (may be clamped by maxHeight)
|
|
const effectiveHeight = maxHeight !== undefined ? Math.min(overlayHeight, maxHeight) : overlayHeight;
|
|
|
|
// === Resolve position ===
|
|
let row: number;
|
|
let col: number;
|
|
|
|
if (opt.row !== undefined) {
|
|
if (typeof opt.row === "string") {
|
|
// Percentage: 0% = top, 100% = bottom (overlay stays within bounds)
|
|
const match = opt.row.match(/^(\d+(?:\.\d+)?)%$/);
|
|
if (match) {
|
|
const maxRow = Math.max(0, availHeight - effectiveHeight);
|
|
const percent = parseFloat(match[1]) / 100;
|
|
row = marginTop + Math.floor(maxRow * percent);
|
|
} else {
|
|
// Invalid format, fall back to center
|
|
row = this.#resolveAnchorRow("center", effectiveHeight, availHeight, marginTop);
|
|
}
|
|
} else {
|
|
// Absolute row position
|
|
row = opt.row;
|
|
}
|
|
} else {
|
|
// Anchor-based (default: center)
|
|
const anchor = opt.anchor ?? "center";
|
|
row = this.#resolveAnchorRow(anchor, effectiveHeight, availHeight, marginTop);
|
|
}
|
|
|
|
if (opt.col !== undefined) {
|
|
if (typeof opt.col === "string") {
|
|
// Percentage: 0% = left, 100% = right (overlay stays within bounds)
|
|
const match = opt.col.match(/^(\d+(?:\.\d+)?)%$/);
|
|
if (match) {
|
|
const maxCol = Math.max(0, availWidth - width);
|
|
const percent = parseFloat(match[1]) / 100;
|
|
col = marginLeft + Math.floor(maxCol * percent);
|
|
} else {
|
|
// Invalid format, fall back to center
|
|
col = this.#resolveAnchorCol("center", width, availWidth, marginLeft);
|
|
}
|
|
} else {
|
|
// Absolute column position
|
|
col = opt.col;
|
|
}
|
|
} else {
|
|
// Anchor-based (default: center)
|
|
const anchor = opt.anchor ?? "center";
|
|
col = this.#resolveAnchorCol(anchor, width, availWidth, marginLeft);
|
|
}
|
|
|
|
// Apply offsets
|
|
if (opt.offsetY !== undefined) row += opt.offsetY;
|
|
if (opt.offsetX !== undefined) col += opt.offsetX;
|
|
|
|
// Clamp to terminal bounds (respecting margins)
|
|
row = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight));
|
|
col = Math.max(marginLeft, Math.min(col, termWidth - marginRight - width));
|
|
|
|
return { width, row, col, maxHeight };
|
|
}
|
|
|
|
#resolveAnchorRow(anchor: OverlayAnchor, height: number, availHeight: number, marginTop: number): number {
|
|
switch (anchor) {
|
|
case "top-left":
|
|
case "top-center":
|
|
case "top-right":
|
|
return marginTop;
|
|
case "bottom-left":
|
|
case "bottom-center":
|
|
case "bottom-right":
|
|
return marginTop + availHeight - height;
|
|
case "left-center":
|
|
case "center":
|
|
case "right-center":
|
|
return marginTop + Math.floor((availHeight - height) / 2);
|
|
}
|
|
}
|
|
|
|
#resolveAnchorCol(anchor: OverlayAnchor, width: number, availWidth: number, marginLeft: number): number {
|
|
switch (anchor) {
|
|
case "top-left":
|
|
case "left-center":
|
|
case "bottom-left":
|
|
return marginLeft;
|
|
case "top-right":
|
|
case "right-center":
|
|
case "bottom-right":
|
|
return marginLeft + availWidth - width;
|
|
case "top-center":
|
|
case "center":
|
|
case "bottom-center":
|
|
return marginLeft + Math.floor((availWidth - width) / 2);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Composite all visible overlays into the window slice (screen
|
|
* coordinates, in stack order, later = on top). Overlays never touch the
|
|
* frame: composited rows exist only in the painted window, and commits are
|
|
* frozen while an overlay is visible, so overlay pixels can never enter
|
|
* native scrollback.
|
|
*/
|
|
#compositeOverlaysIntoWindow(window: string[], termWidth: number, termHeight: number): string[] {
|
|
const result = [...window];
|
|
for (const entry of this.overlayStack) {
|
|
if (!this.#isOverlayVisible(entry)) continue;
|
|
const { component, options } = entry;
|
|
// Get layout with height=0 first to determine width and maxHeight
|
|
// (width and maxHeight don't depend on overlay height).
|
|
const { width, maxHeight } = this.#resolveOverlayLayout(options, 0, termWidth, termHeight);
|
|
let overlayLines = component.render(width);
|
|
if (maxHeight !== undefined && overlayLines.length > maxHeight) {
|
|
overlayLines = overlayLines.slice(0, maxHeight);
|
|
}
|
|
const { row, col } = this.#resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight);
|
|
for (let i = 0; i < overlayLines.length; i++) {
|
|
const idx = row + i;
|
|
if (idx < 0 || idx >= result.length) continue;
|
|
const truncatedOverlayLine =
|
|
visibleWidth(overlayLines[i]) > width ? sliceByColumn(overlayLines[i], 0, width, true) : overlayLines[i];
|
|
result[idx] = this.#compositeLineAt(result[idx], truncatedOverlayLine, col, width, termWidth);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** Splice overlay content into a base line at a specific column. Single-pass optimized. */
|
|
#compositeLineAt(
|
|
baseLine: string,
|
|
overlayLine: string,
|
|
startCol: number,
|
|
overlayWidth: number,
|
|
totalWidth: number,
|
|
): string {
|
|
if (TERMINAL.isImageLine(baseLine)) return baseLine;
|
|
|
|
// Single pass through baseLine extracts both before and after segments
|
|
const afterStart = startCol + overlayWidth;
|
|
const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
|
|
|
|
// Extract overlay with width tracking (strict=true to exclude wide chars at boundary)
|
|
const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
|
|
|
|
// Pad segments to target widths
|
|
const beforePad = Math.max(0, startCol - base.beforeWidth);
|
|
const overlayPad = Math.max(0, overlayWidth - overlay.width);
|
|
const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
|
|
const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
|
|
const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
|
|
const afterPad = Math.max(0, afterTarget - base.afterWidth);
|
|
|
|
// Compose result
|
|
const r = SEGMENT_RESET;
|
|
const result =
|
|
base.before +
|
|
" ".repeat(beforePad) +
|
|
r +
|
|
overlay.text +
|
|
" ".repeat(overlayPad) +
|
|
r +
|
|
base.after +
|
|
" ".repeat(afterPad);
|
|
|
|
// CRITICAL: Always verify and truncate to terminal width.
|
|
// This is the final safeguard against width overflow which would crash the TUI.
|
|
// Width tracking can drift from actual visible width due to:
|
|
// - Complex ANSI/OSC sequences (hyperlinks, colors)
|
|
// - Wide characters at segment boundaries
|
|
// - Edge cases in segment extraction
|
|
const resultWidth = visibleWidth(result);
|
|
if (resultWidth <= totalWidth) {
|
|
return result;
|
|
}
|
|
// Truncate with strict=true to ensure we don't exceed totalWidth
|
|
return sliceByColumn(result, 0, totalWidth, true);
|
|
}
|
|
|
|
/**
|
|
* Strip every CURSOR_MARKER from the rendered lines (markers are internal
|
|
* sentinels and must never reach the terminal, the committed prefix, or
|
|
* the resync audit) and return the positions of the stripped markers,
|
|
* bottom-most first. Callers pick the visible one once the window top is
|
|
* known.
|
|
*/
|
|
#extractCursorMarkers(lines: string[]): { row: number; col: number }[] {
|
|
const markers: { row: number; col: number }[] = [];
|
|
for (let row = lines.length - 1; row >= 0; row--) {
|
|
const line = lines[row];
|
|
let markerIndex = line.indexOf(CURSOR_MARKER);
|
|
if (markerIndex === -1) continue;
|
|
const beforeMarker = line.slice(0, markerIndex);
|
|
markers.push({ row, col: visibleWidth(beforeMarker) });
|
|
let stripped = line;
|
|
while (markerIndex !== -1) {
|
|
stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length);
|
|
markerIndex = stripped.indexOf(CURSOR_MARKER, markerIndex);
|
|
}
|
|
lines[row] = stripped;
|
|
}
|
|
return markers;
|
|
}
|
|
|
|
#terminalLine(line: string): string {
|
|
if (TERMINAL.isImageLine(line)) return line;
|
|
return line + (line.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
|
|
}
|
|
|
|
/**
|
|
* Render one frame.
|
|
*
|
|
* Append-only pipeline: compose the frame, derive the commit boundary from
|
|
* the component-reported live-region seam, advance the committed-row count
|
|
* monotonically, and emit either a gesture-driven full paint or an
|
|
* incremental update. Scrollback is `frame[0..committedRows)` at all
|
|
* times — no viewport probes, no deferred reconciliation.
|
|
*/
|
|
#doRender(): void {
|
|
if (this.#stopped) return;
|
|
const width = this.terminal.columns;
|
|
const height = this.terminal.rows;
|
|
|
|
// Fullscreen alt-screen short-circuit. While the topmost visible overlay
|
|
// requests it, borrow the terminal's alternate buffer and paint only the
|
|
// modal there; the normal screen and all accounting stay untouched.
|
|
const wantAlt = this.#wantsAltScreen();
|
|
if (wantAlt && !this.#altActive) {
|
|
this.terminal.write(`\x1b[?1049h${MOUSE_TRACKING_ON}`);
|
|
this.terminal.hideCursor();
|
|
this.#forgetHardwareCursorState();
|
|
this.#recordHardwareCursorHidden();
|
|
this.#altActive = true;
|
|
this.#altPreviousLines = [];
|
|
this.#altEnterWidth = width;
|
|
this.#altEnterHeight = height;
|
|
} else if (!wantAlt && this.#altActive) {
|
|
this.terminal.write(`${MOUSE_TRACKING_OFF}\x1b[?1049l`);
|
|
this.#forgetHardwareCursorState();
|
|
this.#altActive = false;
|
|
this.#altPreviousLines = [];
|
|
// A resize while on the alt buffer reflowed the terminal's saved
|
|
// normal screen; it no longer matches our accounting, so force the
|
|
// geometry rebuild path instead of a stale diff.
|
|
if (width !== this.#altEnterWidth || height !== this.#altEnterHeight) {
|
|
this.#resizeEventPending = true;
|
|
}
|
|
}
|
|
if (this.#altActive) {
|
|
this.#renderAltFrame(width, height);
|
|
return;
|
|
}
|
|
|
|
// 1. Compose the frame. Bracket the render so the image budget observes
|
|
// every inline image in display order (overlays carry none).
|
|
this.#imageBudget.beginPass();
|
|
const rawFrame = this.render(width);
|
|
this.#imageBudget.endPass();
|
|
// Ghostty initial-image deferral must run before any render state is
|
|
// consumed (#resizeEventPending, hardware-cursor state, commit
|
|
// re-anchoring): the early return abandons this frame and the deferred
|
|
// render recomposes from scratch, so consuming state here would
|
|
// misclassify a pending resize as an ordinary diff and corrupt the paint.
|
|
if (this.#maybeDeferGhosttyInitialImagePaint()) return;
|
|
// Cursor markers were stripped at compose time (they are internal
|
|
// sentinels and must never reach the terminal, the committed prefix, or
|
|
// the audit); the visible marker is chosen after the window top is
|
|
// known. Ascending by frame row.
|
|
const cursorMarkers = this.#frameCursorMarkers;
|
|
const liveRegionStart = this.#nativeScrollbackLiveRegionStart;
|
|
const commitSafeEnd = this.#nativeScrollbackCommitSafeEnd;
|
|
|
|
// 2. Transition state captured before any emitter runs.
|
|
const prevWindowTop = this.#windowTopRow;
|
|
const prevHardwareCursorRow = this.#hardwareCursorRow;
|
|
const resizeEventOccurred = this.#resizeEventPending;
|
|
this.#resizeEventPending = false;
|
|
if (resizeEventOccurred) this.#forgetHardwareCursorState();
|
|
const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
|
|
// A resize event with net-unchanged dimensions still reflowed the
|
|
// terminal buffer; classify it as a height change so geometry handling
|
|
// repaints instead of diffing against a screen that no longer exists.
|
|
const heightChanged =
|
|
(this.#previousHeight > 0 && this.#previousHeight !== height) ||
|
|
(resizeEventOccurred && this.#previousHeight > 0);
|
|
const geometryChanged = widthChanged || heightChanged;
|
|
|
|
// Committed-prefix audit: rows below the commit index are physically in
|
|
// terminal history and must never re-layout. When a component violates
|
|
// that — a budget-demoted image collapsing to its one-line fallback, a
|
|
// TTSR rewind truncating a block whose sealed prefix already committed —
|
|
// keeping the old index would silently skip that many rows of
|
|
// everything below (content loss). Re-anchor at the divergence instead:
|
|
// the stale copy stays in history and rows recommit from there —
|
|
// duplication, never loss. Skipped on geometry frames (a rewrap
|
|
// legitimately reflows every row; the mux branch re-bases the prefix
|
|
// and non-mux geometry replays from scratch), and skipped when the
|
|
// composed frame's stable prefix covers every committed row — bytes
|
|
// that provably did not change since the last (aligned) frame cannot
|
|
// have diverged.
|
|
if (
|
|
this.#hasEverRendered &&
|
|
!geometryChanged &&
|
|
!this.#clearScrollbackOnNextRender &&
|
|
this.#renderStablePrefixRows < this.#committedRows
|
|
) {
|
|
this.#auditCommittedPrefix(rawFrame);
|
|
}
|
|
|
|
// 3. Window and commit math (lengths only; content prepared below).
|
|
const frameLength = rawFrame.length;
|
|
let hasVisibleOverlay = false;
|
|
for (const entry of this.overlayStack) {
|
|
if (this.#isOverlayVisible(entry)) {
|
|
hasVisibleOverlay = true;
|
|
break;
|
|
}
|
|
}
|
|
// The commit boundary: rows below it may still re-layout and must never
|
|
// enter native history. Finalized prefix (live-region start), deepened
|
|
// by an append-only block's sealed prefix; the whole frame when the
|
|
// root reports no seam (shell semantics: whatever scrolls is final).
|
|
const commitBoundary = Math.max(0, Math.min(frameLength, commitSafeEnd ?? liveRegionStart ?? frameLength));
|
|
|
|
// 4. Classify. A resize is an explicit user gesture: outside a
|
|
// multiplexer it erases and replays so history rewraps at the new
|
|
// geometry (the reader snapped to the bottom just dragged the window);
|
|
// inside one the pane reflows its own history, so repaint in place.
|
|
const firstPaint = !this.#hasEverRendered;
|
|
const replaceRequested = this.#clearScrollbackOnNextRender;
|
|
const geometryRebuild = geometryChanged && !isMultiplexerSession();
|
|
const fullPaint = firstPaint || replaceRequested || geometryRebuild;
|
|
let windowTop: number;
|
|
let chunkTo: number;
|
|
if (fullPaint) {
|
|
windowTop = Math.max(0, frameLength - height);
|
|
chunkTo = Math.min(commitBoundary, windowTop);
|
|
} else if (frameLength <= this.#committedRows) {
|
|
// The frame shrank into (or below) the committed prefix: the app
|
|
// replaced content it had already let scroll into history without
|
|
// requesting a session replace. History is immutable without a
|
|
// gesture, so the stale committed copy stays in scrollback;
|
|
// re-anchor the window at the tail and restart commit bookkeeping
|
|
// there so the live grid shows the real content instead of a blank
|
|
// pinned window.
|
|
windowTop = Math.max(0, frameLength - height);
|
|
chunkTo = Math.min(commitBoundary, windowTop);
|
|
this.#committedRows = chunkTo;
|
|
this.#committedPrefix = rawFrame.slice(0, chunkTo);
|
|
} else {
|
|
// Re-anchor to the frame tail, floored at the committed boundary: a
|
|
// shrink (or overlay close) pulls the window back down, but never
|
|
// onto rows already in native history — re-showing those on the
|
|
// grid would duplicate them for a scrolling reader. On a
|
|
// multiplexer resize the pane reflowed its own history; committed
|
|
// rows keep their old wrap there, same as any shell output.
|
|
windowTop = Math.max(this.#committedRows, frameLength - height, 0);
|
|
// Overlays freeze commits: composited rows must never enter
|
|
// history, and the hidden gap backfills via the chunk once the
|
|
// overlay closes. A multiplexer resize also commits nothing — the
|
|
// pane keeps its own (old-wrap) history — and re-bases the audit
|
|
// prefix at the new width so the accepted wrap drift does not read
|
|
// as a violation on the next ordinary frame.
|
|
chunkTo =
|
|
hasVisibleOverlay || geometryChanged
|
|
? this.#committedRows
|
|
: Math.max(this.#committedRows, Math.min(commitBoundary, windowTop));
|
|
if (geometryChanged) {
|
|
this.#committedPrefix = rawFrame.slice(0, this.#committedRows);
|
|
}
|
|
}
|
|
|
|
// 5. Pick the visible cursor marker (bottom-most at or below the window
|
|
// top), prepare lines, and build the visible window slice.
|
|
let cursorPos: { row: number; col: number } | null = null;
|
|
for (let i = cursorMarkers.length - 1; i >= 0; i--) {
|
|
const marker = cursorMarkers[i]!;
|
|
if (marker.row >= windowTop) {
|
|
cursorPos = marker;
|
|
break;
|
|
}
|
|
}
|
|
const frame = this.#prepareFrame(rawFrame, width);
|
|
let window: string[] = new Array(height);
|
|
for (let r = 0; r < height; r++) window[r] = frame[windowTop + r] ?? "";
|
|
if (hasVisibleOverlay) {
|
|
window = this.#compositeOverlaysIntoWindow(window, width, height);
|
|
const overlayMarkers = this.#extractCursorMarkers(window);
|
|
if (overlayMarkers.length > 0) {
|
|
cursorPos = { row: windowTop + overlayMarkers[0]!.row, col: overlayMarkers[0]!.col };
|
|
}
|
|
window = this.#prepareLinesArray(window, width);
|
|
}
|
|
|
|
const intent: RenderIntent = fullPaint
|
|
? { kind: "fullPaint", clearScrollback: replaceRequested || geometryRebuild ? !isMultiplexerSession() : false }
|
|
: { kind: "update", chunkTo, windowTop };
|
|
this.#logRedraw(intent, frameLength, height);
|
|
|
|
// Load newly-displayed image data once, before this frame's placements
|
|
// (and any emitter) reference it. `a=t` produces no display, so writing
|
|
// it ahead of the synchronized paint is artifact-free.
|
|
const imageTransmits = this.#imageBudget.takeTransmits();
|
|
if (imageTransmits.length > 0) {
|
|
let transmitBuffer = "";
|
|
for (const seq of imageTransmits) transmitBuffer += seq;
|
|
this.terminal.write(transmitBuffer);
|
|
}
|
|
// Purge graphics for images the budget demoted to text. Kitty keeps
|
|
// images in a store that text clears don't touch; demoted rows still
|
|
// visible re-render as text and the window diff repaints them.
|
|
// Committed placements are immutable — their pixels are deleted but
|
|
// their rows are not rewritten.
|
|
let purgeSequence = "";
|
|
if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
|
|
for (const id of this.#imageBudget.takePurgeIds()) purgeSequence += encodeKittyDeleteImage(id);
|
|
} else {
|
|
this.#imageBudget.takePurgeIds();
|
|
}
|
|
|
|
// 6. Emit.
|
|
if (intent.kind === "fullPaint") {
|
|
this.#emitFullPaint(frame, window, width, height, cursorPos, purgeSequence, {
|
|
clearScrollback: intent.clearScrollback,
|
|
chunkTo,
|
|
windowTop,
|
|
});
|
|
this.#committedPrefix = rawFrame.slice(0, chunkTo);
|
|
this.#clearScrollbackOnNextRender = false;
|
|
this.#hasEverRendered = true;
|
|
if (!firstPaint && frameLength > height) this.#armPostFullPaintSettle();
|
|
return;
|
|
}
|
|
this.#emitUpdate(frame, window, width, height, cursorPos, purgeSequence, {
|
|
chunkTo,
|
|
windowTop,
|
|
prevWindowTop,
|
|
prevHardwareCursorRow,
|
|
forceWindowRewrite: this.#forceViewportRepaintOnNextRender || (geometryChanged && isMultiplexerSession()),
|
|
});
|
|
for (let i = this.#committedPrefix.length; i < chunkTo; i++) {
|
|
this.#committedPrefix.push(rawFrame[i] ?? "");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detect committed-prefix violations and re-anchor the commit index at the
|
|
* first moved row, so subsequent rows recommit instead of being skipped:
|
|
* the stale copy stays in history — duplication, never loss. Pure in-place
|
|
* restyles keep their alignment and are left alone (stale styling in
|
|
* history was always the accepted artifact).
|
|
*/
|
|
#auditCommittedPrefix(rawFrame: readonly string[]): void {
|
|
const prefix = this.#committedPrefix;
|
|
if (prefix.length === 0) return;
|
|
const resyncTo = findCommittedPrefixResync(rawFrame, prefix);
|
|
if (resyncTo < 0) return;
|
|
this.#committedRows = resyncTo;
|
|
prefix.length = resyncTo;
|
|
if ($flag("PI_DEBUG_REDRAW")) {
|
|
const msg = `[${new Date().toISOString()}] commit resync: committed prefix diverged at row ${resyncTo}; recommitting\n`;
|
|
fs.appendFileSync(getDebugLogPath(), msg);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Prepare the composed frame for emission, in place. Rows below
|
|
* `#preparedValidRows` are already prepared against the current frame (the
|
|
* compose lowered that floor to the stable prefix); rows at/after it are
|
|
* revalidated positionally — a row whose raw content and width match its
|
|
* cached entry reuses the prepared line, anything else re-prepares.
|
|
*/
|
|
#prepareFrame(frame: readonly string[], width: number): string[] {
|
|
const prepared = this.#preparedFrame;
|
|
const meta = this.#preparedMeta;
|
|
if (prepared.length > frame.length) {
|
|
prepared.length = frame.length;
|
|
meta.length = frame.length;
|
|
}
|
|
for (let i = Math.min(this.#preparedValidRows, prepared.length); i < frame.length; i++) {
|
|
const raw = frame[i]!;
|
|
const cached = meta[i];
|
|
if (cached !== undefined && cached.raw === raw && cached.width === width) {
|
|
prepared[i] = cached.line;
|
|
continue;
|
|
}
|
|
const entry = this.#prepareLine(raw, width);
|
|
meta[i] = entry;
|
|
prepared[i] = entry.line;
|
|
}
|
|
this.#preparedValidRows = frame.length;
|
|
return prepared;
|
|
}
|
|
|
|
/** Stateless variant for overlay-composited windows and alt-screen frames. */
|
|
#prepareLinesArray(lines: readonly string[], width: number): string[] {
|
|
const prepared: string[] = new Array(lines.length);
|
|
for (let i = 0; i < lines.length; i++) {
|
|
prepared[i] = this.#prepareLine(lines[i]!, width).line;
|
|
}
|
|
return prepared;
|
|
}
|
|
|
|
#prepareLine(raw: string, width: number): PreparedLine {
|
|
if (TERMINAL.isImageLine(raw)) {
|
|
return { raw, width, line: raw };
|
|
}
|
|
const source = this.#lineFitSource(raw, width);
|
|
const normalized = normalizeTerminalOutput(source);
|
|
const asciiWidth = this.#ansiAsciiLineWidth(normalized, width);
|
|
if ((asciiWidth ?? visibleWidth(normalized)) <= width) {
|
|
return { raw, width, line: normalized };
|
|
}
|
|
const line = truncateToWidth(normalized, width, Ellipsis.Omit);
|
|
return { raw, width, line };
|
|
}
|
|
|
|
#lineFitSource(raw: string, width: number): string {
|
|
const safeWidth = Number.isFinite(width) ? Math.max(1, Math.trunc(width)) : 1;
|
|
const maxSourceLength = Math.min(
|
|
LINE_FIT_MAX_SOURCE_CODE_UNITS,
|
|
Math.max(LINE_FIT_MIN_SOURCE_CODE_UNITS, safeWidth * LINE_FIT_SOURCE_WIDTH_MULTIPLIER),
|
|
);
|
|
if (raw.length <= maxSourceLength) return raw;
|
|
|
|
let output = "";
|
|
let cells = 0;
|
|
for (let i = 0; i < raw.length && cells < safeWidth; ) {
|
|
if (raw.charCodeAt(i) === 0x1b) {
|
|
const end = this.#ansiSequenceEnd(raw, i);
|
|
if (end < 0) break;
|
|
if (this.#ansiSequenceHasVisiblePayload(raw, i)) {
|
|
const sequence = raw.slice(i, end);
|
|
if (output.length + sequence.length <= maxSourceLength) {
|
|
output += sequence;
|
|
cells += visibleWidth(sequence);
|
|
}
|
|
}
|
|
i = end;
|
|
continue;
|
|
}
|
|
|
|
const code = raw.charCodeAt(i);
|
|
const next = code >= 0xd800 && code <= 0xdbff && i + 1 < raw.length ? i + 2 : i + 1;
|
|
const char = raw.slice(i, next);
|
|
const charWidth = visibleWidth(char);
|
|
if (charWidth > 0 && cells + charWidth > safeWidth) break;
|
|
if (output.length + char.length > maxSourceLength) {
|
|
if (charWidth > 0) break;
|
|
i = next;
|
|
continue;
|
|
}
|
|
if (charWidth === 0) {
|
|
const remainingVisibleCells = safeWidth - cells;
|
|
const reservedCodeUnits = remainingVisibleCells * 2;
|
|
if (output.length + char.length > maxSourceLength - reservedCodeUnits) {
|
|
i = next;
|
|
continue;
|
|
}
|
|
}
|
|
output += char;
|
|
cells += charWidth;
|
|
i = next;
|
|
}
|
|
|
|
return output + SEGMENT_RESET;
|
|
}
|
|
|
|
#ansiSequenceEnd(line: string, start: number): number {
|
|
const next = line.charCodeAt(start + 1);
|
|
if (next === 0x5b) {
|
|
let i = start + 2;
|
|
while (i < line.length) {
|
|
const final = line.charCodeAt(i);
|
|
if (final >= 0x40 && final <= 0x7e) return i + 1;
|
|
i++;
|
|
}
|
|
return -1;
|
|
}
|
|
if (next === 0x5d) {
|
|
let i = start + 2;
|
|
while (i < line.length) {
|
|
const osc = line.charCodeAt(i);
|
|
if (osc === 0x07) return i + 1;
|
|
if (osc === 0x1b && line.charCodeAt(i + 1) === 0x5c) return i + 2;
|
|
i++;
|
|
}
|
|
return -1;
|
|
}
|
|
return start + 2 <= line.length ? start + 2 : -1;
|
|
}
|
|
|
|
#ansiSequenceHasVisiblePayload(line: string, start: number): boolean {
|
|
// OSC 66 (`\x1b]66;META;TEXT\x1b\\`) carries visible cells inside the payload.
|
|
return (
|
|
line.charCodeAt(start + 1) === 0x5d &&
|
|
line.charCodeAt(start + 2) === 0x36 &&
|
|
line.charCodeAt(start + 3) === 0x36 &&
|
|
line.charCodeAt(start + 4) === 0x3b
|
|
);
|
|
}
|
|
|
|
#ansiAsciiLineWidth(line: string, maxWidth: number): number | undefined {
|
|
let col = 0;
|
|
for (let i = 0; i < line.length; ) {
|
|
const code = line.charCodeAt(i);
|
|
if (code === 0x1b) {
|
|
const next = line.charCodeAt(i + 1);
|
|
if (next === 0x5b) {
|
|
let j = i + 2;
|
|
while (j < line.length) {
|
|
const final = line.charCodeAt(j);
|
|
if (final >= 0x40 && final <= 0x7e) break;
|
|
j++;
|
|
}
|
|
if (j >= line.length) return undefined;
|
|
i = j + 1;
|
|
continue;
|
|
}
|
|
if (next === 0x5d) {
|
|
// OSC 66 text-sizing spans carry visible payload inside the OSC.
|
|
// Fall back to visibleWidth() so scaled cells stay exact.
|
|
if (
|
|
line.charCodeAt(i + 2) === 0x36 &&
|
|
line.charCodeAt(i + 3) === 0x36 &&
|
|
line.charCodeAt(i + 4) === 0x3b
|
|
) {
|
|
return undefined;
|
|
}
|
|
let j = i + 2;
|
|
while (j < line.length) {
|
|
const osc = line.charCodeAt(j);
|
|
if (osc === 0x07) {
|
|
i = j + 1;
|
|
break;
|
|
}
|
|
if (osc === 0x1b && line.charCodeAt(j + 1) === 0x5c) {
|
|
i = j + 2;
|
|
break;
|
|
}
|
|
j++;
|
|
}
|
|
if (j >= line.length) return undefined;
|
|
continue;
|
|
}
|
|
return undefined;
|
|
}
|
|
if (code < 0x20 || code > 0x7e) return undefined;
|
|
col++;
|
|
if (col > maxWidth) return col;
|
|
i++;
|
|
}
|
|
return col;
|
|
}
|
|
|
|
#lineRewriteSequence(line: string, width: number): string {
|
|
if (TERMINAL.isImageLine(line)) return ERASE_LINE + line;
|
|
const terminalLine = this.#terminalLine(line);
|
|
const asciiWidth = this.#ansiAsciiLineWidth(line, width);
|
|
if (asciiWidth !== undefined) {
|
|
// Exact width model: skip the erase only when the row truly fills
|
|
// the line (an EL there would eat the last cell via pending-wrap).
|
|
return asciiWidth >= width ? terminalLine : terminalLine + ERASE_TO_END_OF_LINE;
|
|
}
|
|
// Non-ASCII rows: the native measure can over-count combining-heavy
|
|
// scripts, so a row it calls "full" may render short and leave stale
|
|
// cells from the previous occupant — which would then scroll into
|
|
// history baked into the committed row. Erase the line first instead
|
|
// (rewrites always start at column 1, so EL-to-end clears the whole
|
|
// row); the leading reset keeps BCE on the default background.
|
|
return SEGMENT_RESET + ERASE_TO_END_OF_LINE + terminalLine;
|
|
}
|
|
|
|
/**
|
|
* Single state-transition point. Every emitter calls this exactly once at
|
|
* the end so cursor/window accounting stays consistent.
|
|
*/
|
|
#commit(
|
|
lines: readonly string[],
|
|
window: string[],
|
|
width: number,
|
|
height: number,
|
|
hardwareCursor: HardwareCursorUpdate,
|
|
): void {
|
|
this.#previousFrameLength = lines.length;
|
|
this.#previousWindow = window;
|
|
this.#forceViewportRepaintOnNextRender = false;
|
|
this.#previousWidth = width;
|
|
this.#previousHeight = height;
|
|
this.#recordHardwareCursorUpdate(hardwareCursor);
|
|
}
|
|
|
|
#targetHardwareCursorState(
|
|
cursorPos: { row: number; col: number } | null,
|
|
totalLines: number,
|
|
): HardwareCursorState | null {
|
|
if (!cursorPos || totalLines <= 0) return null;
|
|
return {
|
|
row: Math.max(0, Math.min(cursorPos.row, totalLines - 1)),
|
|
col: Math.max(0, cursorPos.col),
|
|
visible: this.#showHardwareCursor,
|
|
};
|
|
}
|
|
|
|
#recordHardwareCursorState(state: HardwareCursorState): void {
|
|
this.#hardwareCursorRow = state.row;
|
|
this.#hardwareCursorState = state;
|
|
this.#hardwareCursorVisible = state.visible;
|
|
this.#hardwareCursorVisibilityKnown = true;
|
|
}
|
|
|
|
#recordHardwareCursorRowOnly(row: number, visible?: boolean): void {
|
|
this.#hardwareCursorRow = row;
|
|
this.#hardwareCursorState = null;
|
|
if (visible !== undefined) {
|
|
this.#hardwareCursorVisible = visible;
|
|
this.#hardwareCursorVisibilityKnown = true;
|
|
}
|
|
}
|
|
|
|
#recordHardwareCursorUpdate(update: HardwareCursorUpdate): void {
|
|
if (update.state) {
|
|
this.#recordHardwareCursorState(update.state);
|
|
return;
|
|
}
|
|
this.#recordHardwareCursorRowOnly(update.toRow, update.visible);
|
|
}
|
|
|
|
#recordHardwareCursorHidden(): void {
|
|
this.#hardwareCursorVisible = false;
|
|
this.#hardwareCursorVisibilityKnown = true;
|
|
if (!this.#hardwareCursorState) return;
|
|
this.#hardwareCursorState = { ...this.#hardwareCursorState, visible: false };
|
|
}
|
|
|
|
#forgetHardwareCursorState(): void {
|
|
this.#hardwareCursorState = null;
|
|
this.#hardwareCursorVisibilityKnown = false;
|
|
}
|
|
|
|
#sameHardwareCursorState(state: HardwareCursorState): boolean {
|
|
const current = this.#hardwareCursorState;
|
|
return (
|
|
current !== null && current.row === state.row && current.col === state.col && current.visible === state.visible
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Clear the viewport (optionally native scrollback) and replay the frame:
|
|
* committed prefix `[0, chunkTo)` followed by the visible window. ED3
|
|
* (`CSI 3 J`) is emitted here and only here, and only for gesture-driven
|
|
* paints (session replace, resize, resetDisplay, or an explicit
|
|
* `clearScrollback` initial paint).
|
|
*/
|
|
#emitFullPaint(
|
|
frame: readonly string[],
|
|
window: string[],
|
|
width: number,
|
|
height: number,
|
|
cursorPos: { row: number; col: number } | null,
|
|
purgeSequence: string,
|
|
options: { clearScrollback: boolean; chunkTo: number; windowTop: number },
|
|
): void {
|
|
this.#fullRedrawCount += 1;
|
|
const { chunkTo, windowTop } = options;
|
|
let buffer = this.#paintBeginSequence + purgeSequence;
|
|
if (options.clearScrollback) {
|
|
buffer += "\x1b[2J\x1b[H\x1b[3J";
|
|
} else {
|
|
// Best-effort: push the pre-paint screen into scrollback on
|
|
// terminals that implement kitty's ED 22
|
|
// (copy-screen-to-scrollback-then-erase). Always follow with ED 2 so
|
|
// the viewport is cleared regardless; on real kitty, ED 2 over the
|
|
// now-blank screen is a no-op and does not push a second copy.
|
|
if (TERMINAL.supportsScreenToScrollback) buffer += "\x1b[22J";
|
|
buffer += "\x1b[2J\x1b[H";
|
|
}
|
|
// DECCARA fills optimize only the rows that stay visible; history-bound
|
|
// rows are written as full styled strings (their background must
|
|
// survive in scrollback, which DECCARA cannot reach).
|
|
const { texts, sequence } = this.#deccaraFillsEnabled()
|
|
? planDeccaraFills(window, width)
|
|
: { texts: window, sequence: "" };
|
|
let wroteLine = false;
|
|
for (let i = 0; i < chunkTo; i++) {
|
|
if (wroteLine) buffer += "\r\n";
|
|
buffer += this.#terminalLine(frame[i] ?? "");
|
|
wroteLine = true;
|
|
}
|
|
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
if (wroteLine) buffer += "\r\n";
|
|
buffer += this.#terminalLine(texts[screenRow] ?? "");
|
|
wroteLine = true;
|
|
}
|
|
buffer += sequence;
|
|
// Park the hardware cursor at real content bottom, not the padded
|
|
// window bottom — a later height shrink would otherwise scroll live
|
|
// rows into scrollback and duplicate them per resize step.
|
|
const contentRows = Math.max(1, Math.min(height, frame.length - windowTop));
|
|
const parkUp = height - contentRows;
|
|
if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
|
|
const contentBottomRow = windowTop + contentRows - 1;
|
|
const cursorControl = this.#cursorControlSequence(cursorPos, frame.length, contentBottomRow);
|
|
buffer += cursorControl.seq;
|
|
buffer += this.#paintEndSequence;
|
|
this.terminal.write(buffer);
|
|
|
|
this.#committedRows = chunkTo;
|
|
this.#windowTopRow = windowTop;
|
|
this.#commit(frame, window, width, height, cursorControl);
|
|
}
|
|
|
|
/** Topmost visible overlay requests the alternate-screen buffer. */
|
|
#wantsAltScreen(): boolean {
|
|
for (let i = this.overlayStack.length - 1; i >= 0; i--) {
|
|
const entry = this.overlayStack[i]!;
|
|
if (!this.#isOverlayVisible(entry)) continue;
|
|
return entry.options?.fullscreen === true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Compose and paint a single fullscreen overlay frame on the alt buffer.
|
|
* Cursor markers are stripped (the modal draws its own in-band caret and
|
|
* keeps the hardware cursor hidden), and only the modal is composited over a
|
|
* blank base — the transcript is never touched while the alt buffer is up.
|
|
*/
|
|
#renderAltFrame(width: number, height: number): void {
|
|
const base: string[] = new Array(Math.max(0, height)).fill("");
|
|
let lines = this.#compositeOverlaysIntoWindow(base, width, height);
|
|
this.#extractCursorMarkers(lines);
|
|
lines = this.#prepareLinesArray(lines, width);
|
|
this.#emitAltFrame(lines, width, height);
|
|
}
|
|
|
|
/**
|
|
* Full per-row viewport rewrite on the alt buffer. Emits only sync-output
|
|
* brackets, a cursor home, and per-row rewrites — never ED3, append-tail, or
|
|
* any native-scrollback byte, so it is fully isolated from the planner and
|
|
* #commit. The hardware cursor stays hidden (it is never re-shown here).
|
|
*/
|
|
#emitAltFrame(lines: string[], width: number, height: number): void {
|
|
const fitted: string[] = new Array(height);
|
|
for (let r = 0; r < height; r++) fitted[r] = lines[r] ?? "";
|
|
// Skip an identical repaint (the modal is mostly static between
|
|
// keystrokes) — unless a forced repaint (resetDisplay,
|
|
// requestRender(true)) is pending: the redraw gesture must repair a
|
|
// corrupted modal even when our cached frame is byte-identical.
|
|
const force = this.#forceViewportRepaintOnNextRender;
|
|
this.#forceViewportRepaintOnNextRender = false;
|
|
if (!force && this.#altPreviousLines.length === height) {
|
|
let same = true;
|
|
for (let r = 0; r < height; r++) {
|
|
if (fitted[r] !== this.#altPreviousLines[r]) {
|
|
same = false;
|
|
break;
|
|
}
|
|
}
|
|
if (same) return;
|
|
}
|
|
let buffer = `${this.#paintBeginSequence}\x1b[H`;
|
|
for (let r = 0; r < height; r++) {
|
|
if (r > 0) buffer += "\r\n";
|
|
buffer += this.#lineRewriteSequence(fitted[r], width);
|
|
}
|
|
buffer += this.#paintEndSequence;
|
|
this.terminal.write(buffer);
|
|
this.#altPreviousLines = fitted;
|
|
this.#fullRedrawCount += 1;
|
|
}
|
|
|
|
/**
|
|
* Incremental frame update. Three byte shapes:
|
|
*
|
|
* - scroll-append: the rows leaving the screen are exactly the newly
|
|
* committed chunk, already painted with final content — emit `\r\n` plus
|
|
* the new bottom rows, then rewrite whatever else changed in place;
|
|
* - in-window diff: nothing scrolls, nothing commits — rewrite the changed
|
|
* row range (cursor-only when nothing changed);
|
|
* - seam rewrite: write the chunk at the scrollback seam, then rewrite the
|
|
* whole window (live-region re-layout, hidden-gap backfill, mux resize).
|
|
*
|
|
* Only chunk rows ever enter native history; the live window repaints in
|
|
* place with relative moves. This path never emits ED2/ED3 or an absolute
|
|
* cursor home — those snap a reader scrolled into history back to the
|
|
* bottom on several terminal families.
|
|
*/
|
|
#emitUpdate(
|
|
frame: readonly string[],
|
|
window: string[],
|
|
width: number,
|
|
height: number,
|
|
cursorPos: { row: number; col: number } | null,
|
|
purgeSequence: string,
|
|
options: {
|
|
chunkTo: number;
|
|
windowTop: number;
|
|
prevWindowTop: number;
|
|
prevHardwareCursorRow: number;
|
|
forceWindowRewrite: boolean;
|
|
},
|
|
): void {
|
|
const { chunkTo, windowTop, prevWindowTop, prevHardwareCursorRow, forceWindowRewrite } = options;
|
|
const chunkFrom = this.#committedRows;
|
|
const chunkLength = chunkTo - chunkFrom;
|
|
const scroll = windowTop - prevWindowTop;
|
|
const previousWindow = this.#previousWindow;
|
|
const contentRows = Math.max(1, Math.min(height, frame.length - windowTop));
|
|
const contentBottomRow = windowTop + contentRows - 1;
|
|
// Terminals clamp the hardware cursor to the viewport on resize; clamp
|
|
// our tracking to match so relative moves land correctly.
|
|
const clampedCursor = Math.min(prevHardwareCursorRow, prevWindowTop + height - 1);
|
|
const currentScreenRow = Math.max(0, Math.min(height - 1, clampedCursor - prevWindowTop));
|
|
|
|
// Scroll-append: committing exactly the rows that scroll off the top,
|
|
// with content untouched since they were painted.
|
|
if (
|
|
!forceWindowRewrite &&
|
|
chunkLength > 0 &&
|
|
chunkLength === scroll &&
|
|
scroll < height &&
|
|
chunkFrom === prevWindowTop
|
|
) {
|
|
let prefixIntact = previousWindow.length === height;
|
|
for (let i = 0; prefixIntact && i < chunkLength; i++) {
|
|
if (previousWindow[i] !== frame[chunkFrom + i]) prefixIntact = false;
|
|
}
|
|
if (prefixIntact) {
|
|
let buffer = this.#paintBeginSequence + purgeSequence;
|
|
const moveToBottom = height - 1 - currentScreenRow;
|
|
if (moveToBottom > 0) buffer += `\x1b[${moveToBottom}B`;
|
|
for (let r = height - scroll; r < height; r++) {
|
|
buffer += `\r\n${this.#lineRewriteSequence(window[r] ?? "", width)}`;
|
|
}
|
|
// Rewrite any remaining changed rows after the shift.
|
|
let firstChanged = -1;
|
|
let lastChanged = -1;
|
|
for (let r = 0; r < height - scroll; r++) {
|
|
if ((window[r] ?? "") === (previousWindow[r + scroll] ?? "")) continue;
|
|
if (firstChanged === -1) firstChanged = r;
|
|
lastChanged = r;
|
|
}
|
|
let cursorFromRow = windowTop + height - 1;
|
|
if (firstChanged !== -1) {
|
|
const up = height - 1 - firstChanged;
|
|
if (up > 0) buffer += `\x1b[${up}A`;
|
|
buffer += "\r";
|
|
for (let r = firstChanged; r <= lastChanged; r++) {
|
|
if (r > firstChanged) buffer += "\r\n";
|
|
buffer += this.#lineRewriteSequence(window[r] ?? "", width);
|
|
}
|
|
cursorFromRow = windowTop + lastChanged;
|
|
}
|
|
const cursorControl = this.#cursorControlSequence(cursorPos, frame.length, cursorFromRow);
|
|
buffer += cursorControl.seq;
|
|
buffer += this.#paintEndSequence;
|
|
this.terminal.write(buffer);
|
|
this.#committedRows = chunkTo;
|
|
this.#windowTopRow = windowTop;
|
|
this.#commit(frame, window, width, height, cursorControl);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// In-window diff: nothing scrolls, nothing commits.
|
|
if (chunkLength === 0 && scroll === 0) {
|
|
if (forceWindowRewrite) this.#fullRedrawCount += 1;
|
|
let firstChanged = forceWindowRewrite ? 0 : -1;
|
|
let lastChanged = forceWindowRewrite ? height - 1 : -1;
|
|
if (!forceWindowRewrite) {
|
|
const comparable = previousWindow.length === height;
|
|
for (let r = 0; r < height; r++) {
|
|
if (comparable && (window[r] ?? "") === (previousWindow[r] ?? "")) continue;
|
|
if (firstChanged === -1) firstChanged = r;
|
|
lastChanged = r;
|
|
}
|
|
}
|
|
if (firstChanged === -1) {
|
|
if (purgeSequence.length > 0) this.terminal.write(purgeSequence);
|
|
this.#writeCursorPosition(cursorPos, frame.length);
|
|
this.#previousWidth = width;
|
|
this.#previousHeight = height;
|
|
return;
|
|
}
|
|
let buffer = this.#paintBeginSequence + purgeSequence;
|
|
const rowDelta = firstChanged - currentScreenRow;
|
|
if (rowDelta > 0) buffer += `\x1b[${rowDelta}B`;
|
|
else if (rowDelta < 0) buffer += `\x1b[${-rowDelta}A`;
|
|
buffer += "\r";
|
|
// DECCARA-optimize the contiguous rewritten range (visible rows
|
|
// only; rectangles are absolute screen rows).
|
|
let fillTexts: string[] | null = null;
|
|
let fillSequence = "";
|
|
if (this.#deccaraFillsEnabled()) {
|
|
const slice: string[] = new Array(lastChanged - firstChanged + 1);
|
|
for (let r = firstChanged; r <= lastChanged; r++) slice[r - firstChanged] = window[r] ?? "";
|
|
const plan = planDeccaraFills(slice, width, firstChanged);
|
|
fillTexts = plan.texts;
|
|
fillSequence = plan.sequence;
|
|
}
|
|
for (let r = firstChanged; r <= lastChanged; r++) {
|
|
if (r > firstChanged) buffer += "\r\n";
|
|
buffer += this.#lineRewriteSequence(fillTexts ? fillTexts[r - firstChanged] : (window[r] ?? ""), width);
|
|
}
|
|
buffer += fillSequence;
|
|
// Never park below real content (a height shrink would scroll live
|
|
// rows into history and duplicate them per resize step).
|
|
let cursorFromRow = windowTop + lastChanged;
|
|
const contentBottomScreenRow = contentBottomRow - windowTop;
|
|
if (lastChanged > contentBottomScreenRow) {
|
|
buffer += `\x1b[${lastChanged - contentBottomScreenRow}A`;
|
|
cursorFromRow = contentBottomRow;
|
|
}
|
|
const cursorControl = this.#cursorControlSequence(cursorPos, frame.length, cursorFromRow);
|
|
buffer += cursorControl.seq;
|
|
buffer += this.#paintEndSequence;
|
|
this.terminal.write(buffer);
|
|
this.#commit(frame, window, width, height, cursorControl);
|
|
return;
|
|
}
|
|
|
|
// Seam rewrite: write the chunk into history, then the whole window.
|
|
// Cursor moves to the window top with a relative move; the chunk rows
|
|
// pass through the screen and scroll off as the window rows are written
|
|
// below them, so the rows entering scrollback are exactly the chunk.
|
|
this.#fullRedrawCount += 1;
|
|
let buffer = this.#paintBeginSequence + purgeSequence;
|
|
if (currentScreenRow > 0) buffer += `\x1b[${currentScreenRow}A`;
|
|
buffer += "\r";
|
|
let wroteLine = false;
|
|
for (let i = chunkFrom; i < chunkTo; i++) {
|
|
if (wroteLine) buffer += "\r\n";
|
|
buffer += this.#lineRewriteSequence(frame[i] ?? "", width);
|
|
wroteLine = true;
|
|
}
|
|
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
if (wroteLine) buffer += "\r\n";
|
|
buffer += this.#lineRewriteSequence(window[screenRow] ?? "", width);
|
|
wroteLine = true;
|
|
}
|
|
const parkUp = height - 1 - (contentBottomRow - windowTop);
|
|
if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
|
|
const cursorControl = this.#cursorControlSequence(cursorPos, frame.length, contentBottomRow);
|
|
buffer += cursorControl.seq;
|
|
buffer += this.#paintEndSequence;
|
|
this.terminal.write(buffer);
|
|
this.#committedRows = chunkTo;
|
|
this.#windowTopRow = windowTop;
|
|
this.#commit(frame, window, width, height, cursorControl);
|
|
}
|
|
|
|
/** Optional intent log under PI_DEBUG_REDRAW. */
|
|
#logRedraw(intent: RenderIntent, newLength: number, height: number): void {
|
|
if (!$flag("PI_DEBUG_REDRAW")) return;
|
|
const detail =
|
|
intent.kind === "update"
|
|
? `update(chunk=${this.#committedRows}..${intent.chunkTo}, windowTop=${intent.windowTop})`
|
|
: `fullPaint(clearScrollback=${intent.clearScrollback})`;
|
|
const state =
|
|
`committed=${this.#committedRows}, windowTop=${this.#windowTopRow}, ` +
|
|
`lrStart=${this.#nativeScrollbackLiveRegionStart}, commitSafeEnd=${this.#nativeScrollbackCommitSafeEnd}`;
|
|
const msg = `[${new Date().toISOString()}] render: ${detail} (prev=${this.#previousFrameLength}, new=${newLength}, height=${height}, ${state})\n`;
|
|
fs.appendFileSync(getDebugLogPath(), msg);
|
|
}
|
|
|
|
/**
|
|
* Build cursor control sequences to position the hardware cursor for the IME
|
|
* candidate window. Returns escape sequences and the resulting cursor row for
|
|
* the caller to update `#hardwareCursorRow`. The sequences should be appended
|
|
* into the caller's own synchronized output block to avoid a flicker between
|
|
* content and cursor frames.
|
|
*/
|
|
#cursorControlSequence(
|
|
cursorPos: { row: number; col: number } | null,
|
|
totalLines: number,
|
|
fromRow: number,
|
|
): CursorControlResult {
|
|
// No IME target or no content — hide cursor regardless of preference.
|
|
const target = this.#targetHardwareCursorState(cursorPos, totalLines);
|
|
if (!target) {
|
|
return { seq: "\x1b[?25l", toRow: fromRow, toCol: 0, visible: false, state: null };
|
|
}
|
|
|
|
// Move cursor from current position to target.
|
|
const rowDelta = target.row - fromRow;
|
|
let seq = "";
|
|
if (rowDelta > 0) {
|
|
seq += `\x1b[${rowDelta}B`; // Move down
|
|
} else if (rowDelta < 0) {
|
|
seq += `\x1b[${-rowDelta}A`; // Move up
|
|
}
|
|
// Move to absolute column (1-indexed)
|
|
seq += `\x1b[${target.col + 1}G`;
|
|
seq += target.visible ? "\x1b[?25h" : "\x1b[?25l";
|
|
|
|
return { seq, toRow: target.row, toCol: target.col, visible: target.visible, state: target };
|
|
}
|
|
|
|
#isHiddenCursorKnown(): boolean {
|
|
return this.#hardwareCursorVisibilityKnown && !this.#hardwareCursorVisible;
|
|
}
|
|
|
|
/**
|
|
* Write the hardware cursor position to the terminal as a standalone
|
|
* synchronized output block. Use when there is no surrounding render buffer
|
|
* to embed the sequences into.
|
|
*/
|
|
#writeCursorPosition(cursorPos: { row: number; col: number } | null, totalLines: number): void {
|
|
const target = this.#targetHardwareCursorState(cursorPos, totalLines);
|
|
if (!target) {
|
|
if (this.#isHiddenCursorKnown()) return;
|
|
this.terminal.hideCursor();
|
|
this.#recordHardwareCursorHidden();
|
|
return;
|
|
}
|
|
if (this.#sameHardwareCursorState(target)) return;
|
|
const cursorControl = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow);
|
|
this.terminal.write(`${this.#cursorBeginSequence}${cursorControl.seq}${this.#cursorEndSequence}`);
|
|
this.#recordHardwareCursorUpdate(cursorControl);
|
|
}
|
|
}
|