Skip to content

Commit 67327b5

Browse files
committed
Keep transcript headings from re-highlighting while the paragraph below them streams
The default markdown block mode merges a heading into the same raw chunk as the prose that follows it, so every keystroke of that prose re-highlights the heading's already-settled markers and styling too. Switching the transcript's MarkdownRenderable to "top-level" block mode gives a heading its own block, so once it falls behind the streaming tail it is never recomputed again. Pin the table style to "grid" alongside it, since that is the other default the block mode switch would otherwise change.
1 parent cf3bb84 commit 67327b5

2 files changed

Lines changed: 48 additions & 5 deletions

File tree

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

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

66
import { describe, expect, test } from "bun:test"
7+
import { MarkdownRenderable, BoxRenderable } from "@opentui/core"
78
import { withTestRenderer, type Harness } from "./harness"
8-
import { appendStreamRow, createAppShell, replaceStreamRowAt } from "./shell"
9+
import { appendStreamRow, createAppShell, createStreamRowRenderable, replaceStreamRowAt } from "./shell"
910
import { isMarkdownRow } from "./stream"
1011

1112
const WIDE = { width: 80, height: 24 } as const
@@ -15,11 +16,17 @@ const shellOpts = {
1516
wireKeys: false,
1617
} as const
1718

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

@@ -144,4 +151,28 @@ describe("markdown transcript rows", () => {
144151
expect(next).not.toContain("#### Title")
145152
}, WIDE)
146153
})
154+
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
157+
// chunk as the paragraph that follows it, so every keystroke of that
158+
// paragraph re-highlights the heading's already-settled text too — the
159+
// 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.
163+
await withTestRenderer(async (h) => {
164+
const shell = createAppShell(h.renderer, shellOpts)
165+
const node = createStreamRowRenderable(shell, {
166+
role: "assistant",
167+
streaming: true,
168+
text: ["### Title", "", "Some body text."].join("\n"),
169+
})
170+
expect(node).toBeInstanceOf(BoxRenderable)
171+
const [, bodyNode] = (node as BoxRenderable).getChildren()
172+
expect(bodyNode).toBeInstanceOf(MarkdownRenderable)
173+
expect((bodyNode as MarkdownRenderable).internalBlockMode).toBe(
174+
"top-level",
175+
)
176+
}, WIDE)
177+
})
147178
})

src/tui-opentui/shell.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2231,6 +2231,9 @@ function markdownBodyColumns(gutter: PaintedStreamLine, layout: RowLayout): numb
22312231
const TRANSCRIPT_TABLE_OPTIONS = {
22322232
wrapMode: "word",
22332233
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",
22342237
} as const
22352238

22362239
/**
@@ -2323,6 +2326,15 @@ function buildRowNode(
23232326
width: markdownBodyColumns(gutter, layout),
23242327
flexShrink: 0,
23252328
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",
23262338
// Native incremental block stability: only the trailing block is unstable.
23272339
streaming: row.streaming === true,
23282340
}),

0 commit comments

Comments
 (0)