Skip to content

Commit fdb0a7f

Browse files
committed
Split a streaming heading into its own settled renderer instead of switching block modes
The previous fix set internalBlockMode to "top-level" on the transcript's MarkdownRenderable, which does give a heading real stability, but it also routes lists through the top-level layout path: a 10+ item ordered list gets its markers padded for alignment, and a list with no blank line before it gains one anyway, both changes to how non-heading markdown renders during live streaming. Render the row's markdown body as two stacked renderers instead: a "frozen" one holding everything through the last heading that already has content behind it, and a "live" one holding the still-streaming tail. Both halves use the library's default block mode untouched, so paragraphs, lists and tables inside either one lay out exactly as an unsplit body would — the split only ever falls at a settled heading boundary, never at a list or paragraph boundary. The frozen half is marked non-streaming and, once written, is never handed new content while the tail keeps growing, so it is never asked to re-highlight. Adds a mid-stream span-sampling test (no settle wait) that reproduces the literal shake — the heading's painted text reverting to "### Title" on a delta — against the unfixed renderer, plus regression tests pinning list spacing and ordered-list marker width across the change.
1 parent 67327b5 commit fdb0a7f

2 files changed

Lines changed: 227 additions & 37 deletions

File tree

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

Lines changed: 120 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66
import { describe, expect, test } from "bun:test"
7-
import { MarkdownRenderable, BoxRenderable } from "@opentui/core"
7+
import { MarkdownRenderable, BoxRenderable, type CapturedSpan } from "@opentui/core"
88
import { withTestRenderer, type Harness } from "./harness"
99
import { appendStreamRow, createAppShell, createStreamRowRenderable, replaceStreamRowAt } from "./shell"
1010
import { isMarkdownRow } from "./stream"
@@ -152,14 +152,14 @@ describe("markdown transcript rows", () => {
152152
}, WIDE)
153153
})
154154

155-
test("a heading renders as its own top-level block, not merged with the prose after it", async () => {
156-
// The default ("coalesced") block mode folds a heading into the same raw
155+
test("a closed heading renders in its own settled renderer, separate from the prose after it", async () => {
156+
// The library's default block mode merges a heading into the same raw
157157
// chunk as the paragraph that follows it, so every keystroke of that
158158
// paragraph re-highlights the heading's already-settled text too — the
159159
// heading's markers and styling visibly flicker while the rest of the
160-
// message keeps streaming in. "top-level" mode keeps the heading its own
161-
// block so, once it is behind the streaming tail, it is never
162-
// recomputed again.
160+
// message keeps streaming in. Splitting the body at the heading gives it
161+
// its own renderer, marked non-streaming, that the live (still-growing)
162+
// half never shares — so it is never asked to re-highlight again.
163163
await withTestRenderer(async (h) => {
164164
const shell = createAppShell(h.renderer, shellOpts)
165165
const node = createStreamRowRenderable(shell, {
@@ -169,10 +169,120 @@ describe("markdown transcript rows", () => {
169169
})
170170
expect(node).toBeInstanceOf(BoxRenderable)
171171
const [, bodyNode] = (node as BoxRenderable).getChildren()
172-
expect(bodyNode).toBeInstanceOf(MarkdownRenderable)
173-
expect((bodyNode as MarkdownRenderable).internalBlockMode).toBe(
174-
"top-level",
175-
)
172+
expect(bodyNode).toBeInstanceOf(BoxRenderable)
173+
const [frozenNode, liveNode] = (bodyNode as BoxRenderable).getChildren()
174+
expect(frozenNode).toBeInstanceOf(MarkdownRenderable)
175+
expect(liveNode).toBeInstanceOf(MarkdownRenderable)
176+
expect((frozenNode as MarkdownRenderable).content).toContain("### Title")
177+
expect((frozenNode as MarkdownRenderable).streaming).toBe(false)
178+
expect((liveNode as MarkdownRenderable).content).toBe("Some body text.")
179+
expect((liveNode as MarkdownRenderable).streaming).toBe(true)
180+
}, WIDE)
181+
})
182+
183+
test("the settled heading renderer is never rewritten while the prose after it keeps streaming", async () => {
184+
await withTestRenderer(async (h) => {
185+
const shell = createAppShell(h.renderer, shellOpts)
186+
appendStreamRow(shell, {
187+
role: "assistant",
188+
streaming: true,
189+
text: ["### Title", "", "Some"].join("\n"),
190+
})
191+
const children = shell.transcript.getChildren().slice(1)
192+
const [, bodyNode] = (children[0] as BoxRenderable).getChildren()
193+
const [frozenNode] = (bodyNode as BoxRenderable).getChildren()
194+
const before = (frozenNode as MarkdownRenderable).content
195+
196+
replaceStreamRowAt(shell, shell.streamLog.length - 1, {
197+
role: "assistant",
198+
streaming: true,
199+
text: ["### Title", "", "Some body text that keeps growing and growing."].join("\n"),
200+
})
201+
const childrenAfter = shell.transcript.getChildren().slice(1)
202+
const [, bodyNodeAfter] = (childrenAfter[0] as BoxRenderable).getChildren()
203+
const [frozenNodeAfter] = (bodyNodeAfter as BoxRenderable).getChildren()
204+
205+
expect(frozenNodeAfter).toBe(frozenNode)
206+
expect((frozenNodeAfter as MarkdownRenderable).content).toBe(before)
207+
}, WIDE)
208+
})
209+
210+
test("a list directly under a paragraph, with no blank line, keeps that shape after the split", async () => {
211+
// Regression guard: the split must never fall at a list boundary — only
212+
// at a settled heading — so paragraph/list spacing stays byte-identical
213+
// to the unsplit renderer's own default layout.
214+
await withTestRenderer(async (h) => {
215+
const shell = createAppShell(h.renderer, shellOpts)
216+
appendStreamRow(shell, {
217+
role: "assistant",
218+
text: ["### Title", "", "Here is the list:", "- alpha", "- beta"].join("\n"),
219+
})
220+
const frame = await settle(h)
221+
const lines = frame.split("\n").map((line) => line.trimEnd())
222+
const listLine = lines.findIndex((line) => line.includes("Here is the list:"))
223+
expect(listLine).toBeGreaterThan(-1)
224+
// No blank row inserted between the paragraph and the list beneath it.
225+
expect(lines[listLine + 1]).toContain("alpha")
226+
}, WIDE)
227+
})
228+
229+
test("a ten-item ordered list under a heading keeps unpadded markers", async () => {
230+
await withTestRenderer(async (h) => {
231+
const shell = createAppShell(h.renderer, shellOpts)
232+
const items = Array.from({ length: 10 }, (_, i) => `${i + 1}. item ${i + 1}`)
233+
appendStreamRow(shell, {
234+
role: "assistant",
235+
text: ["### Steps", "", ...items].join("\n"),
236+
})
237+
const frame = await settle(h)
238+
expect(frame).toContain("1. item 1")
239+
expect(frame).toContain("10. item 10")
240+
}, WIDE)
241+
})
242+
243+
/** The heading's own painted span, wherever it lands in the current frame. */
244+
function headingSpan(h: Harness): CapturedSpan | null {
245+
for (const line of h.captureSpans().lines) {
246+
for (const span of line.spans) {
247+
if (span.text.includes("Title")) return span
248+
}
249+
}
250+
return null
251+
}
252+
253+
test("a settled heading's painted span never changes while the prose after it keeps streaming", async () => {
254+
// The shake this fixes is a transient re-highlight, not a settled-frame
255+
// difference — a snapshot taken only after several idle ticks (as every
256+
// other test in this file does) cannot see it, because the async
257+
// highlight pass has always finished by then. This test instead samples
258+
// the heading's span on every delta, immediately after a single render
259+
// with no settle wait, which is the one place the flicker would show up.
260+
await withTestRenderer(async (h) => {
261+
const shell = createAppShell(h.renderer, shellOpts)
262+
const full = "Some body text that keeps growing and growing more and more and even more."
263+
appendStreamRow(shell, {
264+
role: "assistant",
265+
streaming: true,
266+
text: ["### Title", "", full.slice(0, 1)].join("\n"),
267+
})
268+
// Warm up once: the first highlight pass in a process loads the
269+
// tree-sitter grammar and is not itself part of what this test samples.
270+
const baseline = await settle(h).then(() => headingSpan(h))
271+
expect(baseline).not.toBeNull()
272+
273+
for (let i = 2; i <= full.length; i += 1) {
274+
replaceStreamRowAt(shell, shell.streamLog.length - 1, {
275+
role: "assistant",
276+
streaming: true,
277+
text: ["### Title", "", full.slice(0, i)].join("\n"),
278+
})
279+
await h.renderOnce()
280+
const span = headingSpan(h)
281+
expect(span).not.toBeNull()
282+
expect(span!.text).toBe(baseline!.text)
283+
expect(span!.fg).toEqual(baseline!.fg)
284+
expect(span!.attributes).toBe(baseline!.attributes)
285+
}
176286
}, WIDE)
177287
})
178288
})

src/tui-opentui/shell.ts

Lines changed: 107 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2091,18 +2091,26 @@ function retextStreamRowBody(
20912091

20922092
if (!(node instanceof BoxRenderable) || !isMarkdownRow(row)) return false
20932093
const [gutterNode, bodyNode] = node.getChildren()
2094-
if (
2095-
!(gutterNode instanceof TextRenderable) ||
2096-
!(bodyNode instanceof MarkdownRenderable)
2097-
) {
2094+
if (!(gutterNode instanceof TextRenderable) || !(bodyNode instanceof BoxRenderable)) {
2095+
return false
2096+
}
2097+
const [frozenNode, liveNode] = bodyNode.getChildren()
2098+
if (!(frozenNode instanceof MarkdownRenderable) || !(liveNode instanceof MarkdownRenderable)) {
20982099
return false
20992100
}
21002101
const gutter = streamRowGutter(row, layout)
21012102
gutterNode.content = gutter.content
21022103
gutterNode.width = stringWidth(gutter.content)
2103-
bodyNode.width = markdownBodyColumns(gutter, layout)
2104-
bodyNode.content = markdownContent(row)
2105-
bodyNode.streaming = row.streaming === true
2104+
const width = markdownBodyColumns(gutter, layout)
2105+
bodyNode.width = width
2106+
const content = markdownContent(row)
2107+
const split = splitAtSettledHeading(content)
2108+
frozenNode.width = width
2109+
frozenNode.content = split?.frozen ?? ""
2110+
liveNode.width = width
2111+
liveNode.content = split?.live ?? content
2112+
liveNode.streaming = row.streaming === true
2113+
liveNode.marginTop = split?.gapRows ?? 0
21062114
return true
21072115
}
21082116

@@ -2231,9 +2239,6 @@ function markdownBodyColumns(gutter: PaintedStreamLine, layout: RowLayout): numb
22312239
const TRANSCRIPT_TABLE_OPTIONS = {
22322240
wrapMode: "word",
22332241
columnFitter: "proportional",
2234-
// "columns" is the top-level-mode default; pin "grid" so switching
2235-
// internalBlockMode below does not also change how tables are framed.
2236-
style: "grid",
22372242
} as const
22382243

22392244
/**
@@ -2250,6 +2255,50 @@ function markdownContent(row: StreamRow): string {
22502255
return row.text.replace(/(^|\n)#{1,6}[ \t]*$/, "$1")
22512256
}
22522257

2258+
/** An ATX heading line (`#` through `######`) with a title, not a bare marker. */
2259+
const HEADING_LINE_RE = /^#{1,6}[ \t]+\S.*$/
2260+
2261+
/**
2262+
* A markdown body split at the last heading that already has content behind
2263+
* it: everything through that heading, and everything after it.
2264+
*
2265+
* The renderer's own incremental parser only reuses a block whose raw text is
2266+
* unchanged; the default block mode merges a heading into the same raw chunk
2267+
* as the paragraph that follows it, so every keystroke of that paragraph
2268+
* changes the merged chunk's raw text and forces the heading's already-settled
2269+
* markup to re-highlight too — visibly flickering while the rest of the
2270+
* message keeps streaming in. Rendering the two halves as separate
2271+
* `MarkdownRenderable`s keeps the heading's renderer untouched once it is no
2272+
* longer the one growing, without changing how paragraphs, lists or tables
2273+
* inside either half are laid out (both halves still use the library's
2274+
* default block mode).
2275+
*/
2276+
export type MarkdownSplit = {
2277+
readonly frozen: string
2278+
readonly live: string
2279+
/** Blank source lines between the heading and what follows it (0 or 1). */
2280+
readonly gapRows: number
2281+
}
2282+
2283+
export function splitAtSettledHeading(text: string): MarkdownSplit | null {
2284+
const lines = text.split("\n")
2285+
let boundary = -1
2286+
for (let i = 0; i < lines.length; i += 1) {
2287+
if (HEADING_LINE_RE.test(lines[i]!)) boundary = i
2288+
}
2289+
// No heading, or the last one is still the open tail: nothing to freeze.
2290+
if (boundary === -1 || boundary >= lines.length - 1) return null
2291+
const rest = lines.slice(boundary + 1)
2292+
const firstContent = rest.findIndex((line) => line.trim().length > 0)
2293+
// Heading closed but nothing has started under it yet.
2294+
if (firstContent === -1) return null
2295+
return {
2296+
frozen: lines.slice(0, boundary + 1).join("\n"),
2297+
live: rest.slice(firstContent).join("\n"),
2298+
gapRows: firstContent > 0 ? 1 : 0,
2299+
}
2300+
}
2301+
22532302
/**
22542303
* Build the row-shaped paint node: a MarkdownRenderable body next to a plain
22552304
* gutter for markdown-bearing rows (assistant replies), a TextTableRenderable
@@ -2318,28 +2367,59 @@ function buildRowNode(
23182367
const gutter = streamRowGutter(row, layout)
23192368
const wrapper = new BoxRenderable(ctx, { flexDirection: "row", width: "100%" })
23202369
wrapper.add(gutterNode(ctx, gutter))
2321-
wrapper.add(
2370+
wrapper.add(createMarkdownBody(ctx, row, gutter, layout))
2371+
return wrapper
2372+
}
2373+
2374+
/** Shared construction options for a transcript markdown body's renderer. */
2375+
function markdownBodyOptions(gutter: PaintedStreamLine, width: number) {
2376+
return {
2377+
syntaxStyle: transcriptSyntaxStyle(),
2378+
fg: gutter.fg,
2379+
width,
2380+
flexShrink: 0,
2381+
tableOptions: TRANSCRIPT_TABLE_OPTIONS,
2382+
} as const
2383+
}
2384+
2385+
/**
2386+
* A markdown row's body: a settled `frozen` renderer stacked above the still
2387+
* `live` one. `frozen` is empty (and so, zero height) until a heading closes;
2388+
* from then on it holds everything through the last closed heading and never
2389+
* streams, so it is never asked to re-highlight once written. `live` carries
2390+
* the row's own streaming flag. Both halves still use the library's default
2391+
* block mode, so paragraphs, lists and tables inside either one lay out
2392+
* exactly as a single unsplit body would. Always building both — the empty
2393+
* case is just an empty renderer — keeps the shape constant, so
2394+
* `retextStreamRowBody` can update in place without telling one shape of row
2395+
* body from another.
2396+
*/
2397+
function createMarkdownBody(
2398+
ctx: CliRenderer,
2399+
row: StreamRow,
2400+
gutter: PaintedStreamLine,
2401+
layout: RowLayout,
2402+
): BoxRenderable {
2403+
const width = markdownBodyColumns(gutter, layout)
2404+
const split = splitAtSettledHeading(markdownContent(row))
2405+
const column = new BoxRenderable(ctx, { flexDirection: "column", width })
2406+
column.add(
23222407
new MarkdownRenderable(ctx, {
2323-
content: markdownContent(row),
2324-
syntaxStyle: transcriptSyntaxStyle(),
2325-
fg: gutter.fg,
2326-
width: markdownBodyColumns(gutter, layout),
2327-
flexShrink: 0,
2328-
tableOptions: TRANSCRIPT_TABLE_OPTIONS,
2329-
// Native incremental block stability only tracks stable blocks in
2330-
// "top-level" mode; the default coalesces a heading into the same raw
2331-
// chunk as the prose that follows it, so appending to that prose
2332-
// re-highlights the heading's already-settled text too — visible as
2333-
// the heading's markers and styling flickering while the paragraph
2334-
// beneath it keeps streaming in. "top-level" keeps the heading its own
2335-
// block, so once it is behind the trailing streaming block it never
2336-
// needs to be recomputed again.
2337-
internalBlockMode: "top-level",
2408+
...markdownBodyOptions(gutter, width),
2409+
content: split?.frozen ?? "",
2410+
streaming: false,
2411+
}),
2412+
)
2413+
column.add(
2414+
new MarkdownRenderable(ctx, {
2415+
...markdownBodyOptions(gutter, width),
2416+
content: split?.live ?? markdownContent(row),
23382417
// Native incremental block stability: only the trailing block is unstable.
23392418
streaming: row.streaming === true,
2419+
marginTop: split?.gapRows ?? 0,
23402420
}),
23412421
)
2342-
return wrapper
2422+
return column
23432423
}
23442424

23452425
/**

0 commit comments

Comments
 (0)