diff --git a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts index 0264a6a65..c3cda219b 100644 --- a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts @@ -38,7 +38,12 @@ function isVisibleContent(content: AssistantMessage["content"][number], provider } export class AssistantMessageComponent extends Container { - private renderCache?: { readonly lines: string[]; readonly signature: string; readonly width: number }; + private renderCache?: { + readonly lines: string[]; + readonly revision: number; + readonly signature: string; + readonly width: number; + }; private contentContainer: Container; private hideThinkingBlock: boolean; private markdownTheme: MarkdownTheme; @@ -108,11 +113,16 @@ export class AssistantMessageComponent extends Container { override render(width: number): string[] { const signature = this.lastMessageSignature ?? ""; - if (this.renderCache?.width === width && this.renderCache.signature === signature) { - return [...this.renderCache.lines]; + const revision = this.getRenderRevision(); + if ( + this.renderCache?.width === width && + this.renderCache.signature === signature && + this.renderCache.revision === revision + ) { + return this.renderCache.lines; } - const lines = super.render(width); + const lines = [...super.render(width)]; if (this.hasToolCalls || lines.length === 0) { this.cacheRender(width, signature, lines); return lines; @@ -135,6 +145,7 @@ export class AssistantMessageComponent extends Container { } this.lastMessageSignature = messageSignature; this.renderCache = undefined; + this.markRenderInvalidated(); if (streamingChanged) this.renderDescriptors = []; this.hasToolCalls = message.content.some((content) => content.type === "toolCall"); const descriptors = this.createRenderDescriptors(message); @@ -267,7 +278,7 @@ export class AssistantMessageComponent extends Container { } divergentIndex++; } - for (const child of this.contentContainer.children.splice(divergentIndex)) child.dispose?.(); + for (const child of this.contentContainer.detachChildrenFrom(divergentIndex)) child.dispose?.(); for (const descriptor of descriptors.slice(divergentIndex)) this.contentContainer.addChild(this.createRenderChild(descriptor)); this.renderDescriptors = descriptors; @@ -318,7 +329,7 @@ export class AssistantMessageComponent extends Container { } private cacheRender(width: number, signature: string, lines: string[]): void { - this.renderCache = { lines: [...lines], signature, width }; + this.renderCache = { lines, revision: this.getRenderRevision(), signature, width }; } private refreshContent(): void { diff --git a/packages/coding-agent/src/modes/interactive/components/custom-entry.ts b/packages/coding-agent/src/modes/interactive/components/custom-entry.ts index 26444edbb..326bdf3b0 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-entry.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-entry.ts @@ -37,6 +37,13 @@ export class CustomEntryComponent extends Container { this.rebuild(); } + override isRenderCacheTrackable(): boolean { + // Entry renderers are snapshots. Expansion and theme changes rebuild this + // boundary explicitly, so opaque extension children cannot force every + // transcript frame to revisit all historical entries. + return true; + } + private rebuild(): void { this.clear(); this.customComponent = undefined; diff --git a/packages/coding-agent/src/modes/interactive/components/custom-message.ts b/packages/coding-agent/src/modes/interactive/components/custom-message.ts index b5ee92658..291f59174 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-message.ts @@ -57,6 +57,12 @@ export class CustomMessageComponent extends Container { this.rebuild(); } + override isRenderCacheTrackable(): boolean { + // Message renderers are rebuilt only through this component's explicit + // state transitions, making this a revision boundary for opaque children. + return true; + } + private rebuild(): void { // Remove previous content component if (this.customComponent) { diff --git a/packages/coding-agent/src/modes/interactive/components/dynamic-border.ts b/packages/coding-agent/src/modes/interactive/components/dynamic-border.ts index 77342b25e..d18529bae 100644 --- a/packages/coding-agent/src/modes/interactive/components/dynamic-border.ts +++ b/packages/coding-agent/src/modes/interactive/components/dynamic-border.ts @@ -10,16 +10,42 @@ import { theme } from "../theme/theme.ts"; */ export class DynamicBorder implements Component { private color: (str: string) => string; + private renderCache?: { width: number; lines: string[] }; + private renderRevision = 0; + private renderInvalidationCallback: (() => void) | undefined; constructor(color: (str: string) => string = (str) => theme.fg("border", str)) { this.color = color; } invalidate(): void { - // No cached state to invalidate currently + this.renderCache = undefined; + this.renderRevision++; + this.renderInvalidationCallback?.(); + } + + getRenderRevision(): number { + return this.renderRevision; + } + + getRenderChangeStart(): number { + return 0; + } + + setRenderInvalidationCallback(callback: (() => void) | undefined): void { + this.renderInvalidationCallback = callback; + } + + isRenderCacheTrackable(): boolean { + return true; } render(width: number): string[] { - return [this.color("─".repeat(Math.max(1, width)))]; + if (this.renderCache?.width === width) { + return this.renderCache.lines; + } + const lines = [this.color("─".repeat(Math.max(1, width)))]; + this.renderCache = { width, lines }; + return lines; } } diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 88371b9d5..29c80b765 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -75,9 +75,7 @@ export class LoginDialogComponent extends Container implements Focusable { } private replaceInputWithSubmittedText(value: string): void { - this.contentContainer.children = this.contentContainer.children.map((child) => - child === this.input ? new Text(`> ${value}`, 0, 0) : child, - ); + this.contentContainer.replaceChild(this.input, new Text(`> ${value}`, 0, 0)); } private cancel(): void { diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index 4a7c81efb..4a2e7851d 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -37,6 +37,7 @@ export class ToolExecutionComponent extends Container { private todoStrikeInterval?: NodeJS.Timeout; private result?: ToolExecutionResult; private cachedLines?: string[]; + private cachedRevision?: number; private cachedSignature?: string; private cachedWidth?: number; private lastDisplaySignature?: string; @@ -143,7 +144,7 @@ export class ToolExecutionComponent extends Container { } override invalidate(): void { - this.invalidateRenderCache(); + this.invalidateRenderCache(false); super.invalidate(); this.lastDisplaySignature = undefined; this.updateDisplay(); @@ -153,8 +154,14 @@ export class ToolExecutionComponent extends Container { if (this.presentation === "grok") return super.render(width); const signature = this.createRenderSignature(); - if (this.cachedLines && this.cachedWidth === width && this.cachedSignature === signature) { - return [...this.cachedLines]; + const revision = this.getRenderRevision(); + if ( + this.cachedLines && + this.cachedWidth === width && + this.cachedSignature === signature && + this.cachedRevision === revision + ) { + return this.cachedLines; } let lines: string[]; @@ -171,7 +178,8 @@ export class ToolExecutionComponent extends Container { this.cachedWidth = width; this.cachedSignature = signature; - this.cachedLines = [...lines]; + this.cachedRevision = this.getRenderRevision(); + this.cachedLines = lines; return lines; } @@ -290,9 +298,18 @@ export class ToolExecutionComponent extends Container { this.invalidateRenderCache(); } - private invalidateRenderCache(): void { + override isRenderCacheTrackable(): boolean { + // Renderer components receive state changes only through this shell. Async + // renderers use the supplied invalidate callback, so the shell is a safe + // revision boundary even when an extension component is not trackable. + return true; + } + + private invalidateRenderCache(notifyParent = true): void { this.cachedLines = undefined; + this.cachedRevision = undefined; this.cachedSignature = undefined; this.cachedWidth = undefined; + if (notifyParent) this.markRenderInvalidated(); } } diff --git a/packages/coding-agent/src/modes/interactive/components/user-message.ts b/packages/coding-agent/src/modes/interactive/components/user-message.ts index 009389dfc..199ca0145 100644 --- a/packages/coding-agent/src/modes/interactive/components/user-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/user-message.ts @@ -15,6 +15,7 @@ export class UserMessageComponent extends Container { private markdownTheme: MarkdownTheme; private outputPad: number; private markdownTransformers: readonly MarkdownTransformer[]; + private renderCache?: { width: number; lines: string[]; revision: number }; constructor( text: string, @@ -36,6 +37,7 @@ export class UserMessageComponent extends Container { } private rebuild(): void { + this.renderCache = undefined; this.clear(); const contentBox = new Box(this.outputPad, 1, (content: string) => theme.bg("userMessageBg", content)); contentBox.addChild( @@ -57,14 +59,25 @@ export class UserMessageComponent extends Container { this.addChild(contentBox); } + override invalidate(): void { + this.renderCache = undefined; + super.invalidate(); + } + override render(width: number): string[] { - const lines = super.render(width); + const revision = this.getRenderRevision(); + if (this.renderCache?.width === width && this.renderCache.revision === revision) { + return this.renderCache.lines; + } + const lines = [...super.render(width)]; if (lines.length === 0) { + this.renderCache = { width, lines, revision: this.getRenderRevision() }; return lines; } lines[0] = OSC133_ZONE_START + lines[0]; lines[lines.length - 1] = OSC133_ZONE_END + OSC133_ZONE_FINAL + lines[lines.length - 1]; + this.renderCache = { width, lines, revision: this.getRenderRevision() }; return lines; } } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 34124ea69..37197d25f 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2791,10 +2791,10 @@ export class InteractiveMode { this.customHeader.setExpanded(this.toolOutputExpanded); } if (index !== -1) { - this.headerContainer.children[index] = this.customHeader; + this.headerContainer.replaceChild(currentHeader, this.customHeader); } else { // If not found (e.g. builtInHeader was never added), add at the top - this.headerContainer.children.unshift(this.customHeader); + this.headerContainer.insertChild(0, this.customHeader); } } else { // Restore built-in header @@ -2803,7 +2803,7 @@ export class InteractiveMode { this.builtInHeader.setExpanded(this.toolOutputExpanded); } if (index !== -1) { - this.headerContainer.children[index] = this.builtInHeader; + this.headerContainer.replaceChild(currentHeader, this.builtInHeader); } } @@ -4197,7 +4197,7 @@ export class InteractiveMode { if (this.streamingComponent) { const streamingIndex = this.chatContainer.children.indexOf(this.streamingComponent); if (streamingIndex >= 0) { - this.chatContainer.children.splice(streamingIndex, 0, component); + this.chatContainer.insertChild(streamingIndex, component); return; } } diff --git a/packages/tui/README.md b/packages/tui/README.md index 92bd20dfc..24f0ea827 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -78,6 +78,10 @@ tui.requestRender(); // Request a re-render tui.onDebug = () => console.log("Debug triggered"); ``` +`children` is a read-only view. Use `addChild`, `insertChild`, `replaceChild`, `removeChild`, or the detach methods +to preserve render invalidation. A component may occur more than once in one container or be projected by multiple +containers; its invalidation is delivered to every containing parent. + ### Alternate-screen viewport layouts `TuiAltScreen` can render an explicit terminal-height layout. `VStack` and `HStack` allocate constrained regions, while `ScrollView` owns scrolling for one region. These semantics are intentionally unavailable on `TuiMainScreen`, where the terminal owns scrollback. diff --git a/packages/tui/src/changes.md b/packages/tui/src/changes.md index bf1aed98f..8cab89bd7 100644 --- a/packages/tui/src/changes.md +++ b/packages/tui/src/changes.md @@ -1,5 +1,29 @@ # TUI delta rendering fork changes +## 2026-08-03: revision-driven container render caching with shared-child invalidation + +### What changed + +- `Container` caches each child's rendered lines and revision, reuses the stable prefix, and propagates the earliest + changed line through nested containers. `Box` and `ScrollView` keep their flattened render projections cached too. +- Container children now have a read-only public view and must be changed through the binding-aware mutation methods. + A single installed dispatcher fans child invalidation out to every containing parent, while per-container binding + counts keep repeated references subscribed until their last occurrence is removed. +- Coding-agent transcript paths use the container mutation methods, so streaming and transcript compaction invalidate + only the affected tail while unchanged history remains cached. +- `test/container-render-cache.test.ts` covers stable reuse, nested tail changes, append/remove, repeated references, + shared-child invalidation fan-out, clean invalidation windows, width changes, and fullscreen scrollbar projection. + +### Why this cannot be expressed externally + +Child ownership, render revisions, invalidation callbacks, and cached line arrays are private component-tree state. +Keeping callbacks connected and reusing unchanged descendant output therefore requires changes inside the TUI. + +### Expected merge conflict zones + +- MEDIUM: `tui.ts` `Container` child mutation and render methods. +- LOW: `components/box.ts`, `components/scroll-view.ts`, and coding-agent transcript mutation call sites. + ## 2026-07-31: memoized line normalization and viewport-bounded rendering by default ### What changed diff --git a/packages/tui/src/components/box.ts b/packages/tui/src/components/box.ts index 1ef6c9b3c..cf5e22b35 100644 --- a/packages/tui/src/components/box.ts +++ b/packages/tui/src/components/box.ts @@ -1,121 +1,91 @@ -import type { Component } from "../tui.ts"; +import { Container } from "../tui.ts"; import { applyBackgroundToLine, visibleWidth } from "../utils.ts"; type RenderCache = { childLines: string[]; width: number; bgSample: string | undefined; + revision: number; lines: string[]; }; /** * Box component - a container that applies padding and background to all children */ -export class Box implements Component { - children: Component[] = []; +export class Box extends Container { private paddingX: number; private paddingY: number; private bgFn?: (text: string) => string; - private disposed = false; // Cache for rendered output private cache?: RenderCache; constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string) { + super(); this.paddingX = paddingX; this.paddingY = paddingY; this.bgFn = bgFn; } - addChild(component: Component): void { - this.children.push(component); - this.invalidateCache(); - } - - removeChild(component: Component): void { - const index = this.children.indexOf(component); - if (index !== -1) { - this.children.splice(index, 1); - this.invalidateCache(); - component.dispose?.(); - } - } - - clear(): void { - for (const child of this.children) { - child.dispose?.(); - } - this.children = []; - this.invalidateCache(); - } - - detachAll(): void { - this.children = []; - this.invalidateCache(); - } - - dispose(): void { - if (this.disposed) return; - this.disposed = true; - for (const child of this.children) { - child.dispose?.(); - } - this.invalidateCache(); - } - setBgFn(bgFn?: (text: string) => string): void { this.bgFn = bgFn; - // Don't invalidate here - we'll detect bgFn changes by sampling output + this.invalidateCache(); + this.markRenderInvalidated(); } private invalidateCache(): void { this.cache = undefined; } - private matchCache(width: number, childLines: string[], bgSample: string | undefined): boolean { + private matchCache(width: number, childLines: string[], bgSample: string | undefined, revision: number): boolean { const cache = this.cache; return ( !!cache && cache.width === width && cache.bgSample === bgSample && + cache.revision === revision && cache.childLines.length === childLines.length && cache.childLines.every((line, i) => line === childLines[i]) ); } - invalidate(): void { + override invalidate(): void { this.invalidateCache(); - for (const child of this.children) { - child.invalidate?.(); - } + super.invalidate(); } - render(width: number): string[] { + override render(width: number): string[] { if (this.children.length === 0) { return []; } const contentWidth = Math.max(1, width - this.paddingX * 2); const leftPad = " ".repeat(this.paddingX); + // Best-effort compatibility check for stateful background closures. Because the probe cannot detect + // text-dependent behavior, callers must use setBgFn() whenever the background semantics change. + const bgSample = this.bgFn ? this.bgFn("test") : undefined; + const revision = this.getRenderRevision(); + if ( + this.isRenderCacheTrackable() && + this.cache?.width === width && + this.cache.bgSample === bgSample && + this.cache.revision === revision + ) { + return this.cache.lines; + } - // Render all children + // Flatten children through Container so unchanged descendants are not rendered. const childLines: string[] = []; - for (const child of this.children) { - const lines = child.render(contentWidth); - for (const line of lines) { - childLines.push(leftPad + line); - } + for (const line of super.render(contentWidth)) { + childLines.push(leftPad + line); } if (childLines.length === 0) { return []; } - // Check if bgFn output changed by sampling - const bgSample = this.bgFn ? this.bgFn("test") : undefined; - // Check cache validity - if (this.matchCache(width, childLines, bgSample)) { + if (this.matchCache(width, childLines, bgSample, revision)) { return this.cache!.lines; } @@ -138,7 +108,7 @@ export class Box implements Component { } // Update cache - this.cache = { childLines, width, bgSample, lines: result }; + this.cache = { childLines, width, bgSample, revision, lines: result }; return result; } diff --git a/packages/tui/src/components/loader.ts b/packages/tui/src/components/loader.ts index 4054f6510..15f2c1fdf 100644 --- a/packages/tui/src/components/loader.ts +++ b/packages/tui/src/components/loader.ts @@ -39,6 +39,7 @@ export class Loader extends Text { private messageColorFn: (str: string) => string; private message: string = "Loading..."; private lastDisplayedText: string | undefined = undefined; + private renderedLines?: { width: number; textLines: string[]; lines: string[] }; constructor( ui: TUI, @@ -56,7 +57,13 @@ export class Loader extends Text { } render(width: number): string[] { - return ["", ...super.render(width)]; + const textLines = super.render(width); + if (this.renderedLines?.width === width && this.renderedLines.textLines === textLines) { + return this.renderedLines.lines; + } + const lines = ["", ...textLines]; + this.renderedLines = { width, textLines, lines }; + return lines; } start(): void { diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 351d0fdbe..0db22bd52 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -433,6 +433,8 @@ export class Markdown implements Component { private cachedText?: string; private cachedWidth?: number; private cachedLines?: string[]; + private renderRevision = 0; + private renderInvalidationCallback: (() => void) | undefined; constructor( text: string, @@ -459,6 +461,24 @@ export class Markdown implements Component { this.cachedText = undefined; this.cachedWidth = undefined; this.cachedLines = undefined; + this.renderRevision++; + this.renderInvalidationCallback?.(); + } + + getRenderRevision(): number { + return this.renderRevision; + } + + getRenderChangeStart(): number { + return 0; + } + + setRenderInvalidationCallback(callback: (() => void) | undefined): void { + this.renderInvalidationCallback = callback; + } + + isRenderCacheTrackable(): boolean { + return true; } render(width: number): string[] { diff --git a/packages/tui/src/components/scroll-view.ts b/packages/tui/src/components/scroll-view.ts index f2c0b5fd7..e51b12425 100644 --- a/packages/tui/src/components/scroll-view.ts +++ b/packages/tui/src/components/scroll-view.ts @@ -29,6 +29,13 @@ export class ScrollView extends Container { private transientScrollbarVisible = false; private scrollbarActive = false; private scrollbarHideTimer: NodeJS.Timeout | undefined; + private scrollRenderCache?: { + width: number; + contentWidth: number; + childLines: string[]; + childRevision: number | undefined; + lines: string[]; + }; constructor(component: Component, options: ScrollViewOptions = {}) { super(); @@ -36,7 +43,7 @@ export class ScrollView extends Container { throw new Error(`Unsupported ScrollView axis: ${options.axis}`); } this.child = component; - this.children.push(component); + super.addChild(component); this.followEnd = (options.follow ?? "none") === "end"; this.followingEnd = this.followEnd; this.primary = options.primary ?? false; @@ -72,6 +79,8 @@ export class ScrollView extends Container { setScrollbar(scrollbar: ScrollViewScrollbar): void { if (scrollbar === this.currentScrollbar) return; this.currentScrollbar = scrollbar; + this.scrollRenderCache = undefined; + this.markRenderInvalidated(); if (scrollbar !== "auto") this.hideTransientScrollbar(); else if (this.scrollbarActive) this.markScrollbarActivity(); this.requestRenderCallback?.(); @@ -183,10 +192,51 @@ export class ScrollView extends Container { throw new Error("ScrollView child cannot be cleared"); } + override invalidate(): void { + this.scrollRenderCache = undefined; + super.invalidate(); + } + override render(width: number): string[] { const contentWidth = this.getContentWidth(width); - const lines = this.child.render(contentWidth); - return contentWidth === width ? lines : lines.map((line) => `${line} `); + const cached = this.scrollRenderCache; + const childRevisionBeforeRender = this.child.getRenderRevision?.(); + const childTrackable = + typeof this.child.getRenderRevision === "function" && + typeof this.child.setRenderInvalidationCallback === "function" && + this.child.isRenderCacheTrackable?.() === true; + const canReuseChild = + cached?.contentWidth === contentWidth && childTrackable && cached.childRevision === childRevisionBeforeRender; + const childLines = canReuseChild ? cached.childLines : this.child.render(contentWidth); + const childRevision = this.child.getRenderRevision?.(); + if ( + cached?.width === width && + cached.contentWidth === contentWidth && + cached.childLines === childLines && + cached.childRevision === childRevision + ) { + this.markRenderCompleted(this.getRenderChangeStart()); + return cached.lines; + } + + const changeStart = + cached?.width === width && cached.contentWidth === contentWidth && cached.childRevision !== childRevision + ? Math.max(0, Math.min(this.child.getRenderChangeStart?.() ?? 0, childLines.length)) + : 0; + let lines: string[]; + if (contentWidth === width) { + lines = childLines; + } else if (cached?.width === width && cached.contentWidth === contentWidth) { + lines = cached.lines.slice(0, changeStart); + for (let index = changeStart; index < childLines.length; index++) { + lines.push(`${childLines[index]} `); + } + } else { + lines = childLines.map((line) => `${line} `); + } + this.scrollRenderCache = { width, contentWidth, childLines, childRevision, lines }; + this.markRenderCompleted(changeStart); + return lines; } [LAYOUT_NODE](): ScrollLayoutNode { diff --git a/packages/tui/src/components/spacer.ts b/packages/tui/src/components/spacer.ts index 7abe1551c..2ea17c546 100644 --- a/packages/tui/src/components/spacer.ts +++ b/packages/tui/src/components/spacer.ts @@ -5,24 +5,48 @@ import type { Component } from "../tui.ts"; */ export class Spacer implements Component { private lines: number; + private renderedLines: string[]; + private renderRevision = 0; + private renderInvalidationCallback: (() => void) | undefined; constructor(lines: number = 1) { this.lines = lines; + this.renderedLines = Array.from({ length: Math.max(0, Math.ceil(lines)) }, () => ""); } setLines(lines: number): void { + if (this.lines === lines) return; this.lines = lines; + this.renderedLines = Array.from({ length: Math.max(0, Math.ceil(lines)) }, () => ""); + this.markRenderInvalidated(); } invalidate(): void { - // No cached state to invalidate currently + this.markRenderInvalidated(); + } + + getRenderRevision(): number { + return this.renderRevision; + } + + getRenderChangeStart(): number { + return 0; + } + + setRenderInvalidationCallback(callback: (() => void) | undefined): void { + this.renderInvalidationCallback = callback; + } + + isRenderCacheTrackable(): boolean { + return true; } render(_width: number): string[] { - const result: string[] = []; - for (let i = 0; i < this.lines; i++) { - result.push(""); - } - return result; + return this.renderedLines; + } + + private markRenderInvalidated(): void { + this.renderRevision++; + this.renderInvalidationCallback?.(); } } diff --git a/packages/tui/src/components/text.ts b/packages/tui/src/components/text.ts index 3809a48a8..3c8d3e60e 100644 --- a/packages/tui/src/components/text.ts +++ b/packages/tui/src/components/text.ts @@ -14,6 +14,8 @@ export class Text implements Component { private cachedText?: string; private cachedWidth?: number; private cachedLines?: string[]; + private renderRevision = 0; + private renderInvalidationCallback: (() => void) | undefined; constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, customBgFn?: (text: string) => string) { this.text = text; @@ -24,24 +26,48 @@ export class Text implements Component { setText(text: string): void { this.text = text; - this.cachedText = undefined; - this.cachedWidth = undefined; - this.cachedLines = undefined; + this.invalidateRenderCache(); + this.markRenderInvalidated(); } setCustomBgFn(customBgFn?: (text: string) => string): void { this.customBgFn = customBgFn; - this.cachedText = undefined; - this.cachedWidth = undefined; - this.cachedLines = undefined; + this.invalidateRenderCache(); + this.markRenderInvalidated(); } invalidate(): void { + this.invalidateRenderCache(); + this.markRenderInvalidated(); + } + + getRenderRevision(): number { + return this.renderRevision; + } + + getRenderChangeStart(): number { + return 0; + } + + setRenderInvalidationCallback(callback: (() => void) | undefined): void { + this.renderInvalidationCallback = callback; + } + + isRenderCacheTrackable(): boolean { + return true; + } + + private invalidateRenderCache(): void { this.cachedText = undefined; this.cachedWidth = undefined; this.cachedLines = undefined; } + private markRenderInvalidated(): void { + this.renderRevision++; + this.renderInvalidationCallback?.(); + } + render(width: number): string[] { // Check cache if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) { diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 47bf3bc17..3137b7af4 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -77,6 +77,29 @@ export interface Component { */ render(width: number): string[]; + /** + * Optional monotonically increasing revision for components whose rendered + * lines can change. Parents use this to retain their own flattened line + * buffer without missing nested changes. + */ + getRenderRevision?(): number; + + /** + * First line changed by the latest render revision. The prefix before this + * line must be identical to the previous revision. + */ + getRenderChangeStart?(): number; + + /** + * Register the parent callback used by revision-aware render caches. A + * component must implement this together with `getRenderRevision()` and + * `isRenderCacheTrackable()` before a parent may skip calling `render()`. + */ + setRenderInvalidationCallback?(callback: (() => void) | undefined): void; + + /** Whether state changes are guaranteed to notify the registered parent. */ + isRenderCacheTrackable?(): boolean; + /** * Optional handler for keyboard input when component has focus */ @@ -416,72 +439,328 @@ type TuiConstructorOptions = { /** * Container - a component that contains other components */ +interface ComponentInvalidationSubscriptions { + callbacks: Map void>; + dispatch: () => void; +} + +const componentInvalidationSubscriptions = new WeakMap(); + export class Container implements Component { - children: Component[] = []; + private readonly childComponents: Component[] = []; private disposed = false; + private containerRenderCache?: { + width: number; + children: Component[]; + childLines: string[][]; + childRevisions: Array; + childTrackable: boolean[]; + lines: string[]; + }; + private renderRevision = 0; + private renderChangeStart = 0; + private renderInvalidatedSinceRender = false; + private renderInvalidationCallback: (() => void) | undefined; + private suppressChildInvalidation = false; + private readonly childBindingCounts = new Map(); + private readonly childTrackability = new Map(); + private readonly childRenderOffsets = new Map(); + private untrackableChildCount = 0; + + /** Read-only child view. Use the mutation methods so render invalidation stays connected. */ + get children(): readonly Component[] { + return this.childComponents; + } addChild(component: Component): void { - this.children.push(component); + this.bindChild(component); + this.childComponents.push(component); + this.markRenderInvalidated(); + } + + insertChild(index: number, component: Component): void { + const safeIndex = Math.max(0, Math.min(this.childComponents.length, Math.trunc(index))); + this.bindChild(component); + this.childComponents.splice(safeIndex, 0, component); + this.markRenderInvalidated(); + } + + replaceChild(current: Component, replacement: Component): boolean { + const index = this.childComponents.indexOf(current); + if (index === -1) return false; + if (current === replacement) return true; + this.bindChild(replacement); + this.unbindChild(current); + this.childComponents[index] = replacement; + this.markRenderInvalidated(); + return true; + } + + detachChildrenFrom(index: number): Component[] { + const safeIndex = Math.max(0, Math.min(this.childComponents.length, Math.trunc(index))); + const detached = this.childComponents.splice(safeIndex); + for (const child of detached) this.unbindChild(child); + if (detached.length > 0) this.markRenderInvalidated(); + return detached; } removeChild(component: Component): void { - const index = this.children.indexOf(component); + const index = this.childComponents.indexOf(component); if (index !== -1) { - this.children.splice(index, 1); + this.childComponents.splice(index, 1); + this.unbindChild(component); component.dispose?.(); + this.markRenderInvalidated(); } } detachChild(component: Component): void { - const index = this.children.indexOf(component); + const index = this.childComponents.indexOf(component); if (index !== -1) { - this.children.splice(index, 1); + this.childComponents.splice(index, 1); + this.unbindChild(component); + this.markRenderInvalidated(); } } clear(): void { - for (const child of this.children) { + for (const child of this.childComponents) { + this.unbindChild(child); child.dispose?.(); } - this.children = []; + if (this.childComponents.length > 0) this.markRenderInvalidated(); + this.childComponents.length = 0; } detachAll(): void { - this.children = []; + for (const child of this.childComponents) this.unbindChild(child); + if (this.childComponents.length > 0) this.markRenderInvalidated(); + this.childComponents.length = 0; } dispose(): void { if (this.disposed) return; this.disposed = true; - for (const child of this.children) { + for (const child of this.childComponents) { + this.unbindChild(child); child.dispose?.(); } } invalidate(): void { - for (const child of this.children) { - child.invalidate?.(); + this.containerRenderCache = undefined; + this.suppressChildInvalidation = true; + try { + for (const child of this.childComponents) { + child.invalidate?.(); + } + } finally { + this.suppressChildInvalidation = false; + this.markRenderInvalidated(); } } + getRenderRevision(): number { + return this.renderRevision; + } + + getRenderChangeStart(): number { + return this.renderChangeStart; + } + + setRenderInvalidationCallback(callback: (() => void) | undefined): void { + this.renderInvalidationCallback = callback; + } + + isRenderCacheTrackable(): boolean { + return this.untrackableChildCount === 0; + } + render(width: number): string[] { - const lines: string[] = []; - for (const child of this.children) { - let childLines: string[]; - try { - childLines = child.render(width); - } catch (error) { - logRenderErrorOnce(child, error); - const componentName = componentRenderErrorName(child); - // Focus ownership stays unchanged; render containment must not steal or clear focus implicitly. - childLines = [`[render error: ${componentName}]`]; + const cached = this.containerRenderCache; + if (!cached || cached.width !== width) return this.buildRenderCache(width); + if (!this.renderInvalidatedSinceRender && this.isRenderCacheTrackable()) return cached.lines; + + let firstChangedIndex = -1; + let firstChangedLocalLine = 0; + let firstChangedGlobalLine = 0; + let nextOffset = 0; + this.childRenderOffsets.clear(); + + for (let index = 0; index < this.childComponents.length; index++) { + const child = this.childComponents[index]!; + if (!this.childRenderOffsets.has(child)) this.childRenderOffsets.set(child, nextOffset); + const previousChild = cached.children[index]; + const previousLines = cached.childLines[index]; + const previousRevision = cached.childRevisions[index]; + const previousTrackable = cached.childTrackable[index] ?? false; + const childTrackable = this.isChildTrackable(child); + const revisionBeforeRender = child.getRenderRevision?.(); + const canReuse = + previousChild === child && previousTrackable && childTrackable && previousRevision === revisionBeforeRender; + const childLines = canReuse ? previousLines! : this.renderChild(child, width); + const childRevision = child.getRenderRevision?.(); + + if ( + firstChangedIndex === -1 && + (previousChild !== child || previousLines !== childLines || previousRevision !== childRevision) + ) { + firstChangedIndex = index; + if (previousChild === child && previousRevision !== childRevision) { + firstChangedLocalLine = Math.max( + 0, + Math.min(child.getRenderChangeStart?.() ?? 0, previousLines?.length ?? 0, childLines.length), + ); + } + firstChangedGlobalLine = nextOffset + firstChangedLocalLine; } - for (const line of childLines) { - lines.push(line); + + cached.children[index] = child; + cached.childLines[index] = childLines; + cached.childRevisions[index] = childRevision; + cached.childTrackable[index] = childTrackable; + nextOffset += childLines.length; + } + + if (firstChangedIndex === -1 && cached.children.length !== this.childComponents.length) { + firstChangedIndex = this.childComponents.length; + firstChangedGlobalLine = nextOffset; + } + + cached.children.length = this.childComponents.length; + cached.childLines.length = this.childComponents.length; + cached.childRevisions.length = this.childComponents.length; + cached.childTrackable.length = this.childComponents.length; + + if (firstChangedIndex === -1) { + this.markRenderCompleted(cached.lines.length); + return cached.lines; + } + + const lines = cached.lines.slice(0, firstChangedGlobalLine); + for (let index = firstChangedIndex; index < cached.childLines.length; index++) { + const childLines = cached.childLines[index]!; + const start = index === firstChangedIndex ? firstChangedLocalLine : 0; + for (let lineIndex = start; lineIndex < childLines.length; lineIndex++) { + lines.push(childLines[lineIndex]!); } } + cached.lines = lines; + this.markRenderCompleted(firstChangedGlobalLine); return lines; } + + private buildRenderCache(width: number): string[] { + const children = [...this.childComponents]; + const childLines: string[][] = []; + const childRevisions: Array = []; + const childTrackable: boolean[] = []; + const lines: string[] = []; + this.childRenderOffsets.clear(); + for (const child of children) { + if (!this.childRenderOffsets.has(child)) this.childRenderOffsets.set(child, lines.length); + const rendered = this.renderChild(child, width); + childLines.push(rendered); + childRevisions.push(child.getRenderRevision?.()); + childTrackable.push(this.isChildTrackable(child)); + for (const line of rendered) lines.push(line); + } + this.containerRenderCache = { width, children, childLines, childRevisions, childTrackable, lines }; + this.markRenderCompleted(0); + return lines; + } + + protected markRenderInvalidated(changeStart = 0): void { + this.renderRevision++; + const normalizedChangeStart = Math.max(0, Math.trunc(changeStart)); + this.renderChangeStart = this.renderInvalidatedSinceRender + ? Math.min(this.renderChangeStart, normalizedChangeStart) + : normalizedChangeStart; + this.renderInvalidatedSinceRender = true; + this.renderInvalidationCallback?.(); + } + + /** Record the first changed line and close the current invalidation window. */ + protected markRenderCompleted(changeStart: number): void { + this.renderChangeStart = Math.max(0, Math.trunc(changeStart)); + this.renderInvalidatedSinceRender = false; + } + + private bindChild(child: Component): void { + if (child === this) throw new Error("A Container cannot contain itself"); + const bindingCount = this.childBindingCounts.get(child) ?? 0; + this.childBindingCounts.set(child, bindingCount + 1); + if (bindingCount > 0) return; + const trackable = this.isChildTrackable(child); + this.childTrackability.set(child, trackable); + if (!trackable) this.untrackableChildCount++; + if (typeof child.setRenderInvalidationCallback !== "function") return; + const callback = () => { + const wasTrackable = this.childTrackability.get(child) ?? false; + const isTrackable = this.isChildTrackable(child); + if (wasTrackable !== isTrackable) { + this.childTrackability.set(child, isTrackable); + this.untrackableChildCount += isTrackable ? -1 : 1; + } + if (!this.suppressChildInvalidation) { + const childOffset = this.childRenderOffsets.get(child) ?? 0; + this.markRenderInvalidated(childOffset + Math.max(0, child.getRenderChangeStart?.() ?? 0)); + } + }; + const subscriptions = componentInvalidationSubscriptions.get(child); + if (subscriptions) { + subscriptions.callbacks.set(this, callback); + return; + } + const callbacks = new Map void>([[this, callback]]); + const nextSubscriptions: ComponentInvalidationSubscriptions = { + callbacks, + dispatch: () => { + for (const subscribedCallback of callbacks.values()) subscribedCallback(); + }, + }; + child.setRenderInvalidationCallback(nextSubscriptions.dispatch); + componentInvalidationSubscriptions.set(child, nextSubscriptions); + } + + private unbindChild(child: Component): void { + const bindingCount = this.childBindingCounts.get(child) ?? 0; + if (bindingCount === 0) return; + if (bindingCount > 1) { + this.childBindingCounts.set(child, bindingCount - 1); + return; + } + this.childBindingCounts.delete(child); + const trackable = this.childTrackability.get(child); + if (trackable === false) this.untrackableChildCount--; + this.childTrackability.delete(child); + this.childRenderOffsets.delete(child); + const subscriptions = componentInvalidationSubscriptions.get(child); + if (!subscriptions) return; + subscriptions.callbacks.delete(this); + if (subscriptions.callbacks.size > 0) return; + child.setRenderInvalidationCallback?.(undefined); + componentInvalidationSubscriptions.delete(child); + } + + private isChildTrackable(child: Component): boolean { + return ( + typeof child.getRenderRevision === "function" && + typeof child.setRenderInvalidationCallback === "function" && + child.isRenderCacheTrackable?.() === true + ); + } + + private renderChild(child: Component, width: number): string[] { + try { + return child.render(width); + } catch (error) { + logRenderErrorOnce(child, error); + const componentName = componentRenderErrorName(child); + // Focus ownership stays unchanged; render containment must not steal or clear focus implicitly. + return [`[render error: ${componentName}]`]; + } + } } /** diff --git a/packages/tui/test/chat-simple.ts b/packages/tui/test/chat-simple.ts index 03851e98b..7e6ac4172 100644 --- a/packages/tui/test/chat-simple.ts +++ b/packages/tui/test/chat-simple.ts @@ -56,23 +56,22 @@ editor.onSubmit = (value: string) => { // Handle slash commands if (trimmed === "/delete") { - const children = tui.children; // Remove component before editor (if there are any besides the initial text) - if (children.length > 3) { + if (tui.children.length > 3) { // children[0] = "Welcome to Simple Chat!" // children[1] = "Type your messages below..." // children[2...n-1] = messages // children[n] = editor - children.splice(children.length - 2, 1); + const message = tui.children.at(-2); + if (message) tui.removeChild(message); } tui.requestRender(); return; } if (trimmed === "/clear") { - const children = tui.children; // Remove all messages but keep the welcome text and editor - children.splice(2, children.length - 3); + for (const message of tui.children.slice(2, -1)) tui.removeChild(message); tui.requestRender(); return; } @@ -83,8 +82,7 @@ editor.onSubmit = (value: string) => { const userMessage = new Markdown(value, 1, 1, defaultMarkdownTheme); - const children = tui.children; - children.splice(children.length - 1, 0, userMessage); + tui.insertChild(tui.children.length - 1, userMessage); const loader = new Loader( tui, @@ -92,7 +90,7 @@ editor.onSubmit = (value: string) => { (s) => chalk.dim(s), "Thinking...", ); - children.splice(children.length - 1, 0, loader); + tui.insertChild(tui.children.length - 1, loader); tui.requestRender(); @@ -114,7 +112,7 @@ editor.onSubmit = (value: string) => { // Add assistant message with no background (transparent) const botMessage = new Markdown(randomResponse, 1, 1, defaultMarkdownTheme); - children.splice(children.length - 1, 0, botMessage); + tui.insertChild(tui.children.length - 1, botMessage); // Re-enable submit isResponding = false; diff --git a/packages/tui/test/container-render-cache.test.ts b/packages/tui/test/container-render-cache.test.ts new file mode 100644 index 000000000..029055d57 --- /dev/null +++ b/packages/tui/test/container-render-cache.test.ts @@ -0,0 +1,176 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { type Component, Container, ScrollView, Text } from "../src/index.ts"; + +class StableInvalidatingComponent implements Component { + private readonly lines = ["stable"]; + private revision = 0; + private onInvalidated: (() => void) | undefined; + + render(): string[] { + return this.lines; + } + + getRenderRevision(): number { + return this.revision; + } + + getRenderChangeStart(): number { + return 0; + } + + setRenderInvalidationCallback(callback: (() => void) | undefined): void { + this.onInvalidated = callback; + } + + isRenderCacheTrackable(): boolean { + return true; + } + + invalidate(): void { + this.revision++; + this.onInvalidated?.(); + } + + notifyUnchanged(): void { + this.onInvalidated?.(); + } +} + +describe("Container render cache", () => { + it("reuses the flattened transcript while its children are unchanged", () => { + const container = new Container(); + container.addChild(new Text("first", 0, 0)); + container.addChild(new Text("second", 0, 0)); + + const firstRender = container.render(20); + const secondRender = container.render(20); + + assert.strictEqual(secondRender, firstRender); + assert.deepEqual(secondRender, ["first ", "second "]); + }); + + it("updates only the changed tail and propagates that change through nested containers", () => { + const transcript = new Container(); + const historical = new Text("history", 0, 0); + const streamingTail = new Text("tail-1", 0, 0); + transcript.addChild(historical); + transcript.addChild(streamingTail); + + const document = new Container(); + document.addChild(new Text("header", 0, 0)); + document.addChild(transcript); + + const firstRender = document.render(20); + const firstRevision = document.getRenderRevision(); + streamingTail.setText("tail-2"); + const secondRender = document.render(20); + + assert.notStrictEqual(secondRender, firstRender); + assert.strictEqual(document.render(20), secondRender); + assert.equal(document.getRenderRevision(), firstRevision + 1); + assert.equal(document.getRenderChangeStart(), 2); + assert.deepEqual(secondRender, ["header ", "history ", "tail-2 "]); + }); + + it("handles appended and removed children without rebuilding the stable prefix", () => { + const container = new Container(); + container.addChild(new Text("one", 0, 0)); + const firstRender = container.render(10); + + const appended = new Text("two", 0, 0); + container.addChild(appended); + const appendedRender = container.render(10); + assert.notStrictEqual(appendedRender, firstRender); + assert.strictEqual(container.render(10), appendedRender); + assert.equal(container.getRenderChangeStart(), 1); + assert.deepEqual(appendedRender, ["one ", "two "]); + + container.detachChild(appended); + const removedRender = container.render(10); + assert.notStrictEqual(removedRender, firstRender); + assert.strictEqual(container.render(10), removedRender); + assert.equal(container.getRenderChangeStart(), 1); + assert.deepEqual(removedRender, ["one "]); + }); + + it("keeps repeated child references subscribed until their last occurrence is removed", () => { + const container = new Container(); + const repeated = new Text("one", 0, 0); + container.addChild(repeated); + container.addChild(repeated); + container.render(10); + + container.detachChild(repeated); + const once = container.render(10); + repeated.setText("two"); + const updated = container.render(10); + + assert.notStrictEqual(updated, once); + assert.deepEqual(updated, ["two "]); + }); + + it("fans shared-child invalidation out to every containing parent", () => { + const firstParent = new Container(); + const secondParent = new Container(); + const child = new Text("one", 0, 0); + firstParent.addChild(child); + firstParent.render(10); + secondParent.addChild(child); + secondParent.render(10); + + child.setText("two"); + assert.deepEqual(firstParent.render(10), ["two "]); + assert.deepEqual(secondParent.render(10), ["two "]); + + firstParent.detachChild(child); + const detachedRender = firstParent.render(10); + const detachedRevision = firstParent.getRenderRevision(); + child.setText("three"); + assert.equal(firstParent.getRenderRevision(), detachedRevision); + assert.strictEqual(firstParent.render(10), detachedRender); + assert.deepEqual(secondParent.render(10), ["three "]); + }); + + it("closes a clean invalidation window at the end of the cached output", () => { + const container = new Container(); + const child = new StableInvalidatingComponent(); + container.addChild(child); + const firstRender = container.render(10); + + child.notifyUnchanged(); + const cleanRender = container.render(10); + + assert.strictEqual(cleanRender, firstRender); + assert.equal(container.getRenderChangeStart(), cleanRender.length); + }); + + it("starts a new cache when the render width changes", () => { + const container = new Container(); + container.addChild(new Text("content", 0, 0)); + const narrow = container.render(10); + const wide = container.render(20); + + assert.notStrictEqual(wide, narrow); + assert.equal(container.getRenderChangeStart(), 0); + assert.deepEqual(wide, ["content "]); + }); + + it("reuses the fullscreen scrollbar projection and updates only its changed tail", () => { + const transcript = new Container(); + transcript.addChild(new Text("history", 0, 0)); + const streamingTail = new Text("tail-1", 0, 0); + transcript.addChild(streamingTail); + const scrollView = new ScrollView(transcript, { scrollbar: "always" }); + + const firstRender = scrollView.render(20); + assert.strictEqual(scrollView.render(20), firstRender); + streamingTail.setText("tail-2"); + const secondRender = scrollView.render(20); + + assert.notStrictEqual(secondRender, firstRender); + assert.strictEqual(scrollView.render(20), secondRender); + assert.equal(scrollView.getRenderChangeStart(), 1); + assert.deepEqual(secondRender, ["history ", "tail-2 "]); + }); +});