From b1338b8f57f2e56b9cfeb8cc3f5271ca2db52281 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 11 Jun 2026 16:10:27 +0200 Subject: [PATCH] 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. --- packages/ai/CHANGELOG.md | 1 + packages/coding-agent/src/cli/gallery-cli.ts | 9 +- .../src/modes/components/btw-panel.ts | 6 +- .../src/modes/interactive-mode.ts | 10 +- packages/tui/CHANGELOG.md | 8 + packages/tui/src/components/image.ts | 15 ++ packages/tui/src/components/loader.ts | 6 +- packages/tui/src/tui.ts | 221 ++++++++++++++-- packages/tui/test/component-render.test.ts | 250 ++++++++++++++++++ packages/tui/test/loader.test.ts | 26 +- 10 files changed, 503 insertions(+), 49 deletions(-) create mode 100644 packages/tui/test/component-render.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index ee9d8a534..3c5b98545 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/cli/gallery-cli.ts b/packages/coding-agent/src/cli/gallery-cli.ts index 31d1a958b..2efef7293 100644 --- a/packages/coding-agent/src/cli/gallery-cli.ts +++ b/packages/coding-agent/src/cli/gallery-cli.ts @@ -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); diff --git a/packages/coding-agent/src/modes/components/btw-panel.ts b/packages/coding-agent/src/modes/components/btw-panel.ts index 362857953..91c461779 100644 --- a/packages/coding-agent/src/modes/components/btw-panel.ts +++ b/packages/coding-agent/src/modes/components/btw-panel.ts @@ -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 { diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index e7d0f5924..66410946e 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -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 { 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); } diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 526835a90..1b76e0b33 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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)). diff --git a/packages/tui/src/components/image.ts b/packages/tui/src/components/image.ts index 7c78e6a25..01d0f3e10 100644 --- a/packages/tui/src/components/image.ts +++ b/packages/tui/src/components/image.ts @@ -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; diff --git a/packages/tui/src/components/loader.ts b/packages/tui/src/components/loader.ts index aa7b68682..65a5c1292 100644 --- a/packages/tui/src/components/loader.ts +++ b/packages/tui/src/components/loader.ts @@ -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); } } } diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 9d0bae996..0eb2f7d5e 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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).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(); + #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 | null = null; + #partialComposeRootsScratch = new Set(); + // Target component -> containing root child, so animation-rate requests do + // not re-walk a huge transcript subtree every frame. + #componentRootCache = new WeakMap(); + // 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 | 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 diff --git a/packages/tui/test/component-render.test.ts b/packages/tui/test/component-render.test.ts new file mode 100644 index 000000000..d2486751c --- /dev/null +++ b/packages/tui/test/component-render.test.ts @@ -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(); + } + }); +}); diff --git a/packages/tui/test/loader.test.ts b/packages/tui/test/loader.test.ts index c2f26101f..77c341a1f 100644 --- a/packages/tui/test/loader.test.ts +++ b/packages/tui/test/loader.test.ts @@ -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