diff --git a/TRANSCRIPT_RENDERING.md b/TRANSCRIPT_RENDERING.md index d65f724..0a55345 100644 --- a/TRANSCRIPT_RENDERING.md +++ b/TRANSCRIPT_RENDERING.md @@ -162,92 +162,153 @@ and are the reason several tempting simplifications were rejected. ## 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 -item 7 is in. What is *not* done is in "What is next" below, and the state -of each measurement is stated honestly here so nothing has to be re-derived. +Items 1-5 of the previous list are done and checked on the emulator, and so +is the AGP bump from item 7. Two things were found while measuring them: a +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.** -`MarkdownListItem`'s `Marker` draws `•`, `◦`, `▪` by depth (cycling) in -`listMarkerColor` (Theme.kt, Lavender: the scheme's secondary accent, which -nothing else used, so it now means "structure"). Ordered numbers take the -same colour. Still to check on the emulator: that `◦` and `▪` are in the -system fonts rather than drawing as boxes -- the comment on `BULLETS` claims -they were checked, and that check is what the next session owes it. +**1. Styled markers per depth.** `MarkdownListItem`'s `Marker` draws `•`, +`◦`, `▪` by depth, cycling past the third, in `listMarkerColor` (Theme.kt, +Lavender -- the scheme's secondary accent, which nothing else used, so it +now means "structure"). Ordered numbers take the same colour. All three +glyphs were checked on screen at four depths: they render from the system +fonts, no missing-glyph boxes. The colour is the same at every depth on +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.** -`CodeFence.kt` holds `highlighted` (moved out of `ToolInput.kt`, timed as -`code highlighted`), the `fenceLanguage` alias table (extension or name to -the highlights lexer; unknown words stay plain on purpose), and `CodeFence` -/ `CodeBlock`, registered as the component table's `codeFence`/`codeBlock`. -The library's `MarkdownCodeFence` still finds the code inside the node; the -drawing is ours (same background, corner, padding and sideways scroll, minus -the shadow, border and empty pointer handler). Baseline `transcript-bench.sh` -on the fixture below, before the change: draw phase 0.72ms/frame, transcript -0.36ms, the 200-line kotlin fence one 10,700px block. The after run has not -been taken. +**2. Syntax highlighting inside fences.** `CodeFence.kt` holds it: +`highlight` (moved out of `ToolInput.kt`, so a reply's code and a tool +call's command are the same colours), the `fenceLanguage` alias table +(extension or name to the highlights lexer; a word not in it stays plain, +because a fence coloured by the wrong language's rules looks highlighted and +is wrong in a way the reader cannot see), `fenceContent`, and the +`codeFence`/`codeBlock` entries of the component table. + +The measurement is the reason there is a cache. Highlighted naively, with +the answer held by a `remember` inside the fence, a two-hundred-line Kotlin +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 -provides the eight locals itself (`LocalReferenceLinkHandler` from the -parse, padding, dimens, colours, typography, a no-op image transformer, -animations, components). +provides the locals itself -- reference links from the parse, padding, +dimens, colours, typography, a no-op image transformer, animations, +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 -said draw the image as its own piece; the app has no image loader and the -renderer's transformer was the no-op one, so an image in a reply drew as -*nothing*. An `IMAGE` node is now appended by `appendPlainLink` as a link -carrying its alt text (the address when there is none), and with no image to -place the `hasImage` branch and the renderer's `MarkdownText` are gone: -every paragraph is the platform `BasicText`. +said draw the image as its own piece; measuring first showed the app has no +image loader and the renderer's transformer was the no-op one, so an image +in a reply drew as *nothing at all*. An `IMAGE` node is now appended by +`appendPlainLink` as a link carrying its alt text (the address when there is +none), which says what was there and opens it. With no image to place, the +`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 -not.** `LiveParse.advanceTo` cuts at the last item of a multi-item list -(`openPiece`), provided that item has content beyond its marker (a bare `-` -may still become a paragraph line); the cut is at the start of the item's -line so indentation survives the reparse. `Segment.continues` marks a tail -that carries on a list, and `MarkdownPiece`'s `continuesList`/`listContinues` -keep the padding of an inner item at the seam so nothing moves when it -does. Measured with the new `app/stream-bench.sh` streaming -`/tmp/longlist.md` (forty bullet items with a link each) on the old build: -`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 -empty report -- the first thing to look at (the screen showed the list -drawn with `•` markers, so the build runs; the report tap or the idle wait -may have misfired). +**5. Per-item units for a streaming list.** `LiveParse.advanceTo` now cuts +at the last item of a multi-item list (`openPiece`), provided that item has +content beyond its marker -- a bare `-` is an empty item now and the first +character of a paragraph line once `-x` arrives, so cutting on it would draw +that line as a new item. The cut is at the start of the item's line, so the +indentation the reparse reads its nesting from survives. +`Segment.continues` marks a tail that carries on a list, and +`MarkdownPiece`'s `continuesList`/`listContinues` keep an inner item's +padding at the seam, so nothing moves when the seam does. + +Clean pair, two fresh sessions, forty linked bullet items streamed word at a +time (`stream-bench.sh /tmp/longlist.md`): + +| | 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 -reply still arriving: opens the first session, resets the report, sends -FILE through `ui-sandbox.sh send`, waits for idle, prints the report. -Fixtures used this session, all in `/tmp` (regenerate from the shapes -named): `fixture.md` (lists three deep, ordered and nested, fences in +reply still arriving: opens the first session, taps the app's own "Jump to +latest" so the list is pinned to the newest end, resets the report, sends +FILE, waits for the transcript to stop growing, prints the report. Both of +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 -a standalone image, a reference link), `longfence.md` (200-line kotlin -fence), `longlist.md` (40 linked items), `numlist.md`. +a standalone image, a reference link), `longfence.md` (200-line Kotlin +fence), `longlist.md` (40 linked items). -**Seen and not chased: a reconnect loop.** On the *old* build, restarting -the app onto the fixture session with a saved anchor mid-transcript while a -600-delta reply was streaming left the screen on a spinner, reconnecting -every 1.5s (`RECONNECT_DELAY_MS`) with `session screen recomposed: 26` and -the fence message re-warmed each time, until the sandbox server was -restarted. `events?after=N` more than `CATCH_UP_LIMIT` (200) behind answers -`reset` plus the newest 200 *raw* deltas, i.e. a window starting -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. +**Two traps in the emulator loop**, both of which cost a bench run here. +`adb shell pm clear` removes the enrolment and the notification permission +along with the saved anchors, so the next run measures a permission dialog; +re-enrol with the command `ui-sandbox.sh` prints and +`pm grant … POST_NOTIFICATIONS`. And a saved anchor is per session id, so +the only way two builds start a scroll from the same place is a *fresh +session for each*. ## What is next, in order -1. **Look at the fixture on the emulator** (session `fixture2` in the - sandbox holds only `fixture.md`): bullet glyphs at three depths, fence - colours, the image drawn as a link, the reference link at the foot. -2. **Take the after measurements**: `transcript-bench.sh` for the fence, - `stream-bench.sh /tmp/longlist.md` for the list, and put both pairs in - the commit message. Find out why the after run's report was empty. -3. **Lint** (`./gradlew :androidApp:lint`) on AGP 9.4.0; the bump is in - `libs.versions.toml` and the build passed, lint has not been run. -4. **The reconnect loop above.** -5. **File the highlights range bug upstream** -- no `gh` in this VM and no - GitHub credential, 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`. -6. Everything from the earlier list that still stands: regression runs - before and after any change to these files, pasted into the commit. +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 + 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` + plus the newest 200 *raw* deltas -- a window starting mid-message -- and + 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. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt index 09fc231..5866f56 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember 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.LocalMarkdownDimens import com.mikepenz.markdown.compose.LocalMarkdownPadding -import com.mikepenz.markdown.compose.elements.MarkdownCodeBlock -import com.mikepenz.markdown.compose.elements.MarkdownCodeFence +import com.mikepenz.markdown.model.State import dev.snipme.highlights.Highlights import dev.snipme.highlights.model.BoldHighlight import dev.snipme.highlights.model.ColorHighlight 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.findChildOfType +import org.intellij.markdown.ast.getTextInNode /** * 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. */ @Composable -fun CodeFence(content: String, node: ASTNode, style: TextStyle) { - MarkdownCodeFence(content, node, style) { code, language, codeStyle -> - CodeBlockText(code, language, codeStyle) - } +fun CodeFence(content: String, node: ASTNode, style: TextStyle, replies: ParsedReplies) { + val (code, language) = remember(content, node) { fenceContent(content, node) } ?: return + CodeBlockText(code, language, style, replies) } /** An indented code block, which is a fence with no language word. */ @Composable -fun CodeBlock(content: String, node: ASTNode, style: TextStyle) { - MarkdownCodeBlock(content, node, style) { code, language, codeStyle -> - CodeBlockText(code, language, codeStyle) +fun CodeBlock(content: String, node: ASTNode, style: TextStyle, replies: ParsedReplies) { + val (code, language) = remember(content, node) { fenceContent(content, node) } ?: return + 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? { + 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. */ @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 dimens = LocalMarkdownDimens.current val padding = LocalMarkdownPadding.current @@ -78,7 +111,7 @@ private fun CodeBlockText(code: String, language: String?, style: TextStyle) { .semantics { isTraversalGroup = true } ) { BasicText( - highlighted(code, fenceLanguage(language)), + replies.highlighted(code, language), style = style, modifier = Modifier.horizontalScroll(rememberScrollState()).padding(padding.codeBlock), ) @@ -147,48 +180,71 @@ private val FENCE_LANGUAGES: Map = * 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. * - * Timed, because a fence is highlighted whole and a reply still arriving re-highlights its last - * block on every delta; the counter says what that costs before anybody has to guess. + * Not a composable, and it takes no colour from the theme, because that is what lets [warm] run it + * 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 highlighted(code: String, language: SyntaxLanguage?): AnnotatedString { - val theme = catppuccinSyntax() - val plain = MaterialTheme.colorScheme.onSurface - return remember(code, language, theme, plain) { - if (language == null) return@remember AnnotatedString(code) - val marks = - DebugStats.timed("code highlighted") { - Highlights.Builder(code = code, language = language, theme = theme) - .build() - .getHighlights() - // highlights 1.1.0's shell lexer answers a quoted glob that looks like a - // comment -- `x '*/a/*'` is the smallest input -- with a span whose end is - // before its start, and AnnotatedString refuses such a range. That crashed - // the app the moment a card holding `-path '*/.git/*'` was opened. Dropped - // rather than clamped: a span the lexer got backwards is not one it knows - // the colour of. Delete when snipme/highlights fixes it. - .filter { - it.location.start in 0..it.location.end && it.location.end <= code.length - } - } - buildAnnotatedString { - append(code) - marks.forEach { mark -> - when (mark) { - is ColorHighlight -> - addStyle( - SpanStyle(color = Color(mark.rgb or 0xFF000000.toInt())), - mark.location.start, - mark.location.end, - ) - is BoldHighlight -> - addStyle( - SpanStyle(fontWeight = FontWeight.Bold), - mark.location.start, - mark.location.end, - ) +fun highlight(code: String, language: SyntaxLanguage?): AnnotatedString { + if (language == null) return AnnotatedString(code) + val marks = + DebugStats.timed("code highlighted") { + Highlights.Builder(code = code, language = language, theme = catppuccinSyntax()) + .build() + .getHighlights() + // highlights 1.1.0's shell lexer answers a quoted glob that looks like a comment + // -- `x '*/a/*'` is the smallest input -- with a span whose end is before its + // start, and AnnotatedString refuses such a range. That crashed the app the + // moment a card holding `-path '*/.git/*'` was opened. Dropped rather than + // clamped: a span the lexer got backwards is not one it knows the colour of. + // Delete when snipme/highlights fixes it. + .filter { + it.location.start in 0..it.location.end && it.location.end <= code.length } + } + return buildAnnotatedString { + append(code) + marks.forEach { mark -> + when (mark) { + is ColorHighlight -> + addStyle( + SpanStyle(color = Color(mark.rgb or 0xFF000000.toInt())), + mark.location.start, + mark.location.end, + ) + is BoldHighlight -> + addStyle( + SpanStyle(fontWeight = FontWeight.Bold), + mark.location.start, + mark.location.end, + ) } } } } + +/** + * 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> { + val success = parse as? State.Success ?: return emptyList() + val out = ArrayList>() + 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 +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt index b99610f..41f1a7d 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.semantics.collectionInfo import androidx.compose.ui.semantics.collectionItemInfo import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextStyle 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.markdownPadding import com.mikepenz.markdown.model.parseMarkdown +import dev.snipme.highlights.model.SyntaxLanguage import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -91,7 +93,7 @@ fun MarkdownText( var previousSegment: Segment? = null segments.forEachIndexed { at, segment -> val nextContinues = segments.getOrNull(at + 1)?.continues == true - MarkdownRoot(segment.parse) { + MarkdownRoot(segment.parse, replies) { segment.pieces.forEachIndexed { index, piece -> val gap = when { @@ -305,7 +307,7 @@ fun MarkdownPiece( // Remembered so a message the flatten drew before [warm] reached it is parsed once here, not // once per composition. 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. */ @Composable -private fun MarkdownRoot(parse: State, content: @Composable () -> Unit) { +private fun MarkdownRoot(parse: State, replies: ParsedReplies, content: @Composable () -> Unit) { if (parse !is State.Success) { // Nothing below needs the environment; [MarkdownPiece] draws the words plainly. content() @@ -458,8 +460,8 @@ private fun MarkdownRoot(parse: State, content: @Composable () -> Unit) { 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) }, - codeBlock = { CodeBlock(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, replies) }, ), content = content, ) @@ -597,6 +599,17 @@ class ParsedReplies { private val chunks = ConcurrentHashMap>() + /** + * 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() + private val ready = ConcurrentHashMap.newKeySet() /** 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) } } + /** + * [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. */ fun of(text: String): State = parsed[text]?.also { DebugStats.count("markdown ready") } @@ -649,9 +671,15 @@ class ParsedReplies { */ suspend fun warm(texts: List) { texts.forEach { text -> - parsed.computeIfAbsent(text) { - DebugStats.timed("markdown warmed") { parseMarkdown(it) } - } + val parse = + parsed.computeIfAbsent(text) { + 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() parts.clear() chunks.clear() + highlights.clear() ready.clear() } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index e4a1dcc..ea2438c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -711,14 +711,26 @@ fun SessionScreen( // 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 // bound it; see [loadOlderPage]. - val span = oldestSeq - anchor.seq + RESTORE_PAGE_CUSHION - if ( - !loadOlderPage( - span.coerceIn(1L, RESTORE_PAGE_MAX.toLong()).toInt(), - coalesce = false, - ) - ) - break + val behind = oldestSeq - anchor.seq + val loaded = + if (behind < 0) { + // 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, + ) + } + if (!loaded) break } // 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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt index 4024b1c..4ed473f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt @@ -117,7 +117,9 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) { // Not wrapped: a wrapped command hides where its arguments end, // and the long one is the one being read closely. 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, fontFamily = FontFamily.Monospace, softWrap = false, diff --git a/app/stream-bench.sh b/app/stream-bench.sh index 2841172..22d3a75 100755 --- a/app/stream-bench.sh +++ b/app/stream-bench.sh @@ -38,17 +38,53 @@ if [ -z "$keep" ]; then sleep 3 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. ui-trace record -d 2000 --do 'tap 723 205' -o /tmp/bench-reset.txt >/dev/null 2>&1 adb logcat -c ./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 -while [ "$i" -lt 120 ]; do - status=$(./ui-sandbox.sh api "/sessions/$sid" | - python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])') - [ "$status" = idle ] && break +while [ "$i" -lt 180 ]; do + now=$(events) + if [ "$now" = "$last" ]; then + still=$((still + 1)) + [ "$still" -ge 2 ] && break + else + still=0 + fi + last=$now sleep 2 i=$((i + 1)) done