feat(tui): added component-scoped rendering with partial compose reuse
- Added `TUI.requestComponentRender`, enabling component-scoped render requests. - Implemented partial compose reuse by resolving and memoizing affected root segments. - Fixed component-scoped updates to keep frame-seam rows continuous across partial renders. - Added component-render and loader tests for reuse, downgrade, and call-count coverage.
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
### Fixed
|
||||
|
||||
- Preserved Anthropic `stop_details` on assistant messages so refusal and sensitive classifier stops remain structurally visible to callers. ([#2290](https://github.com/can1357/oh-my-pi/issues/2290))
|
||||
- Fixed OpenAI Responses, Azure OpenAI Responses, and OpenAI Completions streams hanging until the 120s idle watchdog errored the turn when a provider delivers the terminal frame but never sends `[DONE]` nor closes the connection. `processResponsesStream` now breaks out of the event loop on `response.completed`/`response.incomplete` (mirroring the Codex websocket/SSE terminal break), and the completions consumer breaks once `finish_reason` plus a usage payload arrived — or, for hosts that never send usage, ends the stream cleanly via a short post-finish grace window (`iterateWithTerminalGrace`) that aborts the transport to release the socket.
|
||||
|
||||
## [15.11.0] - 2026-06-10
|
||||
|
||||
|
||||
@@ -111,10 +111,11 @@ export async function renderGalleryState(
|
||||
|
||||
const tool = fakeToolFor(name, fixture);
|
||||
const streamingArgs = state === "streaming" ? (fixture.streamingArgs ?? fixture.args) : fixture.args;
|
||||
// The component only calls `requestRender` during a static render;
|
||||
// `imageBudget` is consulted solely when images render, which the gallery
|
||||
// disables. A cast avoids constructing a real terminal.
|
||||
const ui = { requestRender() {} } as unknown as TUI;
|
||||
// The component only calls `requestRender`/`requestComponentRender` (via
|
||||
// its loader) during a static render; `imageBudget` is consulted solely
|
||||
// when images render, which the gallery disables. A cast avoids
|
||||
// constructing a real terminal.
|
||||
const ui = { requestRender() {}, requestComponentRender() {} } as unknown as TUI;
|
||||
const component = new ToolExecutionComponent(name, streamingArgs, { showImages: false }, tool, ui, getProjectDir());
|
||||
component.setExpanded(expanded);
|
||||
|
||||
|
||||
@@ -73,7 +73,11 @@ export class BtwPanelComponent extends Container {
|
||||
this.addChild(new Text(this.#footerLine(), 1, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new DynamicBorder(str => theme.fg("dim", str)));
|
||||
this.#tui.requestRender();
|
||||
// Component-scoped: a rebuild replaces only this panel's own children
|
||||
// (streaming deltas arrive per token, and a full compose would re-walk
|
||||
// the whole transcript each time). Before the panel is mounted the TUI
|
||||
// cannot resolve it and falls back to a full compose on its own.
|
||||
this.#tui.requestComponentRender(this);
|
||||
}
|
||||
|
||||
#footerLine(): string {
|
||||
|
||||
@@ -496,8 +496,12 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
}
|
||||
|
||||
playWelcomeIntro(): void {
|
||||
this.#welcomeComponent?.playIntro(() => this.ui.requestRender());
|
||||
const welcome = this.#welcomeComponent;
|
||||
// Component-scoped: the intro only mutates the welcome box's own rows,
|
||||
// so a resumed long transcript is not re-walked per animation frame.
|
||||
welcome?.playIntro(() => this.ui.requestComponentRender(welcome));
|
||||
}
|
||||
|
||||
async init(options: InteractiveModeInitOptions = {}): Promise<void> {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
@@ -3037,7 +3041,9 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
this.#voiceAnimationInterval = setInterval(() => {
|
||||
this.#voiceHue = (this.#voiceHue + 8) % 360;
|
||||
this.#updateMicIcon();
|
||||
this.ui.requestRender();
|
||||
// Component-scoped: the hue sweep only recolors the editor's cursor
|
||||
// glyph, so the transcript subtree is reused per animation frame.
|
||||
this.ui.requestComponentRender(this.editor);
|
||||
}, 60);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
|
||||
- Added `TUI.requestComponentRender(component)` to schedule component-scoped renders for self-contained updates
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the render pipeline to reuse only affected root subtrees for component-scoped updates, avoiding full-tree compose when animations or other isolated component changes occur
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed component-scoped renders to preserve prior live scrollback seam data for skipped root children, preventing duplicate or missing rows during spinner-only updates
|
||||
- Reported committed native scrollback row counts to interested child components so immutable history can be skipped without breaking live-region commit bookkeeping.
|
||||
- Fixed `ProcessTerminal` treating asynchronous stdout `EIO` errors as uncaught exceptions: stdout `error` events now mark the terminal dead, disable future renders, and keep the active session process alive ([#2284](https://github.com/can1357/oh-my-pi/issues/2284)).
|
||||
|
||||
|
||||
@@ -196,6 +196,21 @@ export class ImageBudget {
|
||||
return this.#pendingTransmits.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the budget has nothing in flight: no live images observed on
|
||||
* the last pass, no queued transmits, no pending purges, and no stricter
|
||||
* threshold left to apply. A component-scoped frame may skip the observe
|
||||
* pass only then — a partial tree walk would under-count display order.
|
||||
*/
|
||||
get quiescent(): boolean {
|
||||
return (
|
||||
this.#lastTotal === 0 &&
|
||||
this.#pendingTransmits.length === 0 &&
|
||||
this.#purgeIds.length === 0 &&
|
||||
this.#planned === this.#onTerminal
|
||||
);
|
||||
}
|
||||
|
||||
/** Transmit sequences to write before this frame's placements; clears the queue. */
|
||||
takeTransmits(): readonly string[] {
|
||||
if (this.#pendingTransmits.length === 0) return EMPTY_TRANSMITS;
|
||||
|
||||
@@ -90,7 +90,11 @@ export class Loader extends Text {
|
||||
const frame = this.#frames[this.#currentFrame];
|
||||
const text = `${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}`;
|
||||
if (this.setText(text) && this.#ui) {
|
||||
this.#ui.requestRender();
|
||||
// Component-scoped: a spinner tick changes only this component, so
|
||||
// the TUI may reuse every other root subtree instead of re-walking
|
||||
// the whole tree (full repaints at 12.5 Hz made huge transcripts
|
||||
// lag as soon as the loader appeared).
|
||||
this.#ui.requestComponentRender(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+193
-28
@@ -498,14 +498,29 @@ interface CursorControlResult extends HardwareCursorUpdate {
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* render() returned, the frame row it starts at, the row count recorded at
|
||||
* compose time (in-place mutators keep the reference but may change length),
|
||||
* and the child-local seam reports captured at render time — replayed verbatim
|
||||
* when a component-scoped frame reuses this segment without re-rendering.
|
||||
*/
|
||||
interface FrameSegment {
|
||||
component: Component;
|
||||
lines: readonly string[];
|
||||
start: number;
|
||||
rowCount: number;
|
||||
liveLocalStart?: number;
|
||||
commitLocalEnd?: number;
|
||||
}
|
||||
|
||||
/** Depth-first identity search through `Container`-shaped children. */
|
||||
function subtreeContains(root: Component, target: Component): boolean {
|
||||
if (root === target) return true;
|
||||
const children = (root as Partial<Container>).children;
|
||||
if (!Array.isArray(children)) return false;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
if (subtreeContains(children[i]!, target)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface PreparedLine {
|
||||
@@ -719,6 +734,21 @@ export class TUI extends Container {
|
||||
// Leading rows of #composedFrame byte-identical to the previous compose.
|
||||
#renderStablePrefixRows = 0;
|
||||
|
||||
// Component-scoped render accumulation. Targets are the components handed
|
||||
// to requestComponentRender() since the last frame; the flag stays true
|
||||
// only while EVERY pending request is component-scoped. Both are consumed
|
||||
// once per frame by #doRender.
|
||||
#componentRenderTargets = new Set<Component>();
|
||||
#pendingRenderComponentsOnly = false;
|
||||
// Root children that must re-render during the current compose; null for a
|
||||
// full compose. Non-null only for the duration of a component-scoped
|
||||
// render() call inside #doRender (the scratch set below, reused per frame).
|
||||
#partialComposeRoots: Set<Component> | null = null;
|
||||
#partialComposeRootsScratch = new Set<Component>();
|
||||
// Target component -> containing root child, so animation-rate requests do
|
||||
// not re-walk a huge transcript subtree every frame.
|
||||
#componentRootCache = new WeakMap<Component, Component>();
|
||||
|
||||
// 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
|
||||
@@ -758,31 +788,53 @@ export class TUI extends Container {
|
||||
this.#composeWidth = width;
|
||||
let offset = 0;
|
||||
let stableRows = 0;
|
||||
const partialRoots = this.#partialComposeRoots;
|
||||
for (let index = 0; index < children.length; index++) {
|
||||
const child = children[index]!;
|
||||
setNativeScrollbackCommittedRows(child, Math.max(0, this.#committedRows - offset));
|
||||
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)))
|
||||
const previous = previousSegments[index];
|
||||
// Component-scoped frame: a root child outside every requested
|
||||
// subtree provably did not change (content mutations route through
|
||||
// a render request, which would have made this frame a full one) —
|
||||
// reuse its previous rows and seam report without calling render().
|
||||
const reuse =
|
||||
partialRoots !== null && previous !== undefined && previous.component === child && !partialRoots.has(child);
|
||||
let childLines: readonly string[];
|
||||
let liveLocalStart: number | undefined;
|
||||
let commitLocalEnd: number | undefined;
|
||||
let reported: number | undefined;
|
||||
if (reuse) {
|
||||
childLines = previous.lines;
|
||||
liveLocalStart = previous.liveLocalStart;
|
||||
commitLocalEnd = previous.commitLocalEnd;
|
||||
} else {
|
||||
childLines = child.render(width);
|
||||
const liveRegionStart = getNativeScrollbackLiveRegionStart(child);
|
||||
if (liveRegionStart !== undefined) {
|
||||
liveLocalStart = Number.isFinite(liveRegionStart)
|
||||
? Math.max(0, Math.min(childLines.length, Math.trunc(liveRegionStart)))
|
||||
: childLines.length;
|
||||
this.#nativeScrollbackCommitSafeEnd = offset + boundedEnd;
|
||||
const commitSafeEnd = getNativeScrollbackCommitSafeEnd(child);
|
||||
if (commitSafeEnd !== undefined) {
|
||||
commitLocalEnd = Number.isFinite(commitSafeEnd)
|
||||
? Math.max(liveLocalStart, Math.min(childLines.length, Math.trunc(commitSafeEnd)))
|
||||
: childLines.length;
|
||||
}
|
||||
}
|
||||
// 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). Reused segments are
|
||||
// deliberately NOT read — their baseline must stay anchored to
|
||||
// the last render the engine actually observed.
|
||||
reported = getRenderStablePrefixRows(child);
|
||||
}
|
||||
if (liveLocalStart !== undefined) {
|
||||
this.#nativeScrollbackLiveRegionStart = offset + liveLocalStart;
|
||||
if (commitLocalEnd !== undefined) {
|
||||
this.#nativeScrollbackCommitSafeEnd = offset + commitLocalEnd;
|
||||
}
|
||||
}
|
||||
// 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) {
|
||||
@@ -803,7 +855,14 @@ export class TUI extends Container {
|
||||
chainStable = false;
|
||||
}
|
||||
}
|
||||
segments[index] = { component: child, lines: childLines, start: offset, rowCount: childLines.length };
|
||||
segments[index] = {
|
||||
component: child,
|
||||
lines: childLines,
|
||||
start: offset,
|
||||
rowCount: childLines.length,
|
||||
liveLocalStart,
|
||||
commitLocalEnd,
|
||||
};
|
||||
offset += childLines.length;
|
||||
}
|
||||
this.#frameSegments = segments;
|
||||
@@ -1344,6 +1403,8 @@ export class TUI extends Container {
|
||||
}
|
||||
|
||||
requestRender(force = false, options?: RenderRequestOptions): void {
|
||||
// Any non-component-scoped request makes the pending frame a full one.
|
||||
this.#pendingRenderComponentsOnly = false;
|
||||
if (force) {
|
||||
// Forced repaints landing inside the multiplexer resize debounce
|
||||
// (e.g. `#finishSixelProbe`, image-budget eviction, a programmatic
|
||||
@@ -1374,6 +1435,38 @@ export class TUI extends Container {
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.#requestOrdinaryRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a render on behalf of `component` after a self-contained change
|
||||
* (spinner frame, blink) that cannot have affected any other component.
|
||||
*
|
||||
* When every request since the last frame is component-scoped and the
|
||||
* frame is otherwise quiet — no resize or geometry change, no overlays, no
|
||||
* live inline images, no forced repaint, unchanged root child list — the
|
||||
* next compose re-renders only the root subtrees containing the requesting
|
||||
* components and reuses the previous frame's rows (and seam reports) for
|
||||
* every other root child, skipping the full component-tree walk that makes
|
||||
* long transcripts expensive to repaint at animation rate. Any concurrent
|
||||
* full request or unsafe condition downgrades the frame to a normal full
|
||||
* compose, so this is never less correct than `requestRender()` — only
|
||||
* cheaper.
|
||||
*/
|
||||
requestComponentRender(component: Component): void {
|
||||
if (this.#stopped) return;
|
||||
// Start a component-scoped accumulation only when nothing else is in
|
||||
// flight (a pending throttled request or a deferred ConPTY settle
|
||||
// replay may carry full-render intent that must not be narrowed).
|
||||
if (!this.#renderRequested && this.#postFullPaintSettleTimer === undefined) {
|
||||
this.#pendingRenderComponentsOnly = true;
|
||||
}
|
||||
this.#componentRenderTargets.add(component);
|
||||
this.#requestOrdinaryRender();
|
||||
}
|
||||
|
||||
/** Ordinary (non-forced) scheduling shared by full and component-scoped requests. */
|
||||
#requestOrdinaryRender(): void {
|
||||
// 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
|
||||
@@ -1388,7 +1481,7 @@ export class TUI extends Container {
|
||||
this.#postFullPaintSettleTimer = undefined;
|
||||
this.#postFullPaintSettleUntilMs = 0;
|
||||
if (this.#stopped) return;
|
||||
this.requestRender(false);
|
||||
this.#requestOrdinaryRender();
|
||||
}, this.#postFullPaintSettleUntilMs - now);
|
||||
}
|
||||
return;
|
||||
@@ -1400,6 +1493,56 @@ export class TUI extends Container {
|
||||
this.#renderScheduler.scheduleImmediate(() => this.#scheduleRender());
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether this frame may compose component-scoped, and resolve the
|
||||
* requested components to the root children that must re-render. Returns
|
||||
* null — full compose — whenever a global condition could invalidate rows
|
||||
* the partial compose would reuse, or when a requested component is not
|
||||
* reachable from the current root child list.
|
||||
*/
|
||||
#resolvePartialComposeRoots(width: number, height: number): Set<Component> | null {
|
||||
if (this.#componentRenderTargets.size === 0) return null;
|
||||
if (!this.#hasEverRendered || this.#resizeEventPending) return null;
|
||||
if (width !== this.#previousWidth || height !== this.#previousHeight || width !== this.#composeWidth) return null;
|
||||
if (this.#clearScrollbackOnNextRender || this.#forceViewportRepaintOnNextRender) return null;
|
||||
if (this.overlayStack.length > 0) return null;
|
||||
// The image budget audits display order across the whole frame; a
|
||||
// partial walk would under-count it. Engage only on image-free frames.
|
||||
if (!this.#imageBudget.quiescent) return null;
|
||||
// The root child list must match the segment ledger exactly — a
|
||||
// structural change shifts offsets under every reused segment.
|
||||
const children = this.children;
|
||||
const segments = this.#frameSegments;
|
||||
if (segments.length !== children.length) return null;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
if (segments[i]!.component !== children[i]) return null;
|
||||
}
|
||||
const roots = this.#partialComposeRootsScratch;
|
||||
roots.clear();
|
||||
for (const target of this.#componentRenderTargets) {
|
||||
const root = this.#resolveComponentRoot(target);
|
||||
if (root === null) return null;
|
||||
roots.add(root);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
/** Root child whose subtree contains `target`, memoized per component. */
|
||||
#resolveComponentRoot(target: Component): Component | null {
|
||||
const cached = this.#componentRootCache.get(target);
|
||||
if (cached !== undefined && this.children.includes(cached) && subtreeContains(cached, target)) {
|
||||
return cached;
|
||||
}
|
||||
for (const child of this.children) {
|
||||
if (subtreeContains(child, target)) {
|
||||
this.#componentRootCache.set(target, child);
|
||||
return child;
|
||||
}
|
||||
}
|
||||
this.#componentRootCache.delete(target);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -1477,7 +1620,7 @@ export class TUI extends Container {
|
||||
this.#postFullPaintSettleTimer = undefined;
|
||||
this.#postFullPaintSettleUntilMs = 0;
|
||||
if (this.#stopped) return;
|
||||
this.requestRender(false);
|
||||
this.#requestOrdinaryRender();
|
||||
}, TUI.#CONPTY_POST_FULL_PAINT_SETTLE_MS);
|
||||
}
|
||||
}
|
||||
@@ -1890,6 +2033,11 @@ export class TUI extends Container {
|
||||
const width = this.terminal.columns;
|
||||
const height = this.terminal.rows;
|
||||
|
||||
// Consume the component-scoped accumulation: it describes the render
|
||||
// requests made up to this frame, whichever path the frame takes.
|
||||
const componentScopedOnly = this.#pendingRenderComponentsOnly;
|
||||
this.#pendingRenderComponentsOnly = false;
|
||||
|
||||
// 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.
|
||||
@@ -1916,15 +2064,32 @@ export class TUI extends Container {
|
||||
}
|
||||
}
|
||||
if (this.#altActive) {
|
||||
this.#componentRenderTargets.clear();
|
||||
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();
|
||||
// every inline image in display order (overlays carry none). A
|
||||
// component-scoped frame skips the budget pass instead — it is gated on
|
||||
// a quiescent budget, and a partial tree walk would under-count display
|
||||
// order — and re-renders only the requested root subtrees, reusing the
|
||||
// previous segment of every other root child.
|
||||
const partialRoots = componentScopedOnly ? this.#resolvePartialComposeRoots(width, height) : null;
|
||||
this.#componentRenderTargets.clear();
|
||||
let rawFrame: readonly string[];
|
||||
if (partialRoots !== null) {
|
||||
this.#partialComposeRoots = partialRoots;
|
||||
try {
|
||||
rawFrame = this.render(width);
|
||||
} finally {
|
||||
this.#partialComposeRoots = null;
|
||||
}
|
||||
} else {
|
||||
this.#imageBudget.beginPass();
|
||||
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
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { type Component, Container, type NativeScrollbackLiveRegion, TUI } from "@oh-my-pi/pi-tui";
|
||||
import { StressRenderScheduler } from "./render-stress-scheduler";
|
||||
import { VirtualTerminal } from "./virtual-terminal";
|
||||
|
||||
// Behavioral tests for TUI.requestComponentRender: a component whose own
|
||||
// content changed (spinner frame, blink) asks for a component-scoped frame.
|
||||
// When every request since the last frame is component-scoped and the frame is
|
||||
// otherwise quiet, the compose re-renders only the root subtrees containing
|
||||
// the requesting components and reuses the previous segment — rows and seam
|
||||
// report — of every other root child. Any concurrent full request or unsafe
|
||||
// condition must downgrade to a normal full compose.
|
||||
|
||||
/** Ref-stable leaf: fresh array per change, counts render() calls. */
|
||||
class CountingLines implements Component {
|
||||
renders = 0;
|
||||
#lines: string[];
|
||||
|
||||
constructor(lines: string[]) {
|
||||
this.#lines = lines;
|
||||
}
|
||||
|
||||
set(lines: string[]): void {
|
||||
this.#lines = lines;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(_width: number): readonly string[] {
|
||||
this.renders++;
|
||||
return this.#lines;
|
||||
}
|
||||
}
|
||||
|
||||
/** Transcript-shaped head: final rows committed, the last row stays live. */
|
||||
class LiveHead extends CountingLines implements NativeScrollbackLiveRegion {
|
||||
#seam = 0;
|
||||
|
||||
setSeam(seam: number): void {
|
||||
this.#seam = seam;
|
||||
}
|
||||
|
||||
getNativeScrollbackLiveRegionStart(): number | undefined {
|
||||
return this.#seam;
|
||||
}
|
||||
}
|
||||
|
||||
function strip(rows: string[]): string[] {
|
||||
return rows.map(row => Bun.stripANSI(row).trimEnd());
|
||||
}
|
||||
|
||||
function visible(term: VirtualTerminal): string[] {
|
||||
return strip(term.getViewport()).filter(row => row.length > 0);
|
||||
}
|
||||
|
||||
describe("TUI.requestComponentRender", () => {
|
||||
it("re-renders only the requesting subtree on a quiet frame", async () => {
|
||||
const term = new VirtualTerminal(40, 8, 1_000);
|
||||
const scheduler = new StressRenderScheduler();
|
||||
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
|
||||
const transcript = new CountingLines(["msg-0", "msg-1", "msg-2"]);
|
||||
const status = new Container();
|
||||
const spinner = new CountingLines(["spin-0"]);
|
||||
status.addChild(spinner);
|
||||
tui.addChild(transcript);
|
||||
tui.addChild(status);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await scheduler.drain(term);
|
||||
expect(visible(term)).toEqual(["msg-0", "msg-1", "msg-2", "spin-0"]);
|
||||
const transcriptRenders = transcript.renders;
|
||||
|
||||
// Spinner tick: component-scoped request, nested one level deep.
|
||||
spinner.set(["spin-1"]);
|
||||
tui.requestComponentRender(spinner);
|
||||
await scheduler.drain(term);
|
||||
|
||||
expect(visible(term)).toEqual(["msg-0", "msg-1", "msg-2", "spin-1"]);
|
||||
// The transcript subtree was reused, not re-rendered.
|
||||
expect(transcript.renders).toBe(transcriptRenders);
|
||||
} finally {
|
||||
tui.stop();
|
||||
await term.flush();
|
||||
}
|
||||
});
|
||||
|
||||
it("downgrades to a full compose when a full request shares the frame", async () => {
|
||||
const term = new VirtualTerminal(40, 8, 1_000);
|
||||
const scheduler = new StressRenderScheduler();
|
||||
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
|
||||
const transcript = new CountingLines(["msg-0"]);
|
||||
const spinner = new CountingLines(["spin-0"]);
|
||||
tui.addChild(transcript);
|
||||
tui.addChild(spinner);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await scheduler.drain(term);
|
||||
const transcriptRenders = transcript.renders;
|
||||
|
||||
// Both a component-scoped and a full request coalesce into one
|
||||
// frame; the full request wins regardless of arrival order.
|
||||
spinner.set(["spin-1"]);
|
||||
tui.requestComponentRender(spinner);
|
||||
transcript.set(["msg-0", "msg-edited"]);
|
||||
tui.requestRender();
|
||||
await scheduler.drain(term);
|
||||
|
||||
expect(visible(term)).toEqual(["msg-0", "msg-edited", "spin-1"]);
|
||||
expect(transcript.renders).toBeGreaterThan(transcriptRenders);
|
||||
} finally {
|
||||
tui.stop();
|
||||
await term.flush();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to a full compose while an overlay is up", async () => {
|
||||
const term = new VirtualTerminal(40, 8, 1_000);
|
||||
const scheduler = new StressRenderScheduler();
|
||||
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
|
||||
const transcript = new CountingLines(["msg-0"]);
|
||||
const spinner = new CountingLines(["spin-0"]);
|
||||
tui.addChild(transcript);
|
||||
tui.addChild(spinner);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await scheduler.drain(term);
|
||||
tui.showOverlay(new CountingLines(["modal"]), { width: 10 });
|
||||
await scheduler.drain(term);
|
||||
const transcriptRenders = transcript.renders;
|
||||
|
||||
spinner.set(["spin-1"]);
|
||||
tui.requestComponentRender(spinner);
|
||||
await scheduler.drain(term);
|
||||
|
||||
// Unsafe condition: the frame rendered fully (and correctly).
|
||||
expect(transcript.renders).toBeGreaterThan(transcriptRenders);
|
||||
} finally {
|
||||
tui.stop();
|
||||
await term.flush();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to a full compose when the root child list changed", async () => {
|
||||
const term = new VirtualTerminal(40, 8, 1_000);
|
||||
const scheduler = new StressRenderScheduler();
|
||||
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
|
||||
const transcript = new CountingLines(["msg-0"]);
|
||||
const spinner = new CountingLines(["spin-0"]);
|
||||
tui.addChild(transcript);
|
||||
tui.addChild(spinner);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await scheduler.drain(term);
|
||||
|
||||
// Structural change with only a component-scoped request pending:
|
||||
// the segment ledger no longer matches the root list, so the frame
|
||||
// must compose fully and paint the new child.
|
||||
tui.addChild(new CountingLines(["banner"]));
|
||||
spinner.set(["spin-1"]);
|
||||
tui.requestComponentRender(spinner);
|
||||
await scheduler.drain(term);
|
||||
|
||||
expect(visible(term)).toEqual(["msg-0", "spin-1", "banner"]);
|
||||
} finally {
|
||||
tui.stop();
|
||||
await term.flush();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to a full compose when the component is not in the tree", async () => {
|
||||
const term = new VirtualTerminal(40, 8, 1_000);
|
||||
const scheduler = new StressRenderScheduler();
|
||||
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
|
||||
const transcript = new CountingLines(["msg-0"]);
|
||||
const status = new Container();
|
||||
const spinner = new CountingLines(["spin-0"]);
|
||||
status.addChild(spinner);
|
||||
tui.addChild(transcript);
|
||||
tui.addChild(status);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await scheduler.drain(term);
|
||||
const transcriptRenders = transcript.renders;
|
||||
|
||||
// A detached component (cleared status container) can still fire a
|
||||
// trailing tick; the frame must not skip anything based on it.
|
||||
status.removeChild(spinner);
|
||||
tui.requestComponentRender(spinner);
|
||||
await scheduler.drain(term);
|
||||
|
||||
expect(visible(term)).toEqual(["msg-0"]);
|
||||
expect(transcript.renders).toBeGreaterThan(transcriptRenders);
|
||||
} finally {
|
||||
tui.stop();
|
||||
await term.flush();
|
||||
}
|
||||
});
|
||||
|
||||
it("replays the seam report of a skipped root child across partial frames", async () => {
|
||||
const term = new VirtualTerminal(40, 4, 1_000);
|
||||
const scheduler = new StressRenderScheduler();
|
||||
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
|
||||
const markers = Array.from({ length: 8 }, (_unused, i) => `ROW-${String(i).padStart(3, "0")}`);
|
||||
// All but the last head row are final; the tail row stays live.
|
||||
const head = new LiveHead([...markers, "streaming"]);
|
||||
head.setSeam(markers.length);
|
||||
const spinner = new CountingLines(["spin-0"]);
|
||||
tui.addChild(head);
|
||||
tui.addChild(spinner);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await scheduler.drain(term);
|
||||
const headRenders = head.renders;
|
||||
|
||||
// Several spinner-only frames while the head (and its commit seam)
|
||||
// ride the reused segment.
|
||||
for (let tick = 1; tick <= 3; tick++) {
|
||||
spinner.set([`spin-${tick}`]);
|
||||
tui.requestComponentRender(spinner);
|
||||
await scheduler.drain(term);
|
||||
}
|
||||
expect(head.renders).toBe(headRenders);
|
||||
expect(visible(term).at(-1)).toBe("spin-3");
|
||||
|
||||
// A later full frame must still commit exactly once: every final
|
||||
// row appears exactly once across history + grid, in order.
|
||||
head.set([...markers, "streamed-final", "tail"]);
|
||||
head.setSeam(markers.length + 2);
|
||||
tui.requestRender();
|
||||
await scheduler.drain(term);
|
||||
|
||||
const buffer = strip(term.getScrollBuffer()).join("\n");
|
||||
const missing = markers.filter(mark => buffer.split(mark).length - 1 === 0);
|
||||
const duplicated = markers.filter(mark => buffer.split(mark).length - 1 > 1);
|
||||
expect(missing).toEqual([]);
|
||||
expect(duplicated).toEqual([]);
|
||||
const observed = Array.from(buffer.matchAll(/ROW-\d{3}/g), match => match[0]);
|
||||
expect(observed).toEqual(markers);
|
||||
} finally {
|
||||
tui.stop();
|
||||
await term.flush();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,7 @@ describe("Loader component", () => {
|
||||
|
||||
it("keeps spinner cadence when animated messages repaint at 30fps", () => {
|
||||
vi.useFakeTimers();
|
||||
const ui = { requestRender: vi.fn() } as unknown as TUI;
|
||||
const ui = { requestComponentRender: vi.fn() } as unknown as TUI;
|
||||
const colorMessage = ((text: string) => text) as LoaderMessageColorFn & { animated: true };
|
||||
colorMessage.animated = true;
|
||||
const loader = new Loader(ui, text => text, colorMessage, "Checking", ["0", "1", "2", "3"]);
|
||||
@@ -49,18 +49,18 @@ describe("Loader component", () => {
|
||||
|
||||
it("skips animated render requests when composed text is unchanged before the spinner advances", () => {
|
||||
vi.useFakeTimers();
|
||||
const ui = { requestRender: vi.fn() } as unknown as TUI;
|
||||
const ui = { requestComponentRender: vi.fn() } as unknown as TUI;
|
||||
const colorMessage = ((text: string) => text) as LoaderMessageColorFn & { animated: true };
|
||||
colorMessage.animated = true;
|
||||
const loader = new Loader(ui, text => text, colorMessage, "Checking", ["0", "1"]);
|
||||
|
||||
expect(ui.requestRender).toHaveBeenCalledTimes(1);
|
||||
expect(ui.requestComponentRender).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(34);
|
||||
expect(ui.requestRender).toHaveBeenCalledTimes(1);
|
||||
expect(ui.requestComponentRender).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(67);
|
||||
expect(ui.requestRender).toHaveBeenCalledTimes(2);
|
||||
expect(ui.requestComponentRender).toHaveBeenCalledTimes(2);
|
||||
expect(loader.render(20).join("\n")).toContain("1 Checking");
|
||||
|
||||
loader.stop();
|
||||
@@ -68,7 +68,7 @@ describe("Loader component", () => {
|
||||
|
||||
it("requests render for message changes but not repeated identical messages", () => {
|
||||
vi.useFakeTimers();
|
||||
const ui = { requestRender: vi.fn() } as unknown as TUI;
|
||||
const ui = { requestComponentRender: vi.fn() } as unknown as TUI;
|
||||
const loader = new Loader(
|
||||
ui,
|
||||
text => text,
|
||||
@@ -77,14 +77,14 @@ describe("Loader component", () => {
|
||||
["0"],
|
||||
);
|
||||
|
||||
expect(ui.requestRender).toHaveBeenCalledTimes(1);
|
||||
expect(ui.requestComponentRender).toHaveBeenCalledTimes(1);
|
||||
|
||||
loader.setMessage("Still checking");
|
||||
expect(ui.requestRender).toHaveBeenCalledTimes(2);
|
||||
expect(ui.requestComponentRender).toHaveBeenCalledTimes(2);
|
||||
expect(loader.render(30).join("\n")).toContain("0 Still checking");
|
||||
|
||||
loader.setMessage("Still checking");
|
||||
expect(ui.requestRender).toHaveBeenCalledTimes(2);
|
||||
expect(ui.requestComponentRender).toHaveBeenCalledTimes(2);
|
||||
|
||||
loader.stop();
|
||||
});
|
||||
@@ -92,15 +92,15 @@ describe("Loader component", () => {
|
||||
it("requests render when animated message bytes change between spinner frames", () => {
|
||||
vi.useFakeTimers();
|
||||
setSystemTime(new Date(1_000));
|
||||
const ui = { requestRender: vi.fn() } as unknown as TUI;
|
||||
const ui = { requestComponentRender: vi.fn() } as unknown as TUI;
|
||||
const colorMessage = ((text: string) => `${text}-${Date.now()}`) as LoaderMessageColorFn & { animated: true };
|
||||
colorMessage.animated = true;
|
||||
const loader = new Loader(ui, text => text, colorMessage, "Checking", ["0"]);
|
||||
|
||||
expect(ui.requestRender).toHaveBeenCalledTimes(1);
|
||||
expect(ui.requestComponentRender).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(34);
|
||||
expect(ui.requestRender).toHaveBeenCalledTimes(2);
|
||||
expect(ui.requestComponentRender).toHaveBeenCalledTimes(2);
|
||||
expect(loader.render(40).join("\n")).toContain("0 Checking-");
|
||||
|
||||
loader.stop();
|
||||
@@ -116,7 +116,7 @@ describe("Loader component", () => {
|
||||
"Checking",
|
||||
["a", "b", "c"],
|
||||
);
|
||||
const spy = spyOn(tui, "requestRender");
|
||||
const spy = spyOn(tui, "requestComponentRender");
|
||||
loader.dispose();
|
||||
const after = spy.mock.calls.length;
|
||||
await Bun.sleep(40); // longer than the spinner interval
|
||||
|
||||
Reference in New Issue
Block a user