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:
1 parent
6892dc7caf
commit
ab6a797941
6 files changed
+316
-120
No files matched your search
+136
-75
@@ -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) {
|
||||||
@@ -191,4 +225,26 @@ 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
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user