feat(tui): added viewport-only transcript tail rendering and deferred resize repaint

- Added tail-only transcript rendering via `renderViewportTail`, stopping at `maxRows` and returning `EMPTY_TAIL`.
- Added `ViewportTailProvider`/`asViewportTailProvider` API and resize getters for viewport state.
- Implemented viewport-only painting during non-mux resize with deferred full repaint after settle.
- Added settle-resize helpers and coverage for deferred repaint timing and cancel paths.
This commit is contained in:
can1357
2026-06-13 21:27:36 +02:00
parent f9a8aa1d96
commit 2bbf995a1d
11 changed files with 674 additions and 28 deletions
@@ -4,6 +4,7 @@ import {
type NativeScrollbackCommittedRows,
type NativeScrollbackLiveRegion,
type RenderStablePrefix,
type ViewportTailProvider,
} from "@oh-my-pi/pi-tui";
const kSnapshot = Symbol("transcript.liveDiffSnapshot");
@@ -139,6 +140,8 @@ interface BlockSegment {
}
const EMPTY_SEGMENTS: BlockSegment[] = [];
/** Shared empty result for an empty viewport-tail render (no allocation). */
const EMPTY_TAIL: readonly string[] = [];
interface LiveCommitState {
appendOnly: boolean;
@@ -415,7 +418,7 @@ function deriveLiveCommitState(
*/
export class TranscriptContainer
extends Container
implements NativeScrollbackLiveRegion, NativeScrollbackCommittedRows, RenderStablePrefix
implements NativeScrollbackLiveRegion, NativeScrollbackCommittedRows, RenderStablePrefix, ViewportTailProvider
{
// Bumped to retire every block's diff snapshot at once (theme change /
// clear); a snapshot is only honored when its stored generation matches.
@@ -520,6 +523,49 @@ export class TranscriptContainer
return index === children.length - 1;
}
/**
* Render only the bottom `maxRows` rows of the transcript at `width`, walking
* blocks from the last toward the first and stopping the instant enough rows
* are collected — blocks above the fold are never rendered. The engine's
* resize viewport fast path uses this so a drag (a SIGWINCH burst, each event
* a fresh width that misses every per-width cache) re-lays-out only the
* handful of visible blocks instead of the whole history every event.
*
* State-isolated by contract: touches none of the persistent full-compose
* fields (#lines, #segments, the per-block diff snapshots, the commit/stable
* bookkeeping), so the authoritative full render on settle reconciles exactly
* as if this never ran. Calling each block's render() still warms its own
* per-width cache, which that settle render then reuses for free.
*
* Consecutive visible blocks are joined by exactly one blank separator, the
* same rule render() applies, so the result equals the bottom of a full
* render except for an at-most-one-row separator on the topmost included
* block — a transient discrepancy the settle paint overwrites.
*/
renderViewportTail(width: number, maxRows: number): readonly string[] {
width = Math.max(1, width);
if (maxRows <= 0) return EMPTY_TAIL;
const collected: (readonly string[])[] = [];
let total = 0;
for (let i = this.children.length - 1; i >= 0 && total < maxRows; i--) {
const contribution = stripPlainBlankEdges(this.children[i]!.render(width));
if (contribution.length === 0) continue;
// One blank separator sits between this block and the (already
// collected) visible block below it.
if (collected.length > 0) total += 1;
collected.push(contribution);
total += contribution.length;
}
if (collected.length === 0) return EMPTY_TAIL;
const rows: string[] = [];
for (let k = collected.length - 1; k >= 0; k--) {
if (rows.length > 0) rows.push("");
const body = collected[k]!;
for (let j = 0; j < body.length; j++) rows.push(body[j]!);
}
return rows.length > maxRows ? rows.slice(rows.length - maxRows) : rows;
}
override render(width: number): readonly string[] {
width = Math.max(1, width);
this.#nativeScrollbackLiveRegionStart = undefined;