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
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.