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 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-03 18:58:55 -04:00
1 parent ab6a797941
commit b0b91ad28f
3 files changed
+71 -21

No files matched your search

+26 -12
View File
@@ -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 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. 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 Clean pair, two fresh sessions of the same 200-line Kotlin fence, no saved
anchor, same gestures (`transcript-bench.sh`): anchor, same gestures (`transcript-bench.sh`):
@@ -209,6 +222,16 @@ anchor, same gestures (`transcript-bench.sh`):
| the transcript's share | 0.33ms | 0.33ms | | the transcript's share | 0.33ms | 0.33ms |
| lexing during the scroll | none | none | | 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 **3. `MarkdownRoot` no longer calls the library's `Markdown()`.** It
provides the locals itself -- reference links from the parse, padding, provides the locals itself -- reference links from the parse, padding,
dimens, colours, typography, a no-op image transformer, animations, dimens, colours, typography, a no-op image transformer, animations,
@@ -284,16 +307,7 @@ session for each*.
## What is next, in order ## What is next, in order
1. **A fence still arriving is lexed per delta.** `warm` covers settled 1. **The reconnect loop.** Restarting the app onto a session with a saved
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
anchor while a long reply was streaming left it reconnecting every 1.5s anchor while a long reply was streaming left it reconnecting every 1.5s
(`RECONNECT_DELAY_MS`), spinner up, until the server was restarted. (`RECONNECT_DELAY_MS`), spinner up, until the server was restarted.
`events?after=N` more than `CATCH_UP_LIMIT` (200) behind answers `reset` `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 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 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. 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 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 repro: lexing `x '*/a/*'` as `SyntaxLanguage.SHELL` in highlights 1.1.0
returns a highlight whose `location.end` precedes its `location.start`. returns a highlight whose `location.end` precedes its `location.start`.
Until then `highlight` drops such spans, which is why the shell fence in Until then `highlight` drops such spans, which is why the shell fence in
`fixture.md` -- whose command contains `'*/.git/*'` -- draws plain while `fixture.md` -- whose command contains `'*/.git/*'` -- draws plain while
an ordinary shell fence colours. 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. 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 The numbers to watch are the worst `record: one block`, the reparse mean
while streaming, and the draw phase's accounting line. while streaming, and the draw phase's accounting line.
@@ -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. * hands out the code and the language and leaves the drawing to the block it is given.
*/ */
@Composable @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 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. */ /** An indented code block, which is a fence with no language word. */
@Composable @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 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<String, SyntaxLanguage?>?
} }
/** /**
* 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 * 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. * 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. * 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?, language: SyntaxLanguage?,
style: TextStyle, style: TextStyle,
replies: ParsedReplies, replies: ParsedReplies,
streaming: Boolean,
) { ) {
val colors = LocalMarkdownColors.current val colors = LocalMarkdownColors.current
val dimens = LocalMarkdownDimens.current val dimens = LocalMarkdownDimens.current
@@ -111,7 +126,9 @@ private fun CodeBlockText(
.semantics { isTraversalGroup = true } .semantics { isTraversalGroup = true }
) { ) {
BasicText( 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, style = style,
modifier = Modifier.horizontalScroll(rememberScrollState()).padding(padding.codeBlock), modifier = Modifier.horizontalScroll(rememberScrollState()).padding(padding.codeBlock),
) )
@@ -93,7 +93,9 @@ fun MarkdownText(
var previousSegment: Segment? = null var previousSegment: Segment? = null
segments.forEachIndexed { at, segment -> segments.forEachIndexed { at, segment ->
val nextContinues = segments.getOrNull(at + 1)?.continues == true 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 -> segment.pieces.forEachIndexed { index, piece ->
val gap = val gap =
when { when {
@@ -328,9 +330,22 @@ fun MarkdownPiece(
* *
* Colours come from the theme rather than from the renderer's defaults, so code, links and rules * 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. * 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 @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) { if (parse !is State.Success) {
// Nothing below needs the environment; [MarkdownPiece] draws the words plainly. // Nothing below needs the environment; [MarkdownPiece] draws the words plainly.
content() content()
@@ -460,8 +475,12 @@ private fun MarkdownRoot(parse: State, replies: ParsedReplies, content: @Composa
unorderedList = { MarkdownList(it.content, it.node, it.listDepth) }, unorderedList = { MarkdownList(it.content, it.node, it.listDepth) },
table = { LinkedTable(it.content, it.node, it.typography.table) }, table = { LinkedTable(it.content, it.node, it.typography.table) },
// Code is highlighted the way a tool call's input is; see [CodeFence]. // Code is highlighted the way a tool call's input is; see [CodeFence].
codeFence = { CodeFence(it.content, it.node, it.typography.code, replies) }, codeFence = {
codeBlock = { CodeBlock(it.content, it.node, it.typography.code, replies) }, CodeFence(it.content, it.node, it.typography.code, replies, streaming)
},
codeBlock = {
CodeBlock(it.content, it.node, it.typography.code, replies, streaming)
},
), ),
content = content, content = content,
) )