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

@@ -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<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
* 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),
)
@@ -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,
)