From e50c57f148fad6c897266a6b8609cc1dae0d5901 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 10:26:15 -0700 Subject: [PATCH 1/3] perf(tui): avoid reparsing frozen streaming markdown --- src/tui/markdown-parser.ts | 48 ++++++ src/tui/markdown-rows.test.ts | 186 +++++++++++++++++++++--- src/tui/runtime-bridge-coalesce.test.ts | 57 ++++++++ src/tui/runtime-bridge.ts | 10 +- src/tui/shell/transcript.ts | 64 +++++++- 5 files changed, 342 insertions(+), 23 deletions(-) diff --git a/src/tui/markdown-parser.ts b/src/tui/markdown-parser.ts index 53acdba29..796b61a09 100644 --- a/src/tui/markdown-parser.ts +++ b/src/tui/markdown-parser.ts @@ -854,3 +854,51 @@ export function splitAtSettledHeading(text: string): MarkdownSplit | null { gapRows: firstContent > 0 ? 1 : 0, }; } + +/** + * Per-row memory for incremental streaming markdown. The transcript keeps one + * of these per split body node: what full content it was last painted with, + * what frozen half it was last handed, and the width and streaming flag behind + * that paint. + */ +export interface StreamMarkdownState { + readonly content: string; + readonly frozen: string | null; + readonly width: number; + readonly streaming: boolean; +} + +export interface StreamMarkdownTransition { + readonly state: StreamMarkdownState; + /** + * False when the frozen half is unchanged and must not be reassigned: handing + * a `MarkdownRenderable` the text it already holds still re-parses it, so an + * append-only delta must touch the live tail only. + */ + readonly paintFrozen: boolean; +} + +/** + * Decide what a streaming retext must repaint. Append-only growth with an + * unchanged frozen half skips the frozen assignment entirely; anything else — + * a first paint, a non-append edit (the new content no longer extends the + * old), a width change, a streaming-flag flip, or a moved freeze boundary — + * repaints the frozen half too. The live tail is always the caller's to paint. + */ +export function nextStreamMarkdownState( + prev: StreamMarkdownState | null, + content: string, + split: MarkdownSplit | null, + width: number, + streaming: boolean, +): StreamMarkdownTransition { + const frozen = split === null ? null : split.frozen; + const append = prev !== null && content.startsWith(prev.content); + const paintFrozen = + prev === null || + !append || + width !== prev.width || + streaming !== prev.streaming || + frozen !== prev.frozen; + return { state: { content, frozen, width, streaming }, paintFrozen }; +} diff --git a/src/tui/markdown-rows.test.ts b/src/tui/markdown-rows.test.ts index bce49270a..d32abfdaf 100644 --- a/src/tui/markdown-rows.test.ts +++ b/src/tui/markdown-rows.test.ts @@ -27,6 +27,49 @@ const shellOpts = { wireKeys: false, } as const; +interface FrozenWork { + assignments: number; + chars: number; +} + +function splitMarkdownNodes( + shell: ReturnType, + index = 0, +) { + const rowNode = defined(shell.transcript.getChildren().slice(1)[index]); + const [, bodyNode] = (rowNode as BoxRenderable).getChildren(); + const [frozenNode, liveNode] = (bodyNode as BoxRenderable).getChildren(); + return { + frozenNode: frozenNode as MarkdownRenderable, + liveNode: liveNode as MarkdownRenderable, + }; +} + +function observeFrozenAssignments( + node: MarkdownRenderable, + run: () => void, +): FrozenWork { + const descriptor = defined( + Object.getOwnPropertyDescriptor(MarkdownRenderable.prototype, "content"), + ); + const work: FrozenWork = { assignments: 0, chars: 0 }; + Object.defineProperty(node, "content", { + configurable: true, + get: () => descriptor.get?.call(node), + set: (value: string) => { + work.assignments += 1; + work.chars += value.length; + descriptor.set?.call(node, value); + }, + }); + try { + run(); + } finally { + Reflect.deleteProperty(node, "content"); + } + return work; +} + /** * Highlighting runs on a worker outside the render scheduler, so the * scheduler goes idle before the highlighted frame lands. Pass a predicate @@ -254,36 +297,141 @@ describe("markdown transcript rows", () => { }, WIDE); }); - test("the settled heading renderer is never rewritten while the prose after it keeps streaming", async () => { + test("append-only prose never reassigns the settled heading", async () => { await withTestRenderer(async (h) => { const shell = createAppShell(h.renderer, shellOpts); + const prefix = ["### Title", "", "Some"].join("\n"); appendStreamRow(shell, { role: "assistant", streaming: true, - text: ["### Title", "", "Some"].join("\n"), + text: prefix, + }); + const { frozenNode, liveNode } = splitMarkdownNodes(shell); + const work = observeFrozenAssignments(frozenNode, () => { + replaceStreamRowAt(shell, shell.streamLog.length - 1, { + role: "assistant", + streaming: true, + text: `${prefix} body text that keeps growing.`, + }); }); - const children = shell.transcript.getChildren().slice(1); - const [, bodyNode] = (children[0] as BoxRenderable).getChildren(); - const [frozenNode] = (bodyNode as BoxRenderable).getChildren(); - const before = (frozenNode as MarkdownRenderable).content; - replaceStreamRowAt(shell, shell.streamLog.length - 1, { + expect(work).toEqual({ assignments: 0, chars: 0 }); + expect(liveNode.content).toBe("Some body text that keeps growing."); + }, WIDE); + }); + + test("a mid-stream edit repaints the frozen half", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, shellOpts); + appendStreamRow(shell, { role: "assistant", streaming: true, - text: [ + text: ["### Title", "", "original tail"].join("\n"), + }); + const { frozenNode, liveNode } = splitMarkdownNodes(shell); + const work = observeFrozenAssignments(frozenNode, () => { + replaceStreamRowAt(shell, shell.streamLog.length - 1, { + role: "assistant", + streaming: true, + text: ["### Title", "", "edited tail"].join("\n"), + }); + }); + + expect(work).toEqual({ assignments: 1, chars: "### Title".length }); + expect(liveNode.content).toBe("edited tail"); + }, WIDE); + }); + + test("a newly settled heading moves and repaints the frozen boundary", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, shellOpts); + const initial = ["### First", "", "first tail"].join("\n"); + const moved = [initial, "", "### Second", "", "second tail"].join("\n"); + appendStreamRow(shell, { + role: "assistant", + streaming: true, + text: initial, + }); + const { frozenNode, liveNode } = splitMarkdownNodes(shell); + const work = observeFrozenAssignments(frozenNode, () => { + replaceStreamRowAt(shell, shell.streamLog.length - 1, { + role: "assistant", + streaming: true, + text: moved, + }); + }); + + const expectedFrozen = [initial, "", "### Second"].join("\n"); + expect(work).toEqual({ + assignments: 1, + chars: expectedFrozen.length, + }); + expect(frozenNode.content).toBe(expectedFrozen); + expect(liveNode.content).toBe("second tail"); + }, WIDE); + }); + + test("a fenced pseudo-heading does not move the frozen boundary", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, shellOpts); + const initial = ["### Title", "", "```bash", "# shell comment"].join( + "\n", + ); + appendStreamRow(shell, { + role: "assistant", + streaming: true, + text: initial, + }); + const { frozenNode, liveNode } = splitMarkdownNodes(shell); + const work = observeFrozenAssignments(frozenNode, () => { + replaceStreamRowAt(shell, shell.streamLog.length - 1, { + role: "assistant", + streaming: true, + text: `${initial}\necho done`, + }); + }); + + expect(work).toEqual({ assignments: 0, chars: 0 }); + expect(liveNode.content).toContain("# shell comment\necho done"); + }, WIDE); + }); + + test("frozen assignment work stays constant when stream size doubles", async () => { + await withTestRenderer(async (h) => { + function measure(size: number): FrozenWork { + const shell = createAppShell(h.renderer, shellOpts); + const frozen = [ + ...Array.from({ length: size }, (_, i) => `settled line ${i}`), "### Title", - "", - "Some body text that keeps growing and growing.", - ].join("\n"), + ].join("\n"); + const initial = `${frozen}\n\nx`; + appendStreamRow(shell, { + role: "assistant", + streaming: true, + text: initial, + }); + const { frozenNode } = splitMarkdownNodes(shell); + const work = observeFrozenAssignments(frozenNode, () => { + for (let i = 1; i <= size; i += 1) { + replaceStreamRowAt(shell, shell.streamLog.length - 1, { + role: "assistant", + streaming: true, + text: `${initial}${"x".repeat(i)}`, + }); + } + }); + shell.dispose(); + return work; + } + + const n = measure(20); + const twoN = measure(40); + const ratio = (twoN.chars + 1) / (n.chars + 1); + expect({ n, twoN, ratio }).toEqual({ + n: { assignments: 0, chars: 0 }, + twoN: { assignments: 0, chars: 0 }, + ratio: 1, }); - const childrenAfter = shell.transcript.getChildren().slice(1); - const [, bodyNodeAfter] = ( - childrenAfter[0] as BoxRenderable - ).getChildren(); - const [frozenNodeAfter] = (bodyNodeAfter as BoxRenderable).getChildren(); - - expect(frozenNodeAfter).toBe(frozenNode); - expect((frozenNodeAfter as MarkdownRenderable).content).toBe(before); }, WIDE); }); diff --git a/src/tui/runtime-bridge-coalesce.test.ts b/src/tui/runtime-bridge-coalesce.test.ts index d74971298..a5e4c3da2 100644 --- a/src/tui/runtime-bridge-coalesce.test.ts +++ b/src/tui/runtime-bridge-coalesce.test.ts @@ -4,10 +4,13 @@ * close/settle seam must apply the accumulated tail exactly. */ import { describe, expect, test } from "bun:test"; +import { BoxRenderable, MarkdownRenderable } from "@opentui/core"; import { attachSessionBridge, createRecordingPort } from "./runtime-bridge"; +import { replaceStreamRowAt } from "./shell/chrome"; import { createAppShell } from "./shell/index"; import { streamRowAt, streamRowCount } from "./shell/transcript"; import { withTestRenderer } from "./harness"; +import { defined } from "../../tests/helpers/defined.js"; import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js"; import type { AppShell } from "./shell/internals.js"; import type { StreamRow } from "./stream.js"; @@ -89,6 +92,60 @@ describe("runtime-bridge stream row coalescing", () => { }); }); + test("closing a stream clears its frozen markdown paint state", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const bridge = attachSessionBridge(shell, createRecordingPort(), { + schedule: () => () => undefined, + }); + try { + const text = ["### Title", "", "body"].join("\n"); + bridge.handle({ type: "assistant.delta", text }); + await h.renderOnce(); + const rowNode = defined(shell.transcript.getChildren().slice(1)[0]); + const [, bodyNode] = (rowNode as BoxRenderable).getChildren(); + const [frozenNode] = (bodyNode as BoxRenderable).getChildren(); + const markdown = frozenNode as MarkdownRenderable; + const descriptor = defined( + Object.getOwnPropertyDescriptor( + MarkdownRenderable.prototype, + "content", + ), + ); + let assignments = 0; + Object.defineProperty(markdown, "content", { + configurable: true, + get: () => descriptor.get?.call(markdown), + set: (value: string) => { + assignments += 1; + descriptor.set?.call(markdown, value); + }, + }); + try { + bridge.handle({ type: "system", text: "done" }); + expect(assignments).toBe(1); + replaceStreamRowAt(shell, 0, { + role: "assistant", + text, + }); + expect(assignments).toBe(2); + } finally { + Reflect.deleteProperty(markdown, "content"); + } + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + test("thinking deltas coalesce the same way and flush their tail on close", async () => { await withCountedReplaceStreamRowAt(async (calls) => { await withTestRenderer( diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 4fff15cda..731e88f4f 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -34,7 +34,11 @@ import { type AppShell, } from "./shell/internals.js"; import { applyShellInterrupt, surfaceSystemNotice } from "./shell/prompt.js"; -import { streamRowAt, streamRowCount } from "./shell/transcript.js"; +import { + dropStreamMarkdownState, + streamRowAt, + streamRowCount, +} from "./shell/transcript.js"; import { rampAnimating } from "./ramp.js"; import { OPERATOR_ORIGINATED_FLAG } from "../agent/message-provenance.js"; import { onTurnBoundary } from "../agent/reactor-events.js"; @@ -868,6 +872,10 @@ function closeOpenRow(shell: AppShell, bag: BridgeBag): void { open.index, openRowContent(open.kind, open.text, false, thought), ); + // The seam paint above finalized the row; its streaming-markdown memory must + // not outlive the stream. Coalescing is untouched — this runs once per close, + // never per delta or per frame. + dropStreamMarkdownState(shell, open.index); } /** diff --git a/src/tui/shell/transcript.ts b/src/tui/shell/transcript.ts index 1d89f02f5..450b1cd0d 100644 --- a/src/tui/shell/transcript.ts +++ b/src/tui/shell/transcript.ts @@ -16,8 +16,10 @@ import { armLinkLine, buildLinkLine, paintLinkLine } from "../url-links.js"; import { findLinks, splitLinkSpans } from "../link-spans.js"; import { splitWrappedLinkSpans } from "../link-wrap.js"; import { + nextStreamMarkdownState, splitAtSettledHeading, withholdIncompleteHeading, + type StreamMarkdownState, } from "../markdown-parser.js"; import { diffLineChunks, retextStyledKindRow } from "./row-retext.js"; import { @@ -159,6 +161,38 @@ export function transcriptRowOffset(shell: AppShell): number { return transcriptMarker(shell) === undefined ? 1 : 2; } +/** + * What a split markdown body was last painted with, per body node. Keying by + * node (not row index) keeps the memory correct across retention trims and + * window rebuilds: a rebuilt row gets fresh nodes, so it can never inherit a + * stale "already painted" claim. Entries die with their nodes; the bridge + * drops the open row's entry at its close/settle seam so the memory never + * outlives the stream either. + */ +const streamMarkdownStates = new WeakMap(); + +function dropStreamMarkdownStateForNode(node: BaseRenderable): void { + streamMarkdownStates.delete(node); + if (node instanceof BoxRenderable) { + for (const child of node.getChildren()) { + dropStreamMarkdownStateForNode(child); + } + } +} + +/** + * Forget a row's streaming-markdown memory at its close/settle seam. The seam + * paint already finalized the row, so the next repaint (if any) simply paints + * it whole again; dropping here bounds the memory to rows still streaming. + * No-op for evicted rows and outside the main transcript. + */ +export function dropStreamMarkdownState(shell: AppShell, index: number): void { + if (shell.observe !== null) return; + const node = transcriptRowChildren(shell)[index - shell.streamLogBase]; + if (node === undefined) return; + dropStreamMarkdownStateForNode(node); +} + /** * Rewrite a row's body on its existing paint node. * @@ -240,12 +274,28 @@ function retextStreamRowBody( ) { return false; } + // Incremental freeze: an append-only delta whose frozen half has not moved + // leaves the frozen renderer untouched (reassigning it would re-parse and + // re-highlight settled text); the live tail always repaints. Anything else + // — first paint, mid-stream edit, resize, settle, moved boundary — repaints + // both halves. bodyNode.width = width; - frozenNode.width = width; - frozenNode.content = split.frozen; + const streaming = row.streaming === true; + const transition = nextStreamMarkdownState( + streamMarkdownStates.get(bodyNode) ?? null, + content, + split, + width, + streaming, + ); + streamMarkdownStates.set(bodyNode, transition.state); + if (transition.paintFrozen) { + frozenNode.width = width; + frozenNode.content = split.frozen; + } liveNode.width = width; liveNode.content = split.live; - liveNode.streaming = row.streaming === true; + liveNode.streaming = streaming; liveNode.marginTop = split.gapRows; return true; } @@ -487,6 +537,14 @@ function createMarkdownBody( marginTop: split.gapRows, }), ); + // Seed the incremental-freeze memory the retext path reads: the first + // retext after this build skips the frozen half unless the boundary moved. + streamMarkdownStates.set(column, { + content, + frozen: split.frozen, + width, + streaming: row.streaming === true, + }); return column; } From 5d34aafe31240a961f71c19204ce92bdd4fe285f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 10:40:03 -0700 Subject: [PATCH 2/3] test(tui): exercise frozen markdown perf through bridge --- src/tui/markdown-rows.test.ts | 78 +++++++++++++++++++++++------------ 1 file changed, 52 insertions(+), 26 deletions(-) diff --git a/src/tui/markdown-rows.test.ts b/src/tui/markdown-rows.test.ts index d32abfdaf..6d4463b23 100644 --- a/src/tui/markdown-rows.test.ts +++ b/src/tui/markdown-rows.test.ts @@ -18,6 +18,7 @@ import { } from "./shell/chrome"; import { createAppShell } from "./shell/index"; import { splitAtSettledHeading } from "./markdown-parser"; +import { attachSessionBridge, createRecordingPort } from "./runtime-bridge"; import { isMarkdownRow } from "./stream"; const WIDE = { width: 80, height: 24 } as const; @@ -396,42 +397,67 @@ describe("markdown transcript rows", () => { }, WIDE); }); - test("frozen assignment work stays constant when stream size doubles", async () => { + test("bridge keeps frozen assignment work sub-quadratic", async () => { await withTestRenderer(async (h) => { - function measure(size: number): FrozenWork { + async function measure(size: number): Promise { const shell = createAppShell(h.renderer, shellOpts); + const bridge = attachSessionBridge(shell, createRecordingPort(), { + schedule: () => () => undefined, + }); const frozen = [ ...Array.from({ length: size }, (_, i) => `settled line ${i}`), "### Title", ].join("\n"); - const initial = `${frozen}\n\nx`; - appendStreamRow(shell, { - role: "assistant", - streaming: true, - text: initial, - }); - const { frozenNode } = splitMarkdownNodes(shell); - const work = observeFrozenAssignments(frozenNode, () => { - for (let i = 1; i <= size; i += 1) { - replaceStreamRowAt(shell, shell.streamLog.length - 1, { - role: "assistant", - streaming: true, - text: `${initial}${"x".repeat(i)}`, - }); + try { + bridge.handle({ + type: "assistant.delta", + text: `${frozen}\n\nx`, + }); + await h.renderOnce(); + const { frozenNode } = splitMarkdownNodes(shell); + const descriptor = defined( + Object.getOwnPropertyDescriptor( + MarkdownRenderable.prototype, + "content", + ), + ); + const work: FrozenWork = { assignments: 0, chars: 0 }; + Object.defineProperty(frozenNode, "content", { + configurable: true, + get: () => descriptor.get?.call(frozenNode), + set: (value: string) => { + work.assignments += 1; + work.chars += value.length; + descriptor.set?.call(frozenNode, value); + }, + }); + try { + for (let i = 0; i < size; i += 1) { + bridge.handle({ type: "assistant.delta", text: "x" }); + await h.renderOnce(); + expect(splitMarkdownNodes(shell).frozenNode).toBe(frozenNode); + } + expect(splitMarkdownNodes(shell).liveNode.content).toBe( + "x".repeat(size + 1), + ); + } finally { + Reflect.deleteProperty(frozenNode, "content"); } - }); - shell.dispose(); - return work; + return work; + } finally { + bridge.dispose(); + shell.dispose(); + } } - const n = measure(20); - const twoN = measure(40); + const n = await measure(20); + const twoN = await measure(40); const ratio = (twoN.chars + 1) / (n.chars + 1); - expect({ n, twoN, ratio }).toEqual({ - n: { assignments: 0, chars: 0 }, - twoN: { assignments: 0, chars: 0 }, - ratio: 1, - }); + const headingTransitions = 0; + const maxAssignments = headingTransitions + 1; + expect(ratio).toBeLessThan(3); + expect(n.assignments).toBeLessThanOrEqual(maxAssignments); + expect(twoN.assignments).toBeLessThanOrEqual(maxAssignments); }, WIDE); }); From 31d9d5a8d2deb0a9103ceec1c6da3295d3c5ae6b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 10:55:58 -0700 Subject: [PATCH 3/3] test(tui): enforce bridge frozen-row invariants --- src/tui/markdown-rows.test.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/tui/markdown-rows.test.ts b/src/tui/markdown-rows.test.ts index 6d4463b23..d29b9f740 100644 --- a/src/tui/markdown-rows.test.ts +++ b/src/tui/markdown-rows.test.ts @@ -414,7 +414,7 @@ describe("markdown transcript rows", () => { text: `${frozen}\n\nx`, }); await h.renderOnce(); - const { frozenNode } = splitMarkdownNodes(shell); + const { frozenNode, liveNode } = splitMarkdownNodes(shell); const descriptor = defined( Object.getOwnPropertyDescriptor( MarkdownRenderable.prototype, @@ -435,11 +435,16 @@ describe("markdown transcript rows", () => { for (let i = 0; i < size; i += 1) { bridge.handle({ type: "assistant.delta", text: "x" }); await h.renderOnce(); - expect(splitMarkdownNodes(shell).frozenNode).toBe(frozenNode); + const current = splitMarkdownNodes(shell); + expect(current.frozenNode).toBe(frozenNode); + expect(current.liveNode).toBe(liveNode); + expect(current.liveNode.streaming).toBe(true); } - expect(splitMarkdownNodes(shell).liveNode.content).toBe( - "x".repeat(size + 1), - ); + const current = splitMarkdownNodes(shell); + expect(current.frozenNode).toBe(frozenNode); + expect(current.liveNode).toBe(liveNode); + expect(current.liveNode.content).toBe("x".repeat(size + 1)); + expect(current.liveNode.streaming).toBe(true); } finally { Reflect.deleteProperty(frozenNode, "content"); } @@ -452,12 +457,8 @@ describe("markdown transcript rows", () => { const n = await measure(20); const twoN = await measure(40); - const ratio = (twoN.chars + 1) / (n.chars + 1); - const headingTransitions = 0; - const maxAssignments = headingTransitions + 1; - expect(ratio).toBeLessThan(3); - expect(n.assignments).toBeLessThanOrEqual(maxAssignments); - expect(twoN.assignments).toBeLessThanOrEqual(maxAssignments); + expect(n).toEqual({ assignments: 0, chars: 0 }); + expect(twoN).toEqual({ assignments: 0, chars: 0 }); }, WIDE); });