diff --git a/src/tui/markdown-parser.test.ts b/src/tui/markdown-parser.test.ts index 71d95f69b..253e5b872 100644 --- a/src/tui/markdown-parser.test.ts +++ b/src/tui/markdown-parser.test.ts @@ -2,7 +2,9 @@ import { describe, expect, test } from "bun:test"; import { defined } from "../../testkit/defined.js"; import { createMemoizedParseMarkdown, + nextStreamMarkdownState, parseMarkdown, + splitAtSettledBlock, splitAtSettledHeading, withholdIncompleteHeading, type StyledSegment, @@ -567,3 +569,230 @@ describe("splitAtSettledHeading", () => { expect(splitAtSettledHeading("# Title")).toBeNull(); }); }); + +describe("splitAtSettledBlock", () => { + test("freezes through the latest settled block (heading, fence, table)", () => { + const lines = [ + "### Title", + "", + "```bash", + "echo hi", + "```", + "", + "| a | b |", + "|---|---|", + "| 1 | 2 |", + "", + "tail", + ]; + expect(splitAtSettledBlock(lines.join("\n"))).toEqual({ + frozen: lines.slice(0, 9).join("\n"), + live: "tail", + gapRows: 1, + }); + }); + + test("freezes a closed fence with prose after the closer", () => { + expect(splitAtSettledBlock("```bash\necho hi\n```\n\ntail")).toEqual({ + frozen: "```bash\necho hi\n```", + live: "tail", + gapRows: 1, + }); + }); + + test("supports tilde fences and indented openers up to three spaces", () => { + expect(splitAtSettledBlock("~~~\ncode\n~~~\n\ntail")).toEqual({ + frozen: "~~~\ncode\n~~~", + live: "tail", + gapRows: 1, + }); + expect(splitAtSettledBlock(" ```js\ncode\n ```\n\ntail")).toEqual({ + frozen: " ```js\ncode\n ```", + live: "tail", + gapRows: 1, + }); + }); + + test("a four-space indented fence is not a fence, so earlier text can settle", () => { + expect(splitAtSettledBlock("## T\n\n ```\nx")).toEqual({ + frozen: "## T", + live: " ```\nx", + gapRows: 1, + }); + }); + + test("an unclosed fence after settled text still leaves the settled split in place", () => { + expect(splitAtSettledBlock("### T\n\n```bash\necho hi")).toEqual({ + frozen: "### T", + live: "```bash\necho hi", + gapRows: 1, + }); + expect(splitAtSettledBlock("```bash\necho hi")).toBeNull(); + }); + + test("a closed block at the last line falls back to the earlier split", () => { + expect(splitAtSettledBlock("## T\n\n```\ncode\n```")).toEqual({ + frozen: "## T", + live: "```\ncode\n```", + gapRows: 1, + }); + expect(splitAtSettledBlock("## T\n\n| h |\n|---|\n| r |")).toEqual({ + frozen: "## T", + live: "| h |\n|---|\n| r |", + gapRows: 1, + }); + expect(splitAtSettledBlock("```\ncode\n```")).toBeNull(); + expect(splitAtSettledBlock("| h |\n|---|\n| r |")).toBeNull(); + }); + + test("freezes through a completed separator table, not just the heading", () => { + const lines = [ + "## T", + "", + "Name | Age", + "--- | ---", + "Ada | 36", + "", + "tail", + ]; + expect(splitAtSettledBlock(lines.join("\n"))).toEqual({ + frozen: lines.slice(0, 5).join("\n"), + live: "tail", + gapRows: 1, + }); + }); + + test("freezes through a completed borderless table", () => { + const lines = [ + "## T", + "", + "| h1 | h2 |", + "| r1 | r2 |", + "| r3 | r4 |", + "", + "tail", + ]; + expect(splitAtSettledBlock(lines.join("\n"))).toEqual({ + frozen: lines.slice(0, 5).join("\n"), + live: "tail", + gapRows: 1, + }); + }); + + test("a table that reaches the last line keeps the row live", () => { + expect(splitAtSettledBlock("| h |\n|---|\n| r1 |\n| r2")).toBeNull(); + expect(splitAtSettledBlock("## T\n\n| h |\n|---|\n| r1 |\n| r2")).toEqual({ + frozen: "## T", + live: "| h |\n|---|\n| r1 |\n| r2", + gapRows: 1, + }); + }); + + test("a table with no separator is not a table", () => { + expect(splitAtSettledBlock("a | b\n\ntail")).toBeNull(); + }); + + test("agrees with splitAtSettledHeading on heading-only content", () => { + for (const text of ["# Title\nbody", "# Title", "plain"]) { + expect(splitAtSettledBlock(text)).toEqual(splitAtSettledHeading(text)); + } + }); +}); + +describe("nextStreamMarkdownState", () => { + const frozen = "### T"; + const base = { + content: `${frozen}\n\ntail`, + frozen, + width: 100, + streaming: true, + }; + + test("a first paint always repaints", () => { + expect( + nextStreamMarkdownState( + null, + base.content, + base.frozen, + base.width, + true, + ), + ).toEqual({ + state: base, + paintFrozen: true, + }); + }); + + test("tail-only growth skips the frozen repaint", () => { + const grown = { ...base, content: `${frozen}\n\ntail grows` }; + const next = nextStreamMarkdownState( + base, + grown.content, + grown.frozen, + grown.width, + true, + ); + expect(next.paintFrozen).toBe(false); + expect(next.state.content).toBe(grown.content); + }); + + test("a moved boundary repaints the frozen node", () => { + const moved = { ...base, frozen: `${frozen}\n\nsettled` }; + expect( + nextStreamMarkdownState( + base, + base.content, + moved.frozen, + base.width, + true, + ).paintFrozen, + ).toBe(true); + }); + + test("width and streaming flips repaint the frozen node", () => { + expect( + nextStreamMarkdownState( + base, + base.content, + base.frozen, + base.width + 1, + true, + ).paintFrozen, + ).toBe(true); + expect( + nextStreamMarkdownState( + base, + base.content, + base.frozen, + base.width, + false, + ).paintFrozen, + ).toBe(true); + }); + + test("mid-stream edits repaint the frozen node", () => { + const edited = `${frozen}\n\nrewritten`; + const prev = { ...base, content: `${frozen}\n\ntail` }; + const next = nextStreamMarkdownState( + prev, + edited, + frozen, + base.width, + true, + ); + expect(next.paintFrozen).toBe(true); + expect(next.state).toBeDefined(); + }); + + test("identical content still skips the frozen repaint", () => { + const next = nextStreamMarkdownState( + base, + base.content, + base.frozen, + base.width, + true, + ); + expect(next.paintFrozen).toBe(false); + expect(next.state).toEqual(base); + }); +}); diff --git a/src/tui/markdown-parser.ts b/src/tui/markdown-parser.ts index 53acdba29..0d324a368 100644 --- a/src/tui/markdown-parser.ts +++ b/src/tui/markdown-parser.ts @@ -854,3 +854,119 @@ export function splitAtSettledHeading(text: string): MarkdownSplit | null { gapRows: firstContent > 0 ? 1 : 0, }; } + +/** + * A markdown body split at the last settled block boundary: a settled ATX + * heading line, the closing fence of a completed fenced block, or the last + * row of a table the renderer would consume. Returns null when no boundary + * has settled yet (or when the only boundary is the last line, leaving no + * live tail). + * + * The walk below mirrors parseMarkdown's dispatch order line for line — + * loose fence pairing (`FENCE_OPEN_RE`/`FENCE_CLOSE_RE`), indented-code runs, + * then `parseTableBlock`'s start/consume/shape rules — so every recorded + * boundary is a block boundary in the one-shot parse, and the frozen prefix + * plus the live suffix render the same apart as they do together. An + * unclosed fence consumes to the end of the row, so nothing after its opener + * can settle. A single non-blank line between a boundary and the live tail + * still yields `gapRows: 0`; blank lines collapse to one gap row, as before. + */ +export function splitAtSettledBlock(withheld: string): MarkdownSplit | null { + const lines = withheld.split("\n"); + const boundaries = new Set(); + let i = 0; + while (i < lines.length) { + const line = lines[i] ?? ""; + if (FENCE_OPEN_RE.test(line)) { + let closer = -1; + for (let j = i + 1; j < lines.length; j += 1) { + if (FENCE_CLOSE_RE.test(lines[j] ?? "")) { + closer = j; + break; + } + } + if (closer === -1) break; + boundaries.add(closer); + i = closer + 1; + continue; + } + if (INDENTED_CODE_RE.test(line)) { + i += 1; + while (i < lines.length && INDENTED_CODE_RE.test(lines[i] ?? "")) i += 1; + continue; + } + if (looksLikeTableRow(line)) { + const next = lines[i + 1]; + const hasSeparator = next !== undefined && isTableSeparator(next); + const rawRows: string[][] = [extractTableCells(line)]; + let consumed = 1; + if (hasSeparator) { + rawRows.push(extractTableCells(next ?? "")); + consumed = 2; + } + while (i + consumed < lines.length) { + const rowLine = lines[i + consumed] ?? ""; + if (!looksLikeTableRow(rowLine) || isTableSeparator(rowLine)) break; + rawRows.push(extractTableCells(rowLine)); + consumed += 1; + } + const firstWidth = rawRows[0]?.length ?? 0; + const valid = + hasSeparator || + (rawRows.length >= 2 && + firstWidth >= 2 && + rawRows.every((row) => row.length === firstWidth)); + if (valid) { + boundaries.add(i + consumed - 1); + i += consumed; + continue; + } + } + if (ATX_HEADING_LINE_RE.test(line)) boundaries.add(i); + i += 1; + } + const ordered = [...boundaries].sort((a, b) => b - a); + for (const boundary of ordered) { + if (boundary >= lines.length - 1) continue; + const rest = lines.slice(boundary + 1); + const firstContent = rest.findIndex((entry) => entry.trim().length > 0); + if (firstContent === -1) continue; + return { + frozen: lines.slice(0, boundary + 1).join("\n"), + live: rest.slice(firstContent).join("\n"), + gapRows: firstContent > 0 ? 1 : 0, + }; + } + return null; +} + +/** Paint state for one streaming markdown row across repaints. */ +export interface StreamMarkdownSnapshot { + readonly content: string; + readonly frozen: string | null; + readonly width: number; + readonly streaming: boolean; +} + +/** + * Decide whether a streaming markdown repaint must repaint the frozen node. + * The same frozen text at the same width, still streaming a pure append, + * needs no frozen repaint; a moved boundary, a resize, a streaming-flag + * flip, or a non-append edit (rollback, rewrite) repaints it. + */ +export function nextStreamMarkdownState( + prev: StreamMarkdownSnapshot | null, + content: string, + frozen: string | null, + width: number, + streaming: boolean, +): { state: StreamMarkdownSnapshot; paintFrozen: boolean } { + const state: StreamMarkdownSnapshot = { content, frozen, width, streaming }; + if (prev === null) return { state, paintFrozen: true }; + const paintFrozen = + prev.frozen !== frozen || + prev.width !== width || + prev.streaming !== streaming || + !content.startsWith(prev.content); + return { state, paintFrozen }; +} diff --git a/src/tui/markdown-rows.test.ts b/src/tui/markdown-rows.test.ts index 0459017a5..1a45fd87c 100644 --- a/src/tui/markdown-rows.test.ts +++ b/src/tui/markdown-rows.test.ts @@ -17,7 +17,8 @@ import { replaceStreamRowAt, } from "./shell/chrome"; import { createAppShell } from "./shell/index"; -import { splitAtSettledHeading } from "./markdown-parser"; +import { attachSessionBridge, createRecordingPort } from "./runtime-bridge"; +import { splitAtSettledBlock, splitAtSettledHeading } from "./markdown-parser"; import { isMarkdownRow } from "./stream"; const WIDE = { width: 80, height: 24 } as const; @@ -580,3 +581,229 @@ describe("markdown transcript rows", () => { expect(defined(split).live).toBe("body"); }); }); + +describe("closed-block frozen/live streaming", () => { + const FROZEN_HEAD = [ + "### Title", + "", + "```bash", + "echo hi", + "```", + "", + "| a | b |", + "|---|---|", + "| 1 | 2 |", + ].join("\n"); + + function splitNodes( + shell: ReturnType, + index: number, + ): { frozenNode: MarkdownRenderable; liveNode: MarkdownRenderable } { + const row = defined(shell.transcript.getChildren()[index + 1]); + expect(row).toBeInstanceOf(BoxRenderable); + const [, bodyNode] = (row as BoxRenderable).getChildren(); + expect(bodyNode).toBeInstanceOf(BoxRenderable); + const [frozenNode, liveNode] = (bodyNode as BoxRenderable).getChildren(); + expect(frozenNode).toBeInstanceOf(MarkdownRenderable); + expect(liveNode).toBeInstanceOf(MarkdownRenderable); + return { + frozenNode: frozenNode as MarkdownRenderable, + liveNode: liveNode as MarkdownRenderable, + }; + } + + function observeFrozenAssignments(frozenNode: MarkdownRenderable): { + count: number; + chars: number; + } { + const calls = { count: 0, chars: 0 }; + const descriptor = Object.getOwnPropertyDescriptor( + MarkdownRenderable.prototype, + "content", + ); + expect(descriptor?.set).toBeDefined(); + Object.defineProperty(frozenNode, "content", { + configurable: true, + get(this: MarkdownRenderable): string { + return defined(descriptor?.get).call(this); + }, + set(this: MarkdownRenderable, value: string) { + calls.count += 1; + calls.chars += value.length; + defined(descriptor?.set).call(this, value); + }, + }); + return calls; + } + + function settledFrameReady(): (frame: string) => boolean { + return (frame) => + frame.includes("Title") && + frame.includes("echo hi") && + frame.includes("tail words here") && + !frame.includes("```"); + } + + test("tail-only growth across frames never rewrites the frozen node", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { ...shellOpts, run: "idle" }); + const bridge = attachSessionBridge(shell, createRecordingPort()); + try { + for (const chunk of [FROZEN_HEAD, "\n\n", "tail"]) { + bridge.handle({ type: "assistant.delta", text: chunk }); + } + await h.renderOnce(); + const index = shell.streamLog.length - 1; + const { frozenNode, liveNode } = splitNodes(shell, index); + expect(frozenNode.content).toBe(FROZEN_HEAD); + expect(liveNode.content).toBe("tail"); + + const calls = observeFrozenAssignments(frozenNode); + for (let frame = 0; frame < 5; frame += 1) { + bridge.handle({ type: "assistant.delta", text: ` ${frame}` }); + await h.renderOnce(); + } + expect(calls).toEqual({ count: 0, chars: 0 }); + expect(liveNode.content).toBe("tail 0 1 2 3 4"); + expect(splitNodes(shell, index).frozenNode).toBe(frozenNode); + expect(liveNode.streaming).toBe(true); + } finally { + bridge.dispose(); + } + }, WIDE); + }); + + test("a newly settled block repaints the frozen node exactly once", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, shellOpts); + appendStreamRow(shell, { + role: "assistant", + streaming: true, + text: `${FROZEN_HEAD}\n\ntail`, + }); + await h.renderOnce(); + const index = shell.streamLog.length - 1; + const { frozenNode, liveNode } = splitNodes(shell, index); + expect(frozenNode.content).toBe(FROZEN_HEAD); + + const calls = observeFrozenAssignments(frozenNode); + const settled = `${FROZEN_HEAD}\n\n| h1 | h2 |\n|---|---|\n| r1 | r2 |\n\ntail`; + replaceStreamRowAt(shell, index, { + role: "assistant", + streaming: true, + text: settled, + }); + await h.renderOnce(); + expect(calls.count).toBe(1); + expect(frozenNode.content).toBe( + `${FROZEN_HEAD}\n\n| h1 | h2 |\n|---|---|\n| r1 | r2 |`, + ); + expect(liveNode.content).toBe("tail"); + + replaceStreamRowAt(shell, index, { + role: "assistant", + streaming: true, + text: `${settled} more`, + }); + await h.renderOnce(); + expect(calls.count).toBe(1); + expect(liveNode.content).toBe("tail more"); + }, WIDE); + }); + + test("a mid-stream edit that is not an append repaints both nodes", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, shellOpts); + appendStreamRow(shell, { + role: "assistant", + streaming: true, + text: `${FROZEN_HEAD}\n\ntail\nmore`, + }); + await h.renderOnce(); + const index = shell.streamLog.length - 1; + const { frozenNode, liveNode } = splitNodes(shell, index); + const calls = observeFrozenAssignments(frozenNode); + + replaceStreamRowAt(shell, index, { + role: "assistant", + streaming: true, + text: `${FROZEN_HEAD}\n\nmore`, + }); + await h.renderOnce(); + expect(calls.count).toBeGreaterThanOrEqual(1); + expect(frozenNode.content).toBe(FROZEN_HEAD); + expect(liveNode.content).toBe("more"); + }, WIDE); + }); + + test("losing the split shape rebuilds the row instead of retexting it", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, shellOpts); + appendStreamRow(shell, { + role: "assistant", + streaming: true, + text: `${FROZEN_HEAD}\n\ntail`, + }); + await h.renderOnce(); + const index = shell.streamLog.length - 1; + splitNodes(shell, index); + + replaceStreamRowAt(shell, index, { + role: "assistant", + streaming: true, + text: "just plain prose now", + }); + await h.renderOnce(); + const row = defined(shell.transcript.getChildren()[index + 1]); + expect(row).toBeInstanceOf(BoxRenderable); + const [, bodyNode] = (row as BoxRenderable).getChildren(); + expect(bodyNode).toBeInstanceOf(MarkdownRenderable); + expect((bodyNode as MarkdownRenderable).content).toBe( + "just plain prose now", + ); + }, WIDE); + }); + + test("incremental streaming settles into the same frame as a one-shot paint", async () => { + const full = `${FROZEN_HEAD}\n\ntail words here`; + // The footer ticker cycles live activity words by timing, so two shells + // can never agree on that one line. Everything above it must match. + const withoutFooter = (frame: string): string => + frame + .split("\n") + .filter((line) => !line.includes("╯")) + .join("\n"); + let incremental = ""; + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { ...shellOpts, run: "idle" }); + const bridge = attachSessionBridge(shell, createRecordingPort()); + try { + for (const chunk of full.match(/[\s\S]{1,7}/g) ?? []) { + bridge.handle({ type: "assistant.delta", text: chunk }); + await h.renderOnce(); + } + bridge.handle({ type: "system", text: "done" }); + incremental = await settle(h, settledFrameReady()); + } finally { + bridge.dispose(); + } + }, WIDE); + + let oneshot = ""; + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { ...shellOpts, run: "idle" }); + appendStreamRow(shell, { role: "assistant", text: full }); + appendStreamRow(shell, { role: "system", text: "done" }); + oneshot = await settle(h, settledFrameReady()); + }, WIDE); + + expect(withoutFooter(incremental)).toBe(withoutFooter(oneshot)); + }); + + test("splitAtSettledBlock freezes through closed blocks at the transcript layer", () => { + const split = splitAtSettledBlock(`${FROZEN_HEAD}\n\ntail`); + expect(defined(split).frozen).toBe(FROZEN_HEAD); + expect(defined(split).live).toBe("tail"); + expect(defined(split).gapRows).toBe(1); + }); +}); diff --git a/src/tui/shell/transcript.ts b/src/tui/shell/transcript.ts index 1d89f02f5..0929da07f 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 { - splitAtSettledHeading, + nextStreamMarkdownState, + splitAtSettledBlock, withholdIncompleteHeading, + type StreamMarkdownSnapshot, } from "../markdown-parser.js"; import { diffLineChunks, retextStyledKindRow } from "./row-retext.js"; import { @@ -187,6 +189,11 @@ export function retextStreamRow( return retextStreamRowBody(node, row, layout); } +/** Last painted state per split markdown body, keyed by its column node. A + * rebuilt row gets a fresh node, so stale entries collect with the old row; + * a retext that finds no entry treats the paint as first and repaints. */ +const splitBodyMemory = new WeakMap(); + /** The shape-matching rewrite shared by labelled and unlabelled rows. */ function retextStreamRowBody( node: BaseRenderable, @@ -219,10 +226,10 @@ function retextStreamRowBody( gutterNode.width = stringWidth(gutter.content); const width = markdownBodyColumns(gutter, layout); const content = markdownContent(row); - const split = splitAtSettledHeading(content); + const split = splitAtSettledBlock(content); - // No settled heading behind the tail: a lone renderer, same as an unsplit - // body. A shape change (a heading just closed, or one just left the window + // No settled block behind the tail: a lone renderer, same as an unsplit + // body. A shape change (a block just settled, or one just left the window // a full rebuild trimmed) falls through to the caller's rebuild. if (split === null) { if (!(bodyNode instanceof MarkdownRenderable)) return false; @@ -240,13 +247,26 @@ function retextStreamRowBody( ) { return false; } + // Tail-only growth repaints just the live renderer: the frozen text is + // already on screen at this width, still settled, behind a pure append. + const streaming = row.streaming === true; + const transition = nextStreamMarkdownState( + splitBodyMemory.get(bodyNode) ?? null, + content, + split.frozen, + width, + streaming, + ); + splitBodyMemory.set(bodyNode, transition.state); bodyNode.width = width; frozenNode.width = width; - frozenNode.content = split.frozen; + if (transition.paintFrozen) { + frozenNode.content = split.frozen; + liveNode.marginTop = split.gapRows; + } liveNode.width = width; liveNode.content = split.live; - liveNode.streaming = row.streaming === true; - liveNode.marginTop = split.gapRows; + liveNode.streaming = streaming; return true; } @@ -444,15 +464,16 @@ function paintPlainRowNode( } /** - * A markdown row's body. Most rows have no settled heading yet (no heading at - * all, or the only one is still the open tail), and paint through a single - * renderer, same as before this fix existed. Once a heading closes, the body - * becomes a settled `frozen` renderer — everything through that heading, - * never streaming, never handed new content while the tail keeps growing, so - * it is never asked to re-highlight once written — stacked above the still - * `live` one, which carries the row's own streaming flag. Both halves use the - * library's default block mode, so paragraphs, lists and tables inside either - * one lay out exactly as a single unsplit body would. + * A markdown row's body. Most rows have no settled block yet (no heading at + * all, or the only boundary is still the open tail), and paint through a + * single renderer, same as before this fix existed. Once a block settles — + * a heading with content behind it, a closed fence, a complete table — the + * body becomes a settled `frozen` renderer — everything through that + * boundary, never streaming, never handed new content while the tail keeps + * growing, so it is never asked to re-highlight once written — stacked above + * the still `live` one, which carries the row's own streaming flag. Both + * halves use the library's default block mode, so paragraphs, lists and + * tables inside either one lay out exactly as a single unsplit body would. */ function createMarkdownBody( ctx: CliRenderer, @@ -462,7 +483,7 @@ function createMarkdownBody( ): MarkdownRenderable | BoxRenderable { const width = markdownBodyColumns(gutter, layout); const content = markdownContent(row); - const split = splitAtSettledHeading(content); + const split = splitAtSettledBlock(content); if (split === null) { // Native incremental block stability: only the trailing block is unstable. return new MarkdownRenderable(ctx, { @@ -487,6 +508,12 @@ function createMarkdownBody( marginTop: split.gapRows, }), ); + splitBodyMemory.set(column, { + content, + frozen: split.frozen, + width, + streaming: row.streaming === true, + }); return column; }