From b0b91ad28f041715d25b900938c6b401d23ac72f Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Thu, 3 Sep 2026 18:58:55 -0400 Subject: [PATCH] Draw a fence plain while it is still being written Warming covers a settled message, so it did not reach the block a reply is still writing: that one was re-lexed at every delta, on the composing thread. Measured streaming a two-hundred-line Kotlin fence -- 211 lexes, 13.7 seconds across the turn, the worst 177ms -- for colours on text being replaced as fast as they were computed. A live reply's last segment now draws its code plain and takes its colours when the block freezes, which for a finished fence is as soon as the next block starts. The settle-time lex then happens in warm, off the drawing thread. Same fixture after: one lex of 374ms in warm, draw phase 1.51ms to 1.01ms per frame while streaming, and the fence coloured on screen once the reply settles. Co-Authored-By: Claude Opus 5 --- TRANSCRIPT_RENDERING.md | 38 +++++++++++++------ .../kotlin/com/example/aiapp/CodeFence.kt | 27 ++++++++++--- .../main/kotlin/com/example/aiapp/Markdown.kt | 27 +++++++++++-- 3 files changed, 71 insertions(+), 21 deletions(-) diff --git a/TRANSCRIPT_RENDERING.md b/TRANSCRIPT_RENDERING.md index 0a55345..c06fb70 100644 --- a/TRANSCRIPT_RENDERING.md +++ b/TRANSCRIPT_RENDERING.md @@ -200,6 +200,19 @@ copied from the library's `MarkdownCodeFence`, which is a composable and so cannot be called from `warm`. Two extractions would be two keys, and the warmed answer would be missed at every fence with nothing saying so. +A fence *still arriving* was the same stall in a second place, and the +warming does not reach it: the tail is re-lexed at every delta, on the +composing thread, for colours on text that is being replaced as fast as they +are computed. Measured streaming the same fence: **211 lexes, 13.7 seconds** +across the turn, the worst 177ms. So a block that is still being written is +drawn plain and takes its colours when it freezes -- `MarkdownRoot`'s +`streaming`, which is true only for a live reply's *last* segment, so a +finished fence colours as soon as the next block starts. It is the bargain +[LiveParse] already makes for a reference link defined at the foot of a +message, and the settle-time lex then happens in `warm`, off the drawing +thread: one lex, 374ms, and the row redraws coloured on the tick that +follows it. + Clean pair, two fresh sessions of the same 200-line Kotlin fence, no saved anchor, same gestures (`transcript-bench.sh`): @@ -209,6 +222,16 @@ anchor, same gestures (`transcript-bench.sh`): | the transcript's share | 0.33ms | 0.33ms | | lexing during the scroll | none | none | +Streaming that fence in (`stream-bench.sh /tmp/longfence.md`), before and +after the plain-while-writing rule: + +| | before | after | +|---|---|---| +| lexes during the turn | 211 | 1 (in `warm`, off-thread) | +| time in them | 13,665ms | 374ms | +| worst single lex | 177.1ms | -- | +| draw phase per frame | 1.51ms | 1.01ms | + **3. `MarkdownRoot` no longer calls the library's `Markdown()`.** It provides the locals itself -- reference links from the parse, padding, dimens, colours, typography, a no-op image transformer, animations, @@ -284,16 +307,7 @@ session for each*. ## What is next, in order -1. **A fence still arriving is lexed per delta.** `warm` covers settled - messages; the live tail's fence is highlighted on the composing thread by - `ParsedReplies.highlighted`'s inline miss, once per delta, and at 174ms - for a long one that is the stall above wearing a different hat. The tail - is short while it is being written, so this may already be cheap -- but - it is unmeasured, and `stream-bench.sh /tmp/longfence.md` is the run that - says. Cheapest fix if it bites: highlight the live tail only when it is - under some length, or move the miss off-thread the way `LiveParse` moved - parsing, keeping the plain text until the answer lands. -2. **The reconnect loop.** Restarting the app onto a session with a saved +1. **The reconnect loop.** Restarting the app onto a session with a saved anchor while a long reply was streaming left it reconnecting every 1.5s (`RECONNECT_DELAY_MS`), spinner up, until the server was restarted. `events?after=N` more than `CATCH_UP_LIMIT` (200) behind answers `reset` @@ -301,14 +315,14 @@ session for each*. the reset clears `items`, which is the state the restore loop then pages against. The one-event-per-request bug above was part of what made it so visible; whether it survives that fix is the first thing to find out. -3. **File the highlights range bug upstream.** There is no `gh` and no +2. **File the highlights range bug upstream.** There is no `gh` and no GitHub credential in this VM, so it needs Bryan or a token. One-line repro: lexing `x '*/a/*'` as `SyntaxLanguage.SHELL` in highlights 1.1.0 returns a highlight whose `location.end` precedes its `location.start`. Until then `highlight` drops such spans, which is why the shell fence in `fixture.md` -- whose command contains `'*/.git/*'` -- draws plain while an ordinary shell fence colours. -4. **Regression runs.** `transcript-bench.sh` and `stream-bench.sh` before +3. **Regression runs.** `transcript-bench.sh` and `stream-bench.sh` before and after any change to the files above, with the report in the commit. The numbers to watch are the worst `record: one block`, the reparse mean while streaming, and the draw phase's accounting line. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt index 5866f56..344ef46 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt @@ -49,16 +49,28 @@ import org.intellij.markdown.ast.getTextInNode * hands out the code and the language and leaves the drawing to the block it is given. */ @Composable -fun CodeFence(content: String, node: ASTNode, style: TextStyle, replies: ParsedReplies) { +fun CodeFence( + content: String, + node: ASTNode, + style: TextStyle, + replies: ParsedReplies, + streaming: Boolean = false, +) { val (code, language) = remember(content, node) { fenceContent(content, node) } ?: return - CodeBlockText(code, language, style, replies) + CodeBlockText(code, language, style, replies, streaming) } /** An indented code block, which is a fence with no language word. */ @Composable -fun CodeBlock(content: String, node: ASTNode, style: TextStyle, replies: ParsedReplies) { +fun CodeBlock( + content: String, + node: ASTNode, + style: TextStyle, + replies: ParsedReplies, + streaming: Boolean = false, +) { val (code, language) = remember(content, node) { fenceContent(content, node) } ?: return - CodeBlockText(code, language, style, replies) + CodeBlockText(code, language, style, replies, streaming) } /** @@ -90,6 +102,8 @@ fun fenceContent(content: String, node: ASTNode): Pair? } /** + * Plain while [streaming], coloured once the block is finished; see [MarkdownRoot]. + * * The renderer's own block, less what nothing here needs: the same background, corner, padding and * sideways scroll, without the shadow, the border and the empty pointer handler it also carried. * The vertical margin is the renderer's too, kept so a reply's fences sit where they always have. @@ -100,6 +114,7 @@ private fun CodeBlockText( language: SyntaxLanguage?, style: TextStyle, replies: ParsedReplies, + streaming: Boolean, ) { val colors = LocalMarkdownColors.current val dimens = LocalMarkdownDimens.current @@ -111,7 +126,9 @@ private fun CodeBlockText( .semantics { isTraversalGroup = true } ) { BasicText( - replies.highlighted(code, language), + // No language while the block is still being written, which is what draws it plain; + // see [MarkdownRoot]'s `streaming`. + replies.highlighted(code, language.takeUnless { streaming }), style = style, modifier = Modifier.horizontalScroll(rememberScrollState()).padding(padding.codeBlock), ) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt index 41f1a7d..a95074f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt @@ -93,7 +93,9 @@ fun MarkdownText( var previousSegment: Segment? = null segments.forEachIndexed { at, segment -> val nextContinues = segments.getOrNull(at + 1)?.continues == true - MarkdownRoot(segment.parse, replies) { + // Only the tail is still being written; a frozen segment is finished text that + // happens to sit in a live reply, and it takes its colours now. See [MarkdownRoot]. + MarkdownRoot(segment.parse, replies, streaming = live && at == segments.lastIndex) { segment.pieces.forEachIndexed { index, piece -> val gap = when { @@ -328,9 +330,22 @@ fun MarkdownPiece( * * Colours come from the theme rather than from the renderer's defaults, so code, links and rules * are the same Catppuccin values the rest of the app uses. Nothing here picks a colour of its own. + * + * [streaming] says this parse is the part of a reply still being written, which only the fences + * care about: lexing is proportional to how much code there is, and a fence still arriving is + * re-lexed at every delta on the composing thread. Measured streaming a two-hundred-line Kotlin + * fence: **13.7 seconds** of lexing across the turn, 211 of them, the worst 177ms -- for colours on + * text that was being replaced as fast as they were computed. So a fence still being written is + * drawn plain and takes its colours when the block freezes, which is the same bargain [LiveParse] + * already makes for a reference link defined at the foot of a message. */ @Composable -private fun MarkdownRoot(parse: State, replies: ParsedReplies, content: @Composable () -> Unit) { +private fun MarkdownRoot( + parse: State, + replies: ParsedReplies, + streaming: Boolean = false, + content: @Composable () -> Unit, +) { if (parse !is State.Success) { // Nothing below needs the environment; [MarkdownPiece] draws the words plainly. content() @@ -460,8 +475,12 @@ private fun MarkdownRoot(parse: State, replies: ParsedReplies, content: @Composa unorderedList = { MarkdownList(it.content, it.node, it.listDepth) }, table = { LinkedTable(it.content, it.node, it.typography.table) }, // Code is highlighted the way a tool call's input is; see [CodeFence]. - codeFence = { CodeFence(it.content, it.node, it.typography.code, replies) }, - codeBlock = { CodeBlock(it.content, it.node, it.typography.code, replies) }, + codeFence = { + CodeFence(it.content, it.node, it.typography.code, replies, streaming) + }, + codeBlock = { + CodeBlock(it.content, it.node, it.typography.code, replies, streaming) + }, ), content = content, )