Warm a fence's highlighting like a parse, and page the restore by rows

Two things the measurements for the previous commit turned up.

Highlighting a fence cost 174ms for a two-hundred-line Kotlin block, and
the lazy list charged it again every time that block scrolled back into
composition -- six times in one bench run, with the scroll's draw phase at
1.29ms per frame. So it is warmed and cached where parses already are:
`highlight` is a plain function taking no colour from the theme, `warm`
fills `ParsedReplies.highlighted` from `fences(parse)` off the drawing
thread, and `fenceContent` extracts the code here rather than through the
library's composable, so the string warmed is the string drawn.

And the anchor restore asked for a span counted in events, which goes
negative when the anchor's row is the oldest half-row and was coerced to
one -- a request per delta, six hundred round trips walking one reply back
a word at a time with the spinner up. It asks for a page of rows now.

Clean pairs, fresh sessions each side, same gestures. Fence scroll
(transcript-bench.sh): draw phase 0.74ms per frame before highlighting
existed, 0.77ms after, no lexing in the window either side. Streaming
forty linked items (stream-bench.sh): 2412ms of reparsing before, 674ms
after; mean 5.0ms to 1.4ms, worst 8.9ms to 7.9ms. Bullet glyphs, fence
colours, the image links and the reference link checked on the emulator;
lint clean on AGP 9.4.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-03 18:50:29 -04:00
1 parent 6892dc7caf
commit ab6a797941
6 files changed
+316 -120

No files matched your search

+136 -75
View File
@@ -162,92 +162,153 @@ and are the reason several tempting simplifications were rejected.
## Session of 2026-09-03: the list above, worked through ## Session of 2026-09-03: the list above, worked through
Items 1-5 of the previous list are implemented and build; the AGP bump from Items 1-5 of the previous list are done and checked on the emulator, and so
item 7 is in. What is *not* done is in "What is next" below, and the state is the AGP bump from item 7. Two things were found while measuring them: a
of each measurement is stated honestly here so nothing has to be re-derived. 174ms stall this work introduced and then removed, and a restore bug that
predates it. Both are described below with their numbers.
**1. Styled markers per depth -- done, unverified on screen.** **1. Styled markers per depth.** `MarkdownListItem`'s `Marker` draws `•`,
`MarkdownListItem`'s `Marker` draws `•`, `◦`, `▪` by depth (cycling) in `◦`, `▪` by depth, cycling past the third, in `listMarkerColor` (Theme.kt,
`listMarkerColor` (Theme.kt, Lavender: the scheme's secondary accent, which Lavender -- the scheme's secondary accent, which nothing else used, so it
nothing else used, so it now means "structure"). Ordered numbers take the now means "structure"). Ordered numbers take the same colour. All three
same colour. Still to check on the emulator: that `◦` and `▪` are in the glyphs were checked on screen at four depths: they render from the system
system fonts rather than drawing as boxes -- the comment on `BULLETS` claims fonts, no missing-glyph boxes. The colour is the same at every depth on
they were checked, and that check is what the next session owes it. purpose -- depth is said by the glyph and the indent, and a colour per depth
would make a difference in degree look like one in kind.
**2. Syntax highlighting inside fences -- done, measured before only.** **2. Syntax highlighting inside fences.** `CodeFence.kt` holds it:
`CodeFence.kt` holds `highlighted` (moved out of `ToolInput.kt`, timed as `highlight` (moved out of `ToolInput.kt`, so a reply's code and a tool
`code highlighted`), the `fenceLanguage` alias table (extension or name to call's command are the same colours), the `fenceLanguage` alias table
the highlights lexer; unknown words stay plain on purpose), and `CodeFence` (extension or name to the highlights lexer; a word not in it stays plain,
/ `CodeBlock`, registered as the component table's `codeFence`/`codeBlock`. because a fence coloured by the wrong language's rules looks highlighted and
The library's `MarkdownCodeFence` still finds the code inside the node; the is wrong in a way the reader cannot see), `fenceContent`, and the
drawing is ours (same background, corner, padding and sideways scroll, minus `codeFence`/`codeBlock` entries of the component table.
the shadow, border and empty pointer handler). Baseline `transcript-bench.sh`
on the fixture below, before the change: draw phase 0.72ms/frame, transcript The measurement is the reason there is a cache. Highlighted naively, with
0.36ms, the 200-line kotlin fence one 10,700px block. The after run has not the answer held by a `remember` inside the fence, a two-hundred-line Kotlin
been taken. fence cost **174ms to lex** and the lazy list charged it again every time
the block scrolled back into composition -- six times in one bench run,
1043ms of lexing, and the scroll's draw phase up at 1.29ms per frame. So
highlighting is now warmed and cached exactly as parsing is
(`ParsedReplies.highlighted`, filled by `warm` from `fences(parse)`), and
`highlight` is a plain function taking no colour from the theme, which is
what lets it run off the drawing thread.
Because the warming has to ask for the same string the drawing does,
`fenceContent` extracts the code and the language word itself -- the rule
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.
Clean pair, two fresh sessions of the same 200-line Kotlin fence, no saved
anchor, same gestures (`transcript-bench.sh`):
| | before (no highlighting) | after (warmed) |
|---|---|---|
| draw phase per frame | 0.74ms | 0.77ms |
| the transcript's share | 0.33ms | 0.33ms |
| lexing during the scroll | none | none |
**3. `MarkdownRoot` no longer calls the library's `Markdown()`.** It **3. `MarkdownRoot` no longer calls the library's `Markdown()`.** It
provides the eight locals itself (`LocalReferenceLinkHandler` from the provides the locals itself -- reference links from the parse, padding,
parse, padding, dimens, colours, typography, a no-op image transformer, dimens, colours, typography, a no-op image transformer, animations,
animations, components). components. Nothing between a piece and the screen is the library's now
except the leaf composables named in the component table.
**4. Paragraphs with images -- done differently from the plan.** The plan **4. Paragraphs with images -- done differently from the plan.** The plan
said draw the image as its own piece; the app has no image loader and the said draw the image as its own piece; measuring first showed the app has no
renderer's transformer was the no-op one, so an image in a reply drew as image loader and the renderer's transformer was the no-op one, so an image
*nothing*. An `IMAGE` node is now appended by `appendPlainLink` as a link in a reply drew as *nothing at all*. An `IMAGE` node is now appended by
carrying its alt text (the address when there is none), and with no image to `appendPlainLink` as a link carrying its alt text (the address when there is
place the `hasImage` branch and the renderer's `MarkdownText` are gone: none), which says what was there and opens it. With no image to place, the
every paragraph is the platform `BasicText`. `hasImage` branch and the renderer's `MarkdownText` are gone: every
paragraph is the platform `BasicText`.
**5. Per-item units for a streaming list -- done, before measured, after **5. Per-item units for a streaming list.** `LiveParse.advanceTo` now cuts
not.** `LiveParse.advanceTo` cuts at the last item of a multi-item list at the last item of a multi-item list (`openPiece`), provided that item has
(`openPiece`), provided that item has content beyond its marker (a bare `-` content beyond its marker -- a bare `-` is an empty item now and the first
may still become a paragraph line); the cut is at the start of the item's character of a paragraph line once `-x` arrives, so cutting on it would draw
line so indentation survives the reparse. `Segment.continues` marks a tail that line as a new item. The cut is at the start of the item's line, so the
that carries on a list, and `MarkdownPiece`'s `continuesList`/`listContinues` indentation the reparse reads its nesting from survives.
keep the padding of an inner item at the seam so nothing moves when it `Segment.continues` marks a tail that carries on a list, and
does. Measured with the new `app/stream-bench.sh` streaming `MarkdownPiece`'s `continuesList`/`listContinues` keep an inner item's
`/tmp/longlist.md` (forty bullet items with a link each) on the old build: padding at the seam, so nothing moves when the seam does.
`markdown reparsed while streaming: 483, 3.9ms mean, 11.6ms worst`,
`record: one block` worst 1.6ms. The same run on the new build printed an Clean pair, two fresh sessions, forty linked bullet items streamed word at a
empty report -- the first thing to look at (the screen showed the list time (`stream-bench.sh /tmp/longlist.md`):
drawn with `•` markers, so the build runs; the report tap or the idle wait
may have misfired). | | before | after |
|---|---|---|
| reparses while streaming | 482 | 483 |
| total time in them | 2412ms | 674ms |
| mean / worst | 5.0ms / 8.9ms | 1.4ms / 7.9ms |
| `record: one block` worst | 1.8ms | 0.7ms |
The worst case moves least, which is the shape to expect: the first reparse
of a tail still covers whatever has arrived, and the last item can be long.
What changes is that every reparse after it covers one item instead of the
whole list.
**The restore walked back one event per request.** Found while benching, and
older than this work. `savedAnchor`'s loop asked for
`oldestSeq - anchor.seq + RESTORE_PAGE_CUSHION` events; when the anchor's row
is already loaded but is the oldest half-row (which `anchorRow` refuses,
correctly -- it grows when the page behind it lands), that span is negative
and was coerced to 1. So the restore fetched one event, then one more, at a
round trip each: six hundred requests walking a long reply back a word at a
time, with the screen on its spinner the whole way and the sandbox log
printing `limit=1` once a second. It now asks for a page counted in rows,
which is the only kind that can promise to reach the row behind the anchor.
**Harness.** `app/stream-bench.sh [-k] FILE` is `transcript-bench.sh` for a **Harness.** `app/stream-bench.sh [-k] FILE` is `transcript-bench.sh` for a
reply still arriving: opens the first session, resets the report, sends reply still arriving: opens the first session, taps the app's own "Jump to
FILE through `ui-sandbox.sh send`, waits for idle, prints the report. latest" so the list is pinned to the newest end, resets the report, sends
Fixtures used this session, all in `/tmp` (regenerate from the shapes FILE, waits for the transcript to stop growing, prints the report. Both of
named): `fixture.md` (lists three deep, ordered and nested, fences in those last two are corrections to a first version that measured nothing:
a transcript parked further back never redraws while a reply streams into it
(the list must not move under a reader), and a session is idle at *both*
ends of a turn, so polling for idle answers before the turn has started.
Fixtures live in `/tmp` and are regenerated from the shapes named here:
`fixture.md` (lists four deep, ordered and nested, fences in
kotlin/rust/sh/none, a table with a link, a quote with a list, an inline and kotlin/rust/sh/none, a table with a link, a quote with a list, an inline and
a standalone image, a reference link), `longfence.md` (200-line kotlin a standalone image, a reference link), `longfence.md` (200-line Kotlin
fence), `longlist.md` (40 linked items), `numlist.md`. fence), `longlist.md` (40 linked items).
**Seen and not chased: a reconnect loop.** On the *old* build, restarting **Two traps in the emulator loop**, both of which cost a bench run here.
the app onto the fixture session with a saved anchor mid-transcript while a `adb shell pm clear` removes the enrolment and the notification permission
600-delta reply was streaming left the screen on a spinner, reconnecting along with the saved anchors, so the next run measures a permission dialog;
every 1.5s (`RECONNECT_DELAY_MS`) with `session screen recomposed: 26` and re-enrol with the command `ui-sandbox.sh` prints and
the fence message re-warmed each time, until the sandbox server was `pm grant … POST_NOTIFICATIONS`. And a saved anchor is per session id, so
restarted. `events?after=N` more than `CATCH_UP_LIMIT` (200) behind answers the only way two builds start a scroll from the same place is a *fresh
`reset` plus the newest 200 *raw* deltas, i.e. a window starting session for each*.
mid-message; the restore loop and that reset clearing `items` look like the
two halves. Reproduce with `stream-bench.sh` (restart form) after a
`transcript-bench.sh` run has left an anchor mid-fence.
## What is next, in order ## What is next, in order
1. **Look at the fixture on the emulator** (session `fixture2` in the 1. **A fence still arriving is lexed per delta.** `warm` covers settled
sandbox holds only `fixture.md`): bullet glyphs at three depths, fence messages; the live tail's fence is highlighted on the composing thread by
colours, the image drawn as a link, the reference link at the foot. `ParsedReplies.highlighted`'s inline miss, once per delta, and at 174ms
2. **Take the after measurements**: `transcript-bench.sh` for the fence, for a long one that is the stall above wearing a different hat. The tail
`stream-bench.sh /tmp/longlist.md` for the list, and put both pairs in is short while it is being written, so this may already be cheap -- but
the commit message. Find out why the after run's report was empty. it is unmeasured, and `stream-bench.sh /tmp/longfence.md` is the run that
3. **Lint** (`./gradlew :androidApp:lint`) on AGP 9.4.0; the bump is in says. Cheapest fix if it bites: highlight the live tail only when it is
`libs.versions.toml` and the build passed, lint has not been run. under some length, or move the miss off-thread the way `LiveParse` moved
4. **The reconnect loop above.** parsing, keeping the plain text until the answer lands.
5. **File the highlights range bug upstream** -- no `gh` in this VM and no 2. **The reconnect loop.** Restarting the app onto a session with a saved
GitHub credential, so it needs Bryan or a token. One-line repro: lexing anchor while a long reply was streaming left it reconnecting every 1.5s
`x '*/a/*'` as `SyntaxLanguage.SHELL` in highlights 1.1.0 returns a (`RECONNECT_DELAY_MS`), spinner up, until the server was restarted.
highlight whose `location.end` precedes its `location.start`. `events?after=N` more than `CATCH_UP_LIMIT` (200) behind answers `reset`
6. Everything from the earlier list that still stands: regression runs plus the newest 200 *raw* deltas -- a window starting mid-message -- and
before and after any change to these files, pasted into the commit. 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
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
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.
@@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicText
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -24,13 +23,16 @@ import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.compose.LocalMarkdownColors import com.mikepenz.markdown.compose.LocalMarkdownColors
import com.mikepenz.markdown.compose.LocalMarkdownDimens import com.mikepenz.markdown.compose.LocalMarkdownDimens
import com.mikepenz.markdown.compose.LocalMarkdownPadding import com.mikepenz.markdown.compose.LocalMarkdownPadding
import com.mikepenz.markdown.compose.elements.MarkdownCodeBlock import com.mikepenz.markdown.model.State
import com.mikepenz.markdown.compose.elements.MarkdownCodeFence
import dev.snipme.highlights.Highlights import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.BoldHighlight import dev.snipme.highlights.model.BoldHighlight
import dev.snipme.highlights.model.ColorHighlight import dev.snipme.highlights.model.ColorHighlight
import dev.snipme.highlights.model.SyntaxLanguage import dev.snipme.highlights.model.SyntaxLanguage
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.findChildOfType
import org.intellij.markdown.ast.getTextInNode
/** /**
* A fenced code block in a reply: the code highlighted, on the dark surface every verbatim thing * A fenced code block in a reply: the code highlighted, on the dark surface every verbatim thing
@@ -47,18 +49,44 @@ import org.intellij.markdown.ast.ASTNode
* 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) { fun CodeFence(content: String, node: ASTNode, style: TextStyle, replies: ParsedReplies) {
MarkdownCodeFence(content, node, style) { code, language, codeStyle -> val (code, language) = remember(content, node) { fenceContent(content, node) } ?: return
CodeBlockText(code, language, codeStyle) CodeBlockText(code, language, style, replies)
}
} }
/** 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) { fun CodeBlock(content: String, node: ASTNode, style: TextStyle, replies: ParsedReplies) {
MarkdownCodeBlock(content, node, style) { code, language, codeStyle -> val (code, language) = remember(content, node) { fenceContent(content, node) } ?: return
CodeBlockText(code, language, codeStyle) CodeBlockText(code, language, style, replies)
}
/**
* The code inside a fence or indented block, and the lexer's language for its info word.
*
* Which children of the node are the fence markers, the language word and the code between them is
* the library's knowledge of the parser, copied from its `MarkdownCodeFence` rather than called:
* that one is a composable, and the whole point of this function is that [warm] can run it on a
* background thread and highlight the same string the drawing will ask for. Two extractions would
* be two keys, and the warmed answer would be silently missed at every fence.
*
* Null for a fence too short to hold anything -- an unterminated one still arriving, which the
* library skips as invalid.
*/
fun fenceContent(content: String, node: ASTNode): Pair<String, SyntaxLanguage?>? {
val word =
node.findChildOfType(MarkdownTokenTypes.FENCE_LANG)?.getTextInNode(content)?.toString()
val language = fenceLanguage(word)
if (node.type == MarkdownElementTypes.CODE_BLOCK) {
val start = node.children.firstOrNull()?.startOffset ?: return null
val end = node.children.lastOrNull()?.endOffset ?: return null
return content.substring(start, end).replaceIndent() to language
} }
if (node.children.size < 3) return null
val start = node.children[2].startOffset
val fenceCount = if (word != null && node.children.size > 3) 3 else 2
val end = node.children[(node.children.size - 2).coerceAtLeast(fenceCount)].endOffset
return content.substring(start, end).replaceIndent() to language
} }
/** /**
@@ -67,7 +95,12 @@ fun CodeBlock(content: String, node: ASTNode, style: TextStyle) {
* 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.
*/ */
@Composable @Composable
private fun CodeBlockText(code: String, language: String?, style: TextStyle) { private fun CodeBlockText(
code: String,
language: SyntaxLanguage?,
style: TextStyle,
replies: ParsedReplies,
) {
val colors = LocalMarkdownColors.current val colors = LocalMarkdownColors.current
val dimens = LocalMarkdownDimens.current val dimens = LocalMarkdownDimens.current
val padding = LocalMarkdownPadding.current val padding = LocalMarkdownPadding.current
@@ -78,7 +111,7 @@ private fun CodeBlockText(code: String, language: String?, style: TextStyle) {
.semantics { isTraversalGroup = true } .semantics { isTraversalGroup = true }
) { ) {
BasicText( BasicText(
highlighted(code, fenceLanguage(language)), replies.highlighted(code, language),
style = style, style = style,
modifier = Modifier.horizontalScroll(rememberScrollState()).padding(padding.codeBlock), modifier = Modifier.horizontalScroll(rememberScrollState()).padding(padding.codeBlock),
) )
@@ -147,31 +180,32 @@ private val FENCE_LANGUAGES: Map<String, SyntaxLanguage> =
* else. Shared by a tool call's input ([ToolInputView]) and a reply's fences ([CodeFence]), so the * else. Shared by a tool call's input ([ToolInputView]) and a reply's fences ([CodeFence]), so the
* same code is the same colours wherever it appears. * same code is the same colours wherever it appears.
* *
* Timed, because a fence is highlighted whole and a reply still arriving re-highlights its last * Not a composable, and it takes no colour from the theme, because that is what lets [warm] run it
* block on every delta; the counter says what that costs before anybody has to guess. * off the drawing thread: the syntax palette is fixed, and a fence with no language is plain text
* which needs no colour of its own -- the style the caller draws it with carries that.
*
* Measured on the emulator before it was cached: a two-hundred-line Kotlin fence costs **174ms** to
* lex, and the lazy list charged that again every time the block scrolled back into composition.
* That is the whole reason [ParsedReplies.highlighted] exists rather than a `remember`.
*/ */
@Composable fun highlight(code: String, language: SyntaxLanguage?): AnnotatedString {
fun highlighted(code: String, language: SyntaxLanguage?): AnnotatedString { if (language == null) return AnnotatedString(code)
val theme = catppuccinSyntax()
val plain = MaterialTheme.colorScheme.onSurface
return remember(code, language, theme, plain) {
if (language == null) return@remember AnnotatedString(code)
val marks = val marks =
DebugStats.timed("code highlighted") { DebugStats.timed("code highlighted") {
Highlights.Builder(code = code, language = language, theme = theme) Highlights.Builder(code = code, language = language, theme = catppuccinSyntax())
.build() .build()
.getHighlights() .getHighlights()
// highlights 1.1.0's shell lexer answers a quoted glob that looks like a // highlights 1.1.0's shell lexer answers a quoted glob that looks like a comment
// comment -- `x '*/a/*'` is the smallest input -- with a span whose end is // -- `x '*/a/*'` is the smallest input -- with a span whose end is before its
// before its start, and AnnotatedString refuses such a range. That crashed // start, and AnnotatedString refuses such a range. That crashed the app the
// the app the moment a card holding `-path '*/.git/*'` was opened. Dropped // moment a card holding `-path '*/.git/*'` was opened. Dropped rather than
// rather than clamped: a span the lexer got backwards is not one it knows // clamped: a span the lexer got backwards is not one it knows the colour of.
// the colour of. Delete when snipme/highlights fixes it. // Delete when snipme/highlights fixes it.
.filter { .filter {
it.location.start in 0..it.location.end && it.location.end <= code.length it.location.start in 0..it.location.end && it.location.end <= code.length
} }
} }
buildAnnotatedString { return buildAnnotatedString {
append(code) append(code)
marks.forEach { mark -> marks.forEach { mark ->
when (mark) { when (mark) {
@@ -190,5 +224,27 @@ fun highlighted(code: String, language: SyntaxLanguage?): AnnotatedString {
} }
} }
} }
}
/**
* Every fence in [parse], as the code and language [highlight] will be asked for.
*
* Walks the whole tree rather than the top level: a fence inside a list item or a quote is drawn
* the same way and costs the same to lex.
*/
fun fences(parse: State): List<Pair<String, SyntaxLanguage?>> {
val success = parse as? State.Success ?: return emptyList()
val out = ArrayList<Pair<String, SyntaxLanguage?>>()
fun walk(node: ASTNode) {
if (
node.type == MarkdownElementTypes.CODE_FENCE ||
node.type == MarkdownElementTypes.CODE_BLOCK
) {
fenceContent(success.content, node)?.let { if (it.second != null) out += it }
return
} }
node.children.forEach(::walk)
}
walk(success.node)
return out
} }
@@ -27,6 +27,7 @@ import androidx.compose.ui.semantics.collectionInfo
import androidx.compose.ui.semantics.collectionItemInfo import androidx.compose.ui.semantics.collectionItemInfo
import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@@ -54,6 +55,7 @@ import com.mikepenz.markdown.model.markdownAnimations
import com.mikepenz.markdown.model.markdownDimens import com.mikepenz.markdown.model.markdownDimens
import com.mikepenz.markdown.model.markdownPadding import com.mikepenz.markdown.model.markdownPadding
import com.mikepenz.markdown.model.parseMarkdown import com.mikepenz.markdown.model.parseMarkdown
import dev.snipme.highlights.model.SyntaxLanguage
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -91,7 +93,7 @@ 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) { MarkdownRoot(segment.parse, replies) {
segment.pieces.forEachIndexed { index, piece -> segment.pieces.forEachIndexed { index, piece ->
val gap = val gap =
when { when {
@@ -305,7 +307,7 @@ fun MarkdownPiece(
// Remembered so a message the flatten drew before [warm] reached it is parsed once here, not // Remembered so a message the flatten drew before [warm] reached it is parsed once here, not
// once per composition. // once per composition.
val parse = remember(text) { replies.of(text) } val parse = remember(text) { replies.of(text) }
MarkdownRoot(parse) { MarkdownPiece(parse, text, piece, modifier) } MarkdownRoot(parse, replies) { MarkdownPiece(parse, text, piece, modifier) }
} }
/** /**
@@ -328,7 +330,7 @@ fun MarkdownPiece(
* 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.
*/ */
@Composable @Composable
private fun MarkdownRoot(parse: State, content: @Composable () -> Unit) { private fun MarkdownRoot(parse: State, replies: ParsedReplies, 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()
@@ -458,8 +460,8 @@ private fun MarkdownRoot(parse: State, content: @Composable () -> Unit) {
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) }, codeFence = { CodeFence(it.content, it.node, it.typography.code, replies) },
codeBlock = { CodeBlock(it.content, it.node, it.typography.code) }, codeBlock = { CodeBlock(it.content, it.node, it.typography.code, replies) },
), ),
content = content, content = content,
) )
@@ -597,6 +599,17 @@ class ParsedReplies {
private val chunks = ConcurrentHashMap<String, List<String>>() private val chunks = ConcurrentHashMap<String, List<String>>()
/**
* Each fence's coloured text, keyed by its language and code.
*
* Beside the parses for the same reason and at the same cost: lexing is proportional to how
* much code was written -- a two-hundred-line Kotlin fence measured 174ms on the emulator --
* and a lazy list drops the composition of a block that scrolls away, so a `remember` inside
* the fence paid that again every time the reader came back to it. Six times in one scroll,
* measured. [warm] fills this off the drawing thread before the row is reached.
*/
private val highlights = ConcurrentHashMap<String, AnnotatedString>()
private val ready = ConcurrentHashMap.newKeySet<String>() private val ready = ConcurrentHashMap.newKeySet<String>()
/** The pieces of [text], from its parse -- made now if [warm] has not made it. */ /** The pieces of [text], from its parse -- made now if [warm] has not made it. */
@@ -633,6 +646,15 @@ class ParsedReplies {
DebugStats.timed("message cut into parts") { messageParts(it) } DebugStats.timed("message cut into parts") { messageParts(it) }
} }
/**
* [code] coloured for [language] -- the answer made ahead, or one made now.
*
* The key carries the language, because the same code lexes differently under two of them.
*/
fun highlighted(code: String, language: SyntaxLanguage?): AnnotatedString =
if (language == null) AnnotatedString(code)
else highlights.computeIfAbsent("$language\n$code") { highlight(code, language) }
/** The parse of [text] -- the one made ahead, or one made now. */ /** The parse of [text] -- the one made ahead, or one made now. */
fun of(text: String): State = fun of(text: String): State =
parsed[text]?.also { DebugStats.count("markdown ready") } parsed[text]?.also { DebugStats.count("markdown ready") }
@@ -649,9 +671,15 @@ class ParsedReplies {
*/ */
suspend fun warm(texts: List<String>) { suspend fun warm(texts: List<String>) {
texts.forEach { text -> texts.forEach { text ->
val parse =
parsed.computeIfAbsent(text) { parsed.computeIfAbsent(text) {
DebugStats.timed("markdown warmed") { parseMarkdown(it) } DebugStats.timed("markdown warmed") { parseMarkdown(it) }
} }
// The fences too, and here rather than in a pass of its own: they are found in the
// parse this just made, and lexing one is the same kind of cost as parsing the
// message it is in -- proportional to what was written, and charged to the frame
// that first draws it if nobody paid it earlier.
fences(parse).forEach { (code, language) -> highlighted(code, language) }
} }
} }
@@ -661,6 +689,7 @@ class ParsedReplies {
pieces.clear() pieces.clear()
parts.clear() parts.clear()
chunks.clear() chunks.clear()
highlights.clear()
ready.clear() ready.clear()
} }
} }
@@ -711,14 +711,26 @@ fun SessionScreen(
// Raw, not coalesced: this counts events back to a known seq, and a page // Raw, not coalesced: this counts events back to a known seq, and a page
// measured in rows cannot be counted to a seq. `RESTORE_PAGE_MAX` and the loop // measured in rows cannot be counted to a seq. `RESTORE_PAGE_MAX` and the loop
// bound it; see [loadOlderPage]. // bound it; see [loadOlderPage].
val span = oldestSeq - anchor.seq + RESTORE_PAGE_CUSHION val behind = oldestSeq - anchor.seq
if ( val loaded =
!loadOlderPage( if (behind < 0) {
span.coerceIn(1L, RESTORE_PAGE_MAX.toLong()).toInt(), // The anchor's row is loaded but is the oldest half-row, which
// [anchorRow] refuses; what completes it is the row before it,
// and only a page counted in rows can promise to reach that.
// Counted in events, the span here is negative and was coerced
// to one: a request per delta, walking a long reply back one word
// at a time -- six hundred round trips and a spinner for all of
// them, seen 2026-09-03 with an anchor inside a 1,400-delta reply.
loadOlderPage()
} else {
loadOlderPage(
(behind + RESTORE_PAGE_CUSHION)
.coerceIn(1L, RESTORE_PAGE_MAX.toLong())
.toInt(),
coalesce = false, coalesce = false,
) )
) }
break if (!loaded) break
} }
// Resolved to the row that *holds* the saved position rather than passed // Resolved to the row that *holds* the saved position rather than passed
// straight through, because the two are not always the same seq: the events // straight through, because the two are not always the same seq: the events
@@ -117,7 +117,9 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
// Not wrapped: a wrapped command hides where its arguments end, // Not wrapped: a wrapped command hides where its arguments end,
// and the long one is the one being read closely. // and the long one is the one being read closely.
Text( Text(
highlighted(subject, parsed.language), // Not cached: a tool's subject is one command line, which lexes in microseconds
// -- the cache exists for a fence with two hundred lines in it.
remember(subject, parsed.language) { highlight(subject, parsed.language) },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
softWrap = false, softWrap = false,
+41 -5
View File
@@ -38,17 +38,53 @@ if [ -z "$keep" ]; then
sleep 3 sleep 3
fi fi
# Pinned to the newest end before anything is sent. The transcript never
# moves under a reader who is further back (see TranscriptList), so a reply
# streaming into a session parked at an older row arrives entirely
# off-screen: nothing recomposes, nothing draws, and the report comes back
# with two recompositions in it and no streaming counters at all. That
# reads exactly like a build where the work vanished. The control is the
# app's own "Jump to latest", which is only there while the newest message
# is off screen -- so when it is absent the list is already where it needs
# to be.
jump=$(ui-trace record -d 1200 -o /tmp/bench-jump.txt >/dev/null 2>&1
ui-trace show /tmp/bench-jump.txt -m 'Jump to latest' --field box |
grep -o '[0-9]*,[0-9]*\.\.[0-9]*,[0-9]*' | tail -1)
if [ -n "$jump" ]; then
x=$(echo "$jump" | awk -F'[,.]' '{print int(($1 + $4) / 2)}')
y=$(echo "$jump" | awk -F'[,.]' '{print int(($2 + $5) / 2)}')
ui-trace record -d 1500 --do "tap $x $y" -o /tmp/bench-tolatest.txt >/dev/null 2>&1
fi
# The first tap resets the report's window; see transcript-bench.sh. # The first tap resets the report's window; see transcript-bench.sh.
ui-trace record -d 2000 --do 'tap 723 205' -o /tmp/bench-reset.txt >/dev/null 2>&1 ui-trace record -d 2000 --do 'tap 723 205' -o /tmp/bench-reset.txt >/dev/null 2>&1
adb logcat -c adb logcat -c
./ui-sandbox.sh send "$sid" "@$file" >/dev/null ./ui-sandbox.sh send "$sid" "@$file" >/dev/null
# Until the echo has finished: its status goes back to idle.
# Until the reply has finished, measured by the transcript rather than by
# the status. A session is idle at both ends of a turn, and polling for
# "idle" answers on the first poll -- before the turn has even started --
# so the report then covers the moment between the send and the first
# delta, and prints a window with nothing in it. The event count only
# grows, so "it stopped growing" is the one signal that cannot be true
# before the work begins.
events() {
./ui-sandbox.sh api "/sessions/$sid/transcript?limit=1" |
python3 -c 'import json,sys; d=json.load(sys.stdin); print(d[-1]["seq"] if d else 0)'
}
last=""
still=0
i=0 i=0
while [ "$i" -lt 120 ]; do while [ "$i" -lt 180 ]; do
status=$(./ui-sandbox.sh api "/sessions/$sid" | now=$(events)
python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])') if [ "$now" = "$last" ]; then
[ "$status" = idle ] && break still=$((still + 1))
[ "$still" -ge 2 ] && break
else
still=0
fi
last=$now
sleep 2 sleep 2
i=$((i + 1)) i=$((i + 1))
done done