Skip to content
Closed
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
48 changes: 48 additions & 0 deletions src/tui/markdown-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
215 changes: 195 additions & 20 deletions src/tui/markdown-rows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,6 +28,49 @@ const shellOpts = {
wireKeys: false,
} as const;

interface FrozenWork {
assignments: number;
chars: number;
}

function splitMarkdownNodes(
shell: ReturnType<typeof createAppShell>,
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
Expand Down Expand Up @@ -254,36 +298,167 @@ 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: [
"### Title",
"",
"Some body text that keeps growing and growing.",
].join("\n"),
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"),
});
});
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);

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("bridge keeps frozen assignment work sub-quadratic", async () => {
await withTestRenderer(async (h) => {
async function measure(size: number): Promise<FrozenWork> {
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");
try {
bridge.handle({
type: "assistant.delta",
text: `${frozen}\n\nx`,
});
await h.renderOnce();
const { frozenNode, liveNode } = 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();
const current = splitMarkdownNodes(shell);
expect(current.frozenNode).toBe(frozenNode);
expect(current.liveNode).toBe(liveNode);
expect(current.liveNode.streaming).toBe(true);
}
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");
}
return work;
} finally {
bridge.dispose();
shell.dispose();
}
}

const n = await measure(20);
const twoN = await measure(40);
expect(n).toEqual({ assignments: 0, chars: 0 });
expect(twoN).toEqual({ assignments: 0, chars: 0 });
}, WIDE);
});

Expand Down
57 changes: 57 additions & 0 deletions src/tui/runtime-bridge-coalesce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down
10 changes: 9 additions & 1 deletion src/tui/runtime-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
}

/**
Expand Down
Loading
Loading