44 */
55
66import { describe , expect , test } from "bun:test"
7+ import { MarkdownRenderable , BoxRenderable , type CapturedSpan } from "@opentui/core"
78import { 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"
916import { isMarkdownRow } from "./stream"
1017
1118const 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+ */
1931async 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