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:
1 parent
ab6a797941
commit
b0b91ad28f
3 files changed
+71
-21
No files matched your search
+26
-12
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in new issue
Block a user