Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -143,7 +144,7 @@ export class ToolExecutionComponent extends Container {
}

override invalidate(): void {
this.invalidateRenderCache();
this.invalidateRenderCache(false);
super.invalidate();
this.lastDisplaySignature = undefined;
this.updateDisplay();
Expand All @@ -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[];
Expand All @@ -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;
}

Expand Down Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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;
}
}
Expand Down
4 changes: 4 additions & 0 deletions packages/tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions packages/tui/src/changes.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading