Skip to content

Commit 6f266c1

Browse files
committed
Stop transcript headings flickering while text below them streams
The markdown renderer's default block mode merges a heading into the same raw chunk as the paragraph that follows it. Every keystroke of that paragraph changes the merged chunk's raw text, so the heading's already-settled markup re-highlights too, flickering while the rest of the message keeps streaming in. Render a streaming row's markdown body as two stacked renderers when a heading has closed: a "frozen" one holding everything through the last such heading, marked non-streaming and never handed new content again, and a "live" one holding the still-growing tail. Most rows have no closed heading yet, so they keep painting through a single renderer as before; the split only ever falls at a settled heading boundary, never at a paragraph, list, or fence boundary, so non-heading layout is unchanged. Track fence state while scanning for that boundary so a `#` line inside a fenced code block (a shell or Python comment, for instance) is never read as a heading. Adds a mid-stream span-sampling test with no settle wait that reproduces the shake directly against the unfixed renderer, plus regression tests pinning list spacing, ordered-list marker width, and fenced-block integrity across the change.
1 parent 7753fd5 commit 6f266c1

2 files changed

Lines changed: 406 additions & 23 deletions

File tree

src/tui-opentui/markdown-rows.test.ts

Lines changed: 243 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,15 @@
44
*/
55

66
import { describe, expect, test } from "bun:test"
7+
import { MarkdownRenderable, BoxRenderable, type CapturedSpan } from "@opentui/core"
78
import { withTestRenderer, type Harness } from "./harness"
8-
import { appendStreamRow, createAppShell, replaceStreamRowAt } from "./shell"
9+
import {
10+
appendStreamRow,
11+
createAppShell,
12+
createStreamRowRenderable,
13+
replaceStreamRowAt,
14+
splitAtSettledHeading,
15+
} from "./shell"
916
import { isMarkdownRow } from "./stream"
1017

1118
const WIDE = { width: 80, height: 24 } as const
@@ -15,11 +22,17 @@ const shellOpts = {
1522
wireKeys: false,
1623
} as const
1724

18-
/** Markdown blocks highlight asynchronously; settle before capturing a frame. */
25+
/**
26+
* Markdown blocks highlight asynchronously; settle before capturing a frame.
27+
* A row with several top-level blocks (heading, list, fence, link) resolves
28+
* its highlight promises one render at a time, so a fixed couple of ticks
29+
* that was enough for one block is not enough for several.
30+
*/
1931
async function settle(h: Harness): Promise<string> {
20-
await new Promise((resolve) => setTimeout(resolve, 250))
21-
await h.renderOnce()
22-
await h.renderOnce()
32+
for (let i = 0; i < 8; i += 1) {
33+
await new Promise((resolve) => setTimeout(resolve, 50))
34+
await h.renderOnce()
35+
}
2336
return h.captureCharFrame()
2437
}
2538

@@ -144,4 +157,229 @@ describe("markdown transcript rows", () => {
144157
expect(next).not.toContain("#### Title")
145158
}, WIDE)
146159
})
160+
161+
test("a row with no settled heading paints through a single renderer, not a wasted split", async () => {
162+
// Most rows never have a settled heading behind their tail (no heading at
163+
// all, or the only one is still being typed). Building the frozen/live
164+
// pair unconditionally would double every markdown row's renderer count
165+
// for no benefit in the common case.
166+
await withTestRenderer(async (h) => {
167+
const shell = createAppShell(h.renderer, shellOpts)
168+
const node = createStreamRowRenderable(shell, {
169+
role: "assistant",
170+
text: "Just a paragraph, no heading at all.",
171+
})
172+
expect(node).toBeInstanceOf(BoxRenderable)
173+
const [, bodyNode] = (node as BoxRenderable).getChildren()
174+
expect(bodyNode).toBeInstanceOf(MarkdownRenderable)
175+
}, WIDE)
176+
})
177+
178+
test("a closed heading renders in its own settled renderer, separate from the prose after it", async () => {
179+
// The library's default block mode merges a heading into the same raw
180+
// chunk as the paragraph that follows it, so every keystroke of that
181+
// paragraph re-highlights the heading's already-settled text too — the
182+
// heading's markers and styling visibly flicker while the rest of the
183+
// message keeps streaming in. Splitting the body at the heading gives it
184+
// its own renderer, marked non-streaming, that the live (still-growing)
185+
// half never shares — so it is never asked to re-highlight again.
186+
await withTestRenderer(async (h) => {
187+
const shell = createAppShell(h.renderer, shellOpts)
188+
const node = createStreamRowRenderable(shell, {
189+
role: "assistant",
190+
streaming: true,
191+
text: ["### Title", "", "Some body text."].join("\n"),
192+
})
193+
expect(node).toBeInstanceOf(BoxRenderable)
194+
const [, bodyNode] = (node as BoxRenderable).getChildren()
195+
expect(bodyNode).toBeInstanceOf(BoxRenderable)
196+
const [frozenNode, liveNode] = (bodyNode as BoxRenderable).getChildren()
197+
expect(frozenNode).toBeInstanceOf(MarkdownRenderable)
198+
expect(liveNode).toBeInstanceOf(MarkdownRenderable)
199+
expect((frozenNode as MarkdownRenderable).content).toContain("### Title")
200+
expect((frozenNode as MarkdownRenderable).streaming).toBe(false)
201+
expect((liveNode as MarkdownRenderable).content).toBe("Some body text.")
202+
expect((liveNode as MarkdownRenderable).streaming).toBe(true)
203+
}, WIDE)
204+
})
205+
206+
test("the settled heading renderer is never rewritten while the prose after it keeps streaming", async () => {
207+
await withTestRenderer(async (h) => {
208+
const shell = createAppShell(h.renderer, shellOpts)
209+
appendStreamRow(shell, {
210+
role: "assistant",
211+
streaming: true,
212+
text: ["### Title", "", "Some"].join("\n"),
213+
})
214+
const children = shell.transcript.getChildren().slice(1)
215+
const [, bodyNode] = (children[0] as BoxRenderable).getChildren()
216+
const [frozenNode] = (bodyNode as BoxRenderable).getChildren()
217+
const before = (frozenNode as MarkdownRenderable).content
218+
219+
replaceStreamRowAt(shell, shell.streamLog.length - 1, {
220+
role: "assistant",
221+
streaming: true,
222+
text: ["### Title", "", "Some body text that keeps growing and growing."].join("\n"),
223+
})
224+
const childrenAfter = shell.transcript.getChildren().slice(1)
225+
const [, bodyNodeAfter] = (childrenAfter[0] as BoxRenderable).getChildren()
226+
const [frozenNodeAfter] = (bodyNodeAfter as BoxRenderable).getChildren()
227+
228+
expect(frozenNodeAfter).toBe(frozenNode)
229+
expect((frozenNodeAfter as MarkdownRenderable).content).toBe(before)
230+
}, WIDE)
231+
})
232+
233+
test("a list directly under a paragraph, with no blank line, keeps that shape after the split", async () => {
234+
// Regression guard: the split must never fall at a list boundary — only
235+
// at a settled heading — so paragraph/list spacing stays byte-identical
236+
// to the unsplit renderer's own default layout.
237+
await withTestRenderer(async (h) => {
238+
const shell = createAppShell(h.renderer, shellOpts)
239+
appendStreamRow(shell, {
240+
role: "assistant",
241+
text: ["### Title", "", "Here is the list:", "- alpha", "- beta"].join("\n"),
242+
})
243+
const frame = await settle(h)
244+
const lines = frame.split("\n").map((line) => line.trimEnd())
245+
const listLine = lines.findIndex((line) => line.includes("Here is the list:"))
246+
expect(listLine).toBeGreaterThan(-1)
247+
// No blank row inserted between the paragraph and the list beneath it.
248+
expect(lines[listLine + 1]).toContain("alpha")
249+
}, WIDE)
250+
})
251+
252+
test("a ten-item ordered list under a heading keeps unpadded markers", async () => {
253+
await withTestRenderer(async (h) => {
254+
const shell = createAppShell(h.renderer, shellOpts)
255+
const items = Array.from({ length: 10 }, (_, i) => `${i + 1}. item ${i + 1}`)
256+
appendStreamRow(shell, {
257+
role: "assistant",
258+
text: ["### Steps", "", ...items].join("\n"),
259+
})
260+
const frame = await settle(h)
261+
expect(frame).toContain("1. item 1")
262+
expect(frame).toContain("10. item 10")
263+
}, WIDE)
264+
})
265+
266+
/** The heading's own painted span, wherever it lands in the current frame. */
267+
function headingSpan(h: Harness): CapturedSpan | null {
268+
for (const line of h.captureSpans().lines) {
269+
for (const span of line.spans) {
270+
if (span.text.includes("Title")) return span
271+
}
272+
}
273+
return null
274+
}
275+
276+
test("a settled heading's painted span never changes while the prose after it keeps streaming", async () => {
277+
// The shake this fixes is a transient re-highlight, not a settled-frame
278+
// difference — a snapshot taken only after several idle ticks (as every
279+
// other test in this file does) cannot see it, because the async
280+
// highlight pass has always finished by then. This test instead samples
281+
// the heading's span on every delta, immediately after a single render
282+
// with no settle wait, which is the one place the flicker would show up.
283+
await withTestRenderer(async (h) => {
284+
const shell = createAppShell(h.renderer, shellOpts)
285+
const full = "Some body text that keeps growing and growing more and more and even more."
286+
appendStreamRow(shell, {
287+
role: "assistant",
288+
streaming: true,
289+
text: ["### Title", "", full.slice(0, 1)].join("\n"),
290+
})
291+
// Warm up once: the first highlight pass in a process loads the
292+
// tree-sitter grammar and is not itself part of what this test samples.
293+
const baseline = await settle(h).then(() => headingSpan(h))
294+
expect(baseline).not.toBeNull()
295+
296+
for (let i = 2; i <= full.length; i += 1) {
297+
replaceStreamRowAt(shell, shell.streamLog.length - 1, {
298+
role: "assistant",
299+
streaming: true,
300+
text: ["### Title", "", full.slice(0, i)].join("\n"),
301+
})
302+
await h.renderOnce()
303+
const span = headingSpan(h)
304+
expect(span).not.toBeNull()
305+
expect(span!.text).toBe(baseline!.text)
306+
expect(span!.fg).toEqual(baseline!.fg)
307+
expect(span!.attributes).toBe(baseline!.attributes)
308+
}
309+
}, WIDE)
310+
})
311+
312+
describe("splitAtSettledHeading never splits a fenced code block", () => {
313+
test("a `#` shell comment inside a fence is not read as a heading boundary", () => {
314+
const text = [
315+
"```bash",
316+
"# this is a comment, not a heading",
317+
"echo hi",
318+
"```",
319+
"",
320+
"more prose streaming in",
321+
].join("\n")
322+
const split = splitAtSettledHeading(text)
323+
// No real heading anywhere in this text, fenced or not: no split at all.
324+
expect(split).toBeNull()
325+
})
326+
327+
test("a real heading before an open fence still splits, and the fence stays whole", () => {
328+
const text = [
329+
"### Title",
330+
"",
331+
"```bash",
332+
"# comment, not a heading",
333+
"echo hi",
334+
"```",
335+
"",
336+
"more prose streaming in",
337+
].join("\n")
338+
const split = splitAtSettledHeading(text)
339+
expect(split).not.toBeNull()
340+
// The fence opens and closes on the same side of the split.
341+
expect(split!.frozen).toBe("### Title")
342+
expect(split!.live).toContain("```bash")
343+
expect(split!.live).toContain("```\n")
344+
})
345+
346+
test("a fence opened before a heading keeps the heading out of the boundary search until it closes", () => {
347+
const text = [
348+
"```py",
349+
"# looks like a heading but is not",
350+
"```",
351+
"",
352+
"### Real Title",
353+
"",
354+
"body text",
355+
].join("\n")
356+
const split = splitAtSettledHeading(text)
357+
expect(split).not.toBeNull()
358+
expect(split!.frozen).toContain("### Real Title")
359+
expect(split!.frozen).not.toContain("body text")
360+
expect(split!.live).toBe("body text")
361+
})
362+
363+
test("a fenced `#` comment renders inside a matched fence, not split across two renderers", async () => {
364+
await withTestRenderer(async (h) => {
365+
const shell = createAppShell(h.renderer, shellOpts)
366+
appendStreamRow(shell, {
367+
role: "assistant",
368+
streaming: true,
369+
text: [
370+
"```bash",
371+
"# this is a comment, not a heading",
372+
"echo hi",
373+
"```",
374+
"",
375+
"more prose streaming in",
376+
].join("\n"),
377+
})
378+
const frame = await settle(h)
379+
expect(frame).toContain("# this is a comment, not a heading")
380+
expect(frame).toContain("echo hi")
381+
expect(frame).toContain("more prose streaming in")
382+
}, WIDE)
383+
})
384+
})
147385
})

0 commit comments

Comments
 (0)