diff --git a/AGENTS.md b/AGENTS.md
index ae43815..23824bb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -334,17 +334,32 @@ first if a remote spawn ever mangles an argument.
success. The phone's half is `uniqueItems`, which every list keyed on a
server-chosen id goes through: a repeat there must never be able to
close the app, whatever produced it.
+- **A reply is drawn as pieces of one parse, never as re-parsed
+ substrings.** `MarkdownPieces.kt`: a `Piece` addresses a top-level block
+ of the message's tree, or one item of a top-level list, and every piece
+ is drawn from the same `State.Success` that `ParsedReplies` cached and
+ `warm` made. That is what bounds a lazy-list item (one paragraph, one
+ bullet) without parsing a message more than once, and it is why a
+ forty-item list of sources is forty units rather than one. The renderer
+ is still the parser and the environment: `MarkdownRoot` provides its
+ locals and `MarkdownElement` dispatches a whole block through our
+ component table, so paragraphs, headings and table cells are span-linked
+ `LinkedText` (links as spans with one tap detector per text, not a layout
+ node per link -- the cost that made a list of sources bumpy) and lists
+ are ours wherever the dispatch meets one. A heading's words are its
+ `ATX_CONTENT`/`SETEXT_CONTENT` child; the inline builder draws nothing
+ for a node type it does not know, so hand it the child.
- **A markdown table wraps its cells and never cuts one off.** The
renderer's own defaults draw every cell at one line with an ellipsis,
which on a phone loses most of a table -- and an elided cell looks
exactly like a short one, so nothing on screen says anything was cut.
- `Markdown.kt` supplies its own header and row blocks with `maxLines =
- Int.MAX_VALUE` and `TextOverflow.Clip`, cells aligned to the top of the
- row so a two-line cell does not re-centre its neighbours. Width is the
- other half: a column narrows to 136dp and no further, and past that the
- whole table scrolls sideways rather than squeezing -- 136 because it is
- the widest floor that still fits three columns across a phone, which is
- the commonest table there is. Exercise it with the echo driver's
+ `Markdown.kt` supplies its own rows (`LinkedTableRow`): as many lines as
+ a cell needs, cells aligned to the top of the row so a two-line cell
+ does not re-centre its neighbours, and each cell a `LinkedText`. Width is
+ the other half: a column narrows to 136dp and no further, and past that
+ the whole table scrolls sideways rather than squeezing -- 136 because it
+ is the widest floor that still fits three columns across a phone, which
+ is the commonest table there is. Exercise it with the echo driver's
`/table N` (default six columns), which writes long cells on purpose:
a fixture of tidy one-word values renders fine whether or not the
truncation is fixed.
diff --git a/app/androidApp/src/main/AndroidManifest.xml b/app/androidApp/src/main/AndroidManifest.xml
index 603e5fb..ec5afd0 100644
--- a/app/androidApp/src/main/AndroidManifest.xml
+++ b/app/androidApp/src/main/AndroidManifest.xml
@@ -34,7 +34,7 @@
adds) in a release build, so a frame cost measured on the real
device can be attributed. shell="true" limits it to profilers
run from the shell; it grants nothing to other apps. -->
-
+
diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt
index 48ad26e..61ffae5 100644
--- a/app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt
+++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt
@@ -129,7 +129,7 @@ private const val CAP = 20_000
* documentation is explicit that doing that on the main thread taxes the very thing being measured.
*/
@Composable
-fun recordFrames() {
+fun RecordFrames() {
val window = LocalContext.current.activity()?.window
DisposableEffect(window) {
if (window == null) return@DisposableEffect onDispose {}
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 14790e8..4cae2e0 100644
--- a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt
+++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt
@@ -1,16 +1,21 @@
package com.example.aiapp
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
+import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.CollectionItemInfo
import androidx.compose.ui.semantics.collectionItemInfo
import androidx.compose.ui.semantics.heading
@@ -27,6 +32,7 @@ import com.mikepenz.markdown.compose.LocalMarkdownDimens
import com.mikepenz.markdown.compose.components.markdownComponents
import com.mikepenz.markdown.compose.elements.LocalTableRowIndex
import com.mikepenz.markdown.compose.elements.MarkdownTable
+import com.mikepenz.markdown.compose.elements.listDepth
import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.elements.MarkdownCheckBox
import com.mikepenz.markdown.m3.markdownColor
@@ -50,12 +56,91 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
* Colours come from the theme rather than from the renderer's defaults, so code, links and rules
* are the same Catppuccin values the rest of the app uses. Nothing here picks a colour of its own.
*/
+/**
+ * [text] drawn as its pieces, one under the other; see [Piece]. [live] is the reply still arriving,
+ * and it is the only message whose pieces get a layer each. A layer buys one thing here: when
+ * drawing is invalidated, only the piece that changed is re-recorded instead of the whole reply.
+ * That is worth a great deal while a reply is streaming, because every delta invalidates the
+ * message and a finished one can be twenty-five screens tall. It is worth nothing once the message
+ * stops changing -- measured on a Pixel 9 Pro XL, whole rows were re-recorded 65 times in fifty
+ * seconds of reading -- and it is not free: each layer is a layout node and a display list held for
+ * the life of the row, and live node count is what the per-frame cost of the transcript scales
+ * with.
+ */
@Composable
-fun MarkdownText(text: String, replies: ParsedReplies, modifier: Modifier = Modifier) {
+fun MarkdownText(
+ text: String,
+ replies: ParsedReplies,
+ modifier: Modifier = Modifier,
+ live: Boolean = false,
+) {
+ val parse = parsedMarkdown(text, replies)
+ val pieces = remember(parse) { pieces(parse) }
+ MarkdownRoot(parse) {
+ Column(modifier.fillMaxWidth()) {
+ var previous: Piece? = null
+ pieces.forEach { piece ->
+ // Keyed by address rather than by position in this column, so a delta that
+ // lands in the last paragraph leaves every other piece's composition alone.
+ key(piece) {
+ MarkdownPiece(
+ parse,
+ text,
+ piece,
+ Modifier.padding(
+ top = if (previous == null) 0.dp else gapBefore(previous, piece)
+ )
+ .then(if (live) Modifier.graphicsLayer() else Modifier)
+ .drawWithContent {
+ val started = System.nanoTime()
+ drawContent()
+ DebugStats.record("record: one block", System.nanoTime() - started)
+ },
+ )
+ }
+ previous = piece
+ }
+ }
+ }
+}
+
+/** One [piece] of [text], drawn on its own -- a unit of the transcript list. */
+@Composable
+fun MarkdownPiece(
+ text: String,
+ piece: Piece,
+ replies: ParsedReplies,
+ modifier: Modifier = Modifier,
+) {
+ // 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) }
+}
+
+/**
+ * The renderer's own environment -- its colours, type scale, dimensions, component table and
+ * reference links -- around whatever draws pieces of [parse].
+ *
+ * The parsing is the library's. Markdown is somebody else's specification, and a hand-written
+ * parser would get the edge cases wrong one case at a time. So is the environment: the element
+ * composables its dispatch reaches read these locals, and providing them once here is what lets a
+ * piece be drawn anywhere -- in a message's column, or as one item of the transcript list.
+ * Everything below this is the mapping onto the app's palette and type scale.
+ *
+ * Colours come from the theme rather than from the renderer's defaults, so code, links and rules
+ * are the same Catppuccin values the rest of the app uses. Nothing here picks a colour of its own.
+ */
+@Composable
+private fun MarkdownRoot(parse: State, content: @Composable () -> Unit) {
+ if (parse !is State.Success) {
+ // Nothing below needs the environment; [MarkdownPiece] draws the words plainly.
+ content()
+ return
+ }
val body = MaterialTheme.typography.bodyLarge
- val parsed = parsedMarkdown(text, replies)
Markdown(
- parsed,
+ parse,
colors =
markdownColor(
text = MaterialTheme.colorScheme.onSurface,
@@ -157,14 +242,18 @@ fun MarkdownText(text: String, replies: ParsedReplies, modifier: Modifier = Modi
// is the renderer's own pairing.
text = { LinkedText(it, it.typography.text) },
paragraph = { LinkedText(it, it.typography.paragraph) },
- heading1 = { LinkedText(it, it.typography.h1, heading = true) },
- heading2 = { LinkedText(it, it.typography.h2, heading = true) },
- heading3 = { LinkedText(it, it.typography.h3, heading = true) },
- heading4 = { LinkedText(it, it.typography.h4, heading = true) },
- heading5 = { LinkedText(it, it.typography.h5, heading = true) },
- heading6 = { LinkedText(it, it.typography.h6, heading = true) },
- setextHeading1 = { LinkedText(it, it.typography.h1, heading = true) },
- setextHeading2 = { LinkedText(it, it.typography.h2, heading = true) },
+ heading1 = { LinkedHeading(it, it.typography.h1) },
+ heading2 = { LinkedHeading(it, it.typography.h2) },
+ heading3 = { LinkedHeading(it, it.typography.h3) },
+ heading4 = { LinkedHeading(it, it.typography.h4) },
+ heading5 = { LinkedHeading(it, it.typography.h5) },
+ heading6 = { LinkedHeading(it, it.typography.h6) },
+ setextHeading1 = { LinkedHeading(it, it.typography.h1) },
+ setextHeading2 = { LinkedHeading(it, it.typography.h2) },
+ // Lists are ours wherever the renderer's dispatch meets one -- inside a quote --
+ // so they draw like the top-level ones the transcript cuts into items.
+ orderedList = { MarkdownList(it.content, it.node, it.listDepth) },
+ unorderedList = { MarkdownList(it.content, it.node, it.listDepth) },
table = {
MarkdownTable(
it.content,
@@ -179,7 +268,8 @@ fun MarkdownText(text: String, replies: ParsedReplies, modifier: Modifier = Modi
)
},
),
- modifier = modifier,
+ modifier = Modifier,
+ success = { _, _, _ -> content() },
)
}
@@ -299,18 +389,15 @@ class ParsedReplies {
private val parsed = ConcurrentHashMap()
/**
- * How each message divides into blocks, cached beside the parses of those blocks.
- *
- * Here rather than in a `remember` because the answer is wanted on two threads: by [warm], to
- * know which strings to make ready, and by the row that draws them. Finding it costs a parse of
- * the whole message, so doing it twice would undo what splitting is for.
+ * How each message divides into pieces, cached beside its parse: [transcriptUnits] asks per
+ * fold, and walking the tree again each time is proportional to the message where a lookup is
+ * proportional to nothing.
*/
- private val blocks = ConcurrentHashMap>()
+ private val pieces = ConcurrentHashMap>()
/**
* How each message divides into prose and memory notes, cached for the same reason as
- * [blocksOf]: [transcriptUnits] asks per fold, and the regex scan behind [messageParts] is
- * proportional to the message every time where a lookup is proportional to nothing.
+ * [piecesOf]: the regex scan behind [messageParts] is proportional to the message.
*/
private val parts = ConcurrentHashMap>()
@@ -318,25 +405,26 @@ class ParsedReplies {
private val ready = ConcurrentHashMap.newKeySet()
- fun blocksOf(text: String): List =
- blocks.computeIfAbsent(text) {
- DebugStats.timed("markdown split into blocks") { markdownBlocks(it) }
+ /** The pieces of [text], from its parse -- made now if [warm] has not made it. */
+ fun piecesOf(text: String): List =
+ pieces.computeIfAbsent(text) {
+ DebugStats.timed("markdown cut into pieces") { pieces(of(it)) }
}
- /** How a long user message divides into slices; cached for the same reason as [blocksOf]. */
+ /** How a long user message divides into slices; cached for the same reason as [piecesOf]. */
fun chunksOf(text: String): List =
chunks.computeIfAbsent(text) {
DebugStats.timed("user message cut into slices") { userChunks(it) }
}
/**
- * Whether [warm] has made everything drawing [text] as blocks will look up.
+ * Whether [warm] has made everything drawing [text] as pieces will look up.
*
- * What the flatten asks before drawing a reply that way. Splitting costs a parse of the whole
+ * What the flatten asks before drawing a reply that way. Cutting costs a parse of the whole
* message and the flatten runs on the composing thread -- so a reply not marked yet stays
* whole, drawing the parse it already has, until the screen has warmed it and re-flattens. An
- * explicit mark rather than a peek into [blocksOf]'s cache, because a message with memory notes
- * is warmed as its *parts*: nothing ever splits its full text, and inferring readiness from the
+ * explicit mark rather than a peek into the parse cache, because a message with memory notes is
+ * warmed as its *parts*: nothing ever parses its full text, and inferring readiness from the
* cache left exactly that message unsplittable forever, re-warmed on every fold.
*/
fun splitReady(text: String): Boolean = text in ready
@@ -376,7 +464,7 @@ class ParsedReplies {
/** Everything these described is gone; see [ParsedReplies]. */
fun clear() {
parsed.clear()
- blocks.clear()
+ pieces.clear()
parts.clear()
chunks.clear()
ready.clear()
diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt
index ca8cb5c..30405e3 100644
--- a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt
+++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt
@@ -22,6 +22,7 @@ import com.mikepenz.markdown.compose.elements.MarkdownText
import com.mikepenz.markdown.model.markdownAnnotator
import com.mikepenz.markdown.utils.getUnescapedTextInNode
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.flavours.gfm.GFMTokenTypes
@@ -49,13 +50,23 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
* resolves those against its definitions.
*/
@Composable
-fun LinkedText(model: MarkdownComponentModel, style: TextStyle, heading: Boolean = false) {
- LinkedText(
- model.content,
- model.node,
- style,
- if (heading) Modifier.semantics { heading() } else Modifier,
- )
+fun LinkedText(model: MarkdownComponentModel, style: TextStyle) {
+ LinkedText(model.content, model.node, style)
+}
+
+/**
+ * A heading. Its words are a child of the heading node -- `ATX_CONTENT` after the `#`s, or
+ * `SETEXT_CONTENT` above the underline -- and the inline builder draws nothing for a node type it
+ * does not know, so handed the heading node itself it draws an empty line. Which is what this did
+ * for a week.
+ */
+@Composable
+fun LinkedHeading(model: MarkdownComponentModel, style: TextStyle) {
+ val words =
+ model.node.findChildOfType(MarkdownTokenTypes.ATX_CONTENT)
+ ?: model.node.findChildOfType(MarkdownTokenTypes.SETEXT_CONTENT)
+ ?: model.node
+ LinkedText(model.content, words, style, Modifier.semantics { heading() })
}
/** The inline content of [node] within [content], drawn as [LinkedText] describes. */
diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownPieces.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownPieces.kt
new file mode 100644
index 0000000..ce287f8
--- /dev/null
+++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownPieces.kt
@@ -0,0 +1,235 @@
+package com.example.aiapp
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.isTraversalGroup
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.mikepenz.markdown.compose.LocalMarkdownColors
+import com.mikepenz.markdown.compose.LocalMarkdownComponents
+import com.mikepenz.markdown.compose.LocalMarkdownPadding
+import com.mikepenz.markdown.compose.LocalMarkdownTypography
+import com.mikepenz.markdown.compose.MarkdownElement
+import com.mikepenz.markdown.compose.components.MarkdownComponentModel
+import com.mikepenz.markdown.model.State
+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
+import org.intellij.markdown.flavours.gfm.GFMTokenTypes
+
+/**
+ * One drawable piece of a parsed message: a top-level block, or one item of a top-level list.
+ *
+ * The point is the draw phase and the lazy list. A reply's display list holds every glyph of it and
+ * is re-recorded whenever drawing is invalidated, so one long message costs as much to draw as a
+ * hundred short ones; and the list composes an item whole in the frame it scrolls into, so an item
+ * has to be bounded for the worst frame to be. Measured on a Pixel 9 Pro XL, the tallest row still
+ * being drawn was 36,982px, twenty-five screens in one message. A piece is a paragraph, a fence, a
+ * table, one bullet: bounded, so both costs are.
+ *
+ * Cut where the parser says the blocks are, which is the whole reason this is safe: a fence, a
+ * table and a nested list are each one node whatever is inside them, so nothing is ever split down
+ * the middle. A list is the one block that is not bounded -- a reply's list of sources can be forty
+ * items -- so it is cut once more, into its items, and a nested list stays inside the item that
+ * holds it.
+ *
+ * A piece is an *address* into the message's one parse ([block] indexes the root's children, [item]
+ * the list items of that child) rather than a substring of the message. Every piece of a message is
+ * drawn from the same tree, so a message is parsed once however many pieces it is drawn as, and a
+ * reference definition at its foot still resolves the links above it -- the two costs of cutting a
+ * message into strings and parsing each on its own.
+ */
+@Immutable
+data class Piece(val block: Int, val item: Int = WHOLE_BLOCK) {
+ companion object {
+ const val WHOLE_BLOCK = -1
+ }
+}
+
+/**
+ * The pieces of [parse], in reading order. Blank nodes between blocks -- the parser keeps the
+ * newlines -- are not pieces.
+ *
+ * A parse that failed yields one piece, so [MarkdownPiece] can still say what the message was: a
+ * message that drew as nothing would be a hole in the transcript with no sign of what fell out.
+ */
+fun pieces(parse: State): List {
+ val success = parse as? State.Success ?: return listOf(Piece(0))
+ val out = ArrayList()
+ success.node.children.forEachIndexed { at, node ->
+ when {
+ node.getTextInNode(success.content).isBlank() -> {}
+ node.isList -> repeat(node.listItems().size) { out += Piece(at, it) }
+ else -> out += Piece(at)
+ }
+ }
+ return out
+}
+
+/**
+ * The room above [piece] when it follows [previous] in the same message: none between two items of
+ * one list, whose own padding already separates them, and a block's gap otherwise. The first piece
+ * of a message takes the message's gap, which is the caller's to know.
+ */
+fun gapBefore(previous: Piece?, piece: Piece): Dp =
+ if (previous != null && previous.block == piece.block) 0.dp else BLOCK_SPACING
+
+/** The gap between one block of a reply and the next, wherever a reply is drawn in pieces. */
+val BLOCK_SPACING: Dp = 6.dp
+
+/**
+ * [piece] of [parse], drawn. Must be inside [MarkdownRoot] for the parse, which is what carries the
+ * theme, the components and the reference links to the renderer's element composables.
+ *
+ * A whole block goes to the renderer's own dispatch with this app's component table, so a paragraph
+ * or heading is a [LinkedText], a table is [LinkedTableRow]s, and a nested list comes back here
+ * through [MarkdownList]. Only the list item is drawn directly, because a list item is the one
+ * piece the renderer has no element for.
+ */
+@Composable
+fun MarkdownPiece(parse: State, text: String, piece: Piece, modifier: Modifier = Modifier) {
+ if (parse !is State.Success) {
+ // The parser threw. Nothing else in the app has seen this happen; if it does, the words
+ // are still worth more than a blank.
+ Text(text, modifier, style = MaterialTheme.typography.bodyLarge)
+ return
+ }
+ val node = parse.node.children[piece.block]
+ if (piece.item == Piece.WHOLE_BLOCK) {
+ Box(modifier) {
+ MarkdownElement(
+ node,
+ LocalMarkdownComponents.current,
+ parse.content,
+ includeSpacer = false,
+ )
+ }
+ } else {
+ val items = node.listItems()
+ MarkdownListItem(
+ content = parse.content,
+ list = node,
+ item = items[piece.item],
+ index = piece.item,
+ first = piece.item == 0,
+ last = piece.item == items.lastIndex,
+ depth = 0,
+ modifier = modifier,
+ )
+ }
+}
+
+/**
+ * A whole list, for the places the renderer's dispatch reaches one it cannot hand to a piece: a
+ * list inside a quote, and the nested lists an item holds. Top-level lists never come here; they
+ * are drawn an item at a time as pieces.
+ */
+@Composable
+fun MarkdownList(content: String, list: ASTNode, depth: Int, modifier: Modifier = Modifier) {
+ val items = list.listItems()
+ Column(modifier) {
+ items.forEachIndexed { index, item ->
+ MarkdownListItem(
+ content,
+ list,
+ item,
+ index,
+ first = index == 0,
+ last = index == items.lastIndex,
+ depth = depth,
+ )
+ }
+ }
+}
+
+/**
+ * One item: its marker beside its content, laid out the way the renderer's own list does so that a
+ * list drawn as pieces looks exactly like one drawn whole. The list's own padding goes on its first
+ * and last items, since there is no list column to carry it.
+ *
+ * The marker is the renderer's bullet and number, and a checkbox for a task item. It is drawn here
+ * rather than by a handler because it is the thing a reader might one day want styled -- a
+ * different glyph per depth, a colour -- and this is the one place it is drawn.
+ */
+@Composable
+private fun MarkdownListItem(
+ content: String,
+ list: ASTNode,
+ item: ASTNode,
+ index: Int,
+ first: Boolean,
+ last: Boolean,
+ depth: Int,
+ modifier: Modifier = Modifier,
+) {
+ val padding = LocalMarkdownPadding.current
+ val typography = LocalMarkdownTypography.current
+ val components = LocalMarkdownComponents.current
+ // A task item's box sits right after the bullet: `- [ ] text`.
+ val checkbox = item.children.getOrNull(1)?.takeIf { it.type == GFMTokenTypes.CHECK_BOX }
+ Row(
+ modifier
+ .semantics { isTraversalGroup = true }
+ .fillMaxWidth()
+ .padding(
+ start = padding.listIndent * depth,
+ top = padding.listItemTop + if (first) padding.list else 0.dp,
+ bottom = padding.listItemBottom + if (last) padding.list else 0.dp,
+ )
+ ) {
+ if (checkbox != null) {
+ components.checkbox(MarkdownComponentModel(content, checkbox, typography))
+ } else if (list.type == MarkdownElementTypes.ORDERED_LIST) {
+ Marker("${list.startNumber(content) + index}. ", typography.ordered)
+ } else {
+ Marker("• ", typography.bullet)
+ }
+ Column {
+ item.children.forEach { child ->
+ when (child.type) {
+ MarkdownTokenTypes.LIST_BULLET,
+ MarkdownTokenTypes.LIST_NUMBER,
+ GFMTokenTypes.CHECK_BOX -> {}
+ MarkdownElementTypes.ORDERED_LIST,
+ MarkdownElementTypes.UNORDERED_LIST -> MarkdownList(content, child, depth + 1)
+ else -> MarkdownElement(child, components, content, includeSpacer = false)
+ }
+ }
+ }
+ }
+}
+
+/** The renderer's text colour on the marker; its styles carry none of their own. */
+@Composable
+private fun Marker(text: String, style: TextStyle) {
+ BasicText(text, style = style.copy(color = LocalMarkdownColors.current.text))
+}
+
+private val ASTNode.isList: Boolean
+ get() = type == MarkdownElementTypes.ORDERED_LIST || type == MarkdownElementTypes.UNORDERED_LIST
+
+private fun ASTNode.listItems(): List = children.filter {
+ it.type == MarkdownElementTypes.LIST_ITEM
+}
+
+/** Where an ordered list counts from: the number its first item was written with. */
+private fun ASTNode.startNumber(content: String): Int =
+ findChildOfType(MarkdownElementTypes.LIST_ITEM)
+ ?.findChildOfType(MarkdownTokenTypes.LIST_NUMBER)
+ ?.getTextInNode(content)
+ ?.takeWhile(Char::isDigit)
+ ?.toString()
+ ?.toIntOrNull() ?: 1
diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt
index 87ae626..6123517 100644
--- a/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt
+++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt
@@ -45,13 +45,13 @@ fun AssistantMessage(
val parts = remember(text) { messageParts(text) }
val only = parts.singleOrNull()
if (only is MessagePart.Prose) {
- BlockedMarkdown(only.text, replies, modifier, live)
+ MarkdownText(only.text, replies, modifier, live)
return
}
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
parts.forEach { part ->
when (part) {
- is MessagePart.Prose -> BlockedMarkdown(part.text, replies, live = live)
+ is MessagePart.Prose -> MarkdownText(part.text, replies, live = live)
is MessagePart.Remembered ->
MemoryNote(part, replies, part.text in openNotes) { onToggleNote(part.text) }
}
diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MessageBlocks.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MessageBlocks.kt
deleted file mode 100644
index f638cde..0000000
--- a/app/androidApp/src/main/kotlin/com/example/aiapp/MessageBlocks.kt
+++ /dev/null
@@ -1,116 +0,0 @@
-package com.example.aiapp
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.drawWithContent
-import androidx.compose.ui.graphics.graphicsLayer
-import androidx.compose.ui.unit.dp
-import com.mikepenz.markdown.model.State
-import com.mikepenz.markdown.model.parseMarkdown
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-
-/**
- * A message's top-level markdown blocks, cut where the parser says the blocks are.
- *
- * The point is the draw phase. A reply's display list holds every glyph of it, and it is
- * re-recorded whenever drawing is invalidated -- so one long message is as expensive to draw as a
- * hundred short ones, and skipping the rows around it cannot help while it is the one on screen.
- * Measured on a Pixel 9 Pro XL: 97% of rows correctly skipped, and the tallest row still being
- * drawn was 36,982px, about twenty-five screens in a single message. Cut into blocks, only the
- * screen or two actually being read is ever recorded.
- *
- * Cut at the parser's own boundaries rather than at blank lines, which is the whole reason this is
- * safe: a heading, a fenced code block, a table and a list are each one node whatever is inside
- * them, so a loose list does not become five one-item lists and a fence is never split down the
- * middle. Guessing at block boundaries with a line scanner gets all three of those wrong.
- *
- * It also bounds parsing, which was the other symptom: one message took **1.4 seconds** to parse as
- * a single unit, and a block is a paragraph.
- */
-fun markdownBlocks(text: String): List {
- // A reference definition sits at the foot of a message and is used by links above it. Parsed on
- // its own each block would lose the definition, and the link would draw as literal brackets --
- // so a message carrying one is kept whole. Rare enough to be worth giving up the split for.
- if (REFERENCE_DEFINITION.containsMatchIn(text)) return listOf(text)
- val parsed = parseMarkdown(text) as? State.Success ?: return listOf(text)
- val blocks =
- parsed.node.children
- .map { text.substring(it.startOffset, it.endOffset) }
- .filter { it.isNotBlank() }
- return if (blocks.size <= 1) listOf(text) else blocks
-}
-
-/** `[label]: https://…` at the start of a line -- see [markdownBlocks]. */
-private val REFERENCE_DEFINITION = Regex("""^ {0,3}\[[^\]]+]:\s""", RegexOption.MULTILINE)
-
-/**
- * A reply drawn a block at a time.
- *
- * Each block keeps its composition and its layout whichever way it is scrolled -- that is what
- * stops a message being rebuilt when somebody comes back to it. The heights come from the blocks
- * themselves as they are measured, so the running total is the same arrangement the list uses one
- * level up.
- *
- * [live] is the message currently arriving, and it is the only one that gets a layer per block. A
- * layer buys one thing here: when drawing is invalidated, only the block that changed is
- * re-recorded instead of the whole reply. That is worth a great deal while a reply is streaming,
- * because every delta invalidates the message and a finished one can be twenty-five screens tall.
- * It is worth nothing once the message stops changing -- measured on a Pixel 9 Pro XL, whole rows
- * were re-recorded 65 times in fifty seconds of reading -- and it is not free: each layer is a
- * layout node and a display list held for the life of the row, and live node count is what the
- * per-frame cost of the transcript scales with.
- */
-@Composable
-fun BlockedMarkdown(
- text: String,
- replies: ParsedReplies,
- modifier: Modifier = Modifier,
- live: Boolean = false,
-) {
- // The first split is inline for the reason [parsedMarkdown]'s first parse is: the row must
- // have its real height in its first frame. Every split after that is a delta landing, and it
- // runs off the composing thread with the message drawing the split it already has until the
- // new one arrives -- when this recomputed wherever composition ran, one streamed reply cost
- // 815 whole-message parses and 2.9 seconds of them, a few milliseconds per delta, on the
- // thread that draws. Not through [replies]: a reply mid-stream is a different text per
- // delta, and each would leave a cache entry nothing reads again.
- val split = remember { mutableStateOf(text to markdownBlocks(text)) }
- LaunchedEffect(text) {
- if (split.value.first == text) return@LaunchedEffect
- split.value =
- text to
- withContext(Dispatchers.Default) {
- DebugStats.timed("blocks split while streaming") { markdownBlocks(text) }
- }
- }
- val blocks = split.value.second
- if (blocks.size == 1) {
- MarkdownText(blocks.first(), replies, modifier)
- return
- }
- Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(BLOCK_SPACING)) {
- blocks.forEach { block ->
- MarkdownText(
- block,
- replies,
- Modifier.fillMaxWidth()
- .then(if (live) Modifier.graphicsLayer() else Modifier)
- .drawWithContent {
- val started = System.nanoTime()
- drawContent()
- DebugStats.record("record: one block", System.nanoTime() - started)
- },
- )
- }
- }
-}
-
-/** The gap between one block of a reply and the next, here and in [transcriptUnits]. */
-val BLOCK_SPACING = 6.dp
diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt
index 5214a5f..a62aa46 100644
--- a/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt
+++ b/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt
@@ -78,24 +78,18 @@ fun PeerHeadRow(
* chose.
*/
@Composable
-fun PeerBlockRow(
- text: String,
- replies: ParsedReplies,
- last: Boolean,
- onToggle: () -> Unit,
- modifier: Modifier = Modifier,
-) {
+fun PeerBlockRow(unit: TranscriptUnit.PeerBlock, replies: ParsedReplies, onToggle: () -> Unit) {
Column(
- modifier.cardPiece(
+ Modifier.cardPiece(
top = false,
- bottom = last,
+ bottom = unit.last,
fill = CardDefaults.cardColors().containerColor,
onPress = onToggle,
)
) {
// The gap the card's own column used to provide between its heading and its prose, and
- // between one block and the next. Uniform, because both of those were 6dp already.
- MarkdownText(text, replies, Modifier.padding(top = BLOCK_SPACING))
+ // between one block and the next -- inside the piece, so the card's fill runs through it.
+ MarkdownPiece(unit.text, unit.piece, replies, Modifier.padding(top = unit.spacing))
}
}
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 a3d16be..9ba99b4 100644
--- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt
+++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt
@@ -1108,7 +1108,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// One poll for this machine's limits, read by the two things that show them: the bar under
// the header, and the colour of the button that opens the dialog.
val usage = rememberSessionUsage(settings, summary.setup)
- recordFrames()
+ RecordFrames()
var usageOpen by remember { mutableStateOf(false) }
var settingsOpen by remember { mutableStateOf(false) }
@@ -1350,7 +1350,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
},
) { unit ->
when (unit) {
- is TranscriptUnit.Block -> MarkdownText(unit.text, replies)
+ is TranscriptUnit.Block -> MarkdownPiece(unit.text, unit.piece, replies)
is TranscriptUnit.PeerHead ->
PeerHeadRow(
unit.item,
@@ -1358,12 +1358,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
onToggle = { togglePeer(unit.item.seq) },
)
is TranscriptUnit.PeerBlock ->
- PeerBlockRow(
- unit.text,
- replies,
- unit.last,
- onToggle = { togglePeer(unit.seq) },
- )
+ PeerBlockRow(unit, replies, onToggle = { togglePeer(unit.seq) })
is TranscriptUnit.UserChunk ->
UserChunkRow(unit, settings, summary.id, ::openImage)
is TranscriptUnit.Memory ->
diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt
index bec357a..a3f085f 100644
--- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt
+++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt
@@ -541,12 +541,12 @@ private val parsingThreads = Dispatchers.Default.limitedParallelism(2)
* whole point: the work happens seconds before the reader reaches the rows it was done for. See
* [ParsedReplies].
*
- * What is warmed mirrors what the rows draw, unit by unit -- prose split into its blocks, a memory
- * note whole, a peer message split the same way prose is -- because a string warmed under a key no
- * row ever looks up is a miss that nothing reports; see [transcriptUnits], which is the flatten
- * this has to agree with. It reads the same [ParsedReplies.partsOf] and [ParsedReplies.blocksOf]
- * caches the flatten does, so a message is scanned once however many pages hand it back through
- * here, while the whole loaded transcript crosses this on every page.
+ * What is warmed mirrors what the rows draw -- each prose part of a reply, a memory note, a peer
+ * message, every one of them whole, since every piece of a message is drawn from its one parse --
+ * because a string warmed under a key no row ever looks up is a miss that nothing reports; see
+ * [transcriptUnits], which is the flatten this has to agree with. It reads the same
+ * [ParsedReplies.partsOf] cache the flatten does, so a message is scanned once however many pages
+ * hand it back through here, while the whole loaded transcript crosses this on every page.
*
* Every kind of row that draws markdown belongs in the `when` below. That is the rule the peer
* message was missing: this used to filter for assistant replies alone, so the one row type nobody
@@ -557,21 +557,12 @@ suspend fun warm(replies: ParsedReplies, rows: List) {
withContext(parsingThreads) {
val texts = rows.flatMap { row ->
when (row) {
- is TranscriptItem.AssistantMsg ->
- replies.partsOf(row.text).flatMap { part ->
- when (part) {
- is MessagePart.Prose -> replies.blocksOf(part.text)
- // Drawn as one MarkdownText, so its whole text is the key
- // looked up.
- is MessagePart.Remembered -> listOf(part.text)
- }
- }
+ is TranscriptItem.AssistantMsg -> replies.partsOf(row.text).map { it.text }
// A message from another agent is markdown too, and it is the longest thing
// in a transcript often enough that leaving it out was the whole of why one
// cost a fifth of a second to open: it was the only markdown in the app
- // parsed on the thread that draws. Its blocks, not its text, because
- // [PeerMessageRow] draws it a block at a time.
- is TranscriptItem.PeerNote -> replies.blocksOf(row.text)
+ // parsed on the thread that draws.
+ is TranscriptItem.PeerNote -> listOf(row.text)
else -> emptyList()
}
}
diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt
index a4da80a..7e4da80 100644
--- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt
+++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt
@@ -49,11 +49,12 @@ sealed class TranscriptUnit {
get() = 0
}
- /** One markdown block of a settled reply. */
+ /** One [Piece] of a settled reply; [text] is the prose it is a piece of. */
data class Block(
override val seq: Long,
override val ordinal: Int,
val text: String,
+ val piece: Piece,
override val gap: Dp,
) : TranscriptUnit() {
override val key: Any
@@ -93,12 +94,18 @@ sealed class TranscriptUnit {
get() = 0
}
- /** One markdown block of an opened peer message; [last] is the piece that closes the card. */
+ /**
+ * One [Piece] of an opened peer message; [last] is the piece that closes the card. Its [gap] is
+ * always zero -- the pieces are one card -- so the room between blocks is [spacing], drawn
+ * inside the piece where the card's fill covers it.
+ */
data class PeerBlock(
override val seq: Long,
override val ordinal: Int,
val text: String,
+ val piece: Piece,
val last: Boolean,
+ val spacing: Dp,
override val gap: Dp,
/** The note this block belongs to; its key, not its seq. See [TranscriptItem.PeerNote]. */
val note: Any,
@@ -145,19 +152,19 @@ sealed class TranscriptUnit {
* The rows flattened into list units, newest first -- index zero is the item at the bottom of the
* screen, which is what a reversed lazy list calls the start.
*
- * Every settled reply is cut into its blocks ([markdownBlocks], via the caches on [replies] so a
- * message is only ever split once), and so is an *opened* peer message -- [openNotes] is which ones
- * those are, which is why the flatten needs it. A shut one is a single heading and cannot be worth
- * splitting. The reply still arriving -- the newest row, until the status event that ends its turn
- * marks it [TranscriptItem.AssistantMsg.settled] -- stays whole: its text changes with every delta,
- * and splitting it here would parse the whole message per delta on whichever thread is composing.
+ * Every settled reply is cut into its pieces ([pieces], via the caches on [replies] so a message is
+ * only ever cut once), and so is an *opened* peer message -- [openNotes] is which ones those are,
+ * which is why the flatten needs it. A shut one is a single heading and cannot be worth splitting.
+ * The reply still arriving -- the newest row, until the status event that ends its turn marks it
+ * [TranscriptItem.AssistantMsg.settled] -- stays whole: its text changes with every delta, and
+ * splitting it here would parse the whole message per delta on whichever thread is composing.
* [AssistantMessage]'s own streaming path already parses deltas off the main thread and gives the
- * live message a layer per block. Once settled it splits like every other reply, which is what
+ * live message a layer per piece. Once settled it splits like every other reply, which is what
* bounds the newest row's cost after a session ends on a long one.
*
* Runs per fold, so it must stay proportional to what is loaded with no parsing in it on the warm
- * path: [ParsedReplies.partsOf] and [ParsedReplies.blocksOf] are lookups for any text [warm] has
- * seen, and a miss -- the one message that just finished streaming -- costs its split exactly once.
+ * path: [ParsedReplies.partsOf] and [ParsedReplies.piecesOf] are lookups for any text [warm] has
+ * seen, and a miss -- the one message that just finished streaming -- costs its parse exactly once.
*/
fun transcriptUnits(
rows: List,
@@ -175,17 +182,21 @@ fun transcriptUnits(
// No gap between the pieces: they are one card, and a card with a stripe through it is
// what any spacing here would draw.
if (open) {
- val blocks = replies.blocksOf(item.text)
- blocks.forEachIndexed { at, block ->
+ val pieces = replies.piecesOf(item.text)
+ var previous: Piece? = null
+ pieces.forEachIndexed { at, piece ->
units +=
TranscriptUnit.PeerBlock(
row.startSeq,
at + 1,
- block,
- last = at == blocks.lastIndex,
+ item.text,
+ piece,
+ last = at == pieces.lastIndex,
+ spacing = gapBefore(previous, piece),
gap = 0.dp,
note = item.key,
)
+ previous = piece
}
}
} else if (item is TranscriptItem.UserMsg && item.text.length > USER_SPLIT_CHARS) {
@@ -210,16 +221,27 @@ fun transcriptUnits(
replies.splitReady(item.text)
) {
var ordinal = 0
- fun gap() = if (ordinal == 0) rowGap else BLOCK_SPACING
+ fun gap(within: Dp) = if (ordinal == 0) rowGap else within
replies.partsOf(item.text).forEach { part ->
when (part) {
- is MessagePart.Prose ->
- replies.blocksOf(part.text).forEach { block ->
- units += TranscriptUnit.Block(row.startSeq, ordinal, block, gap())
+ is MessagePart.Prose -> {
+ var previous: Piece? = null
+ replies.piecesOf(part.text).forEach { piece ->
+ units +=
+ TranscriptUnit.Block(
+ row.startSeq,
+ ordinal,
+ part.text,
+ piece,
+ gap(gapBefore(previous, piece)),
+ )
ordinal++
+ previous = piece
}
+ }
is MessagePart.Remembered -> {
- units += TranscriptUnit.Memory(row.startSeq, ordinal, part, gap())
+ units +=
+ TranscriptUnit.Memory(row.startSeq, ordinal, part, gap(BLOCK_SPACING))
ordinal++
}
}
@@ -358,7 +380,8 @@ private val TranscriptUnit?.kind: String
get() =
when (this) {
null -> "the list's own"
- is TranscriptUnit.Block -> "reply block"
+ is TranscriptUnit.Block ->
+ if (piece.item == Piece.WHOLE_BLOCK) "reply block" else "list item"
is TranscriptUnit.PeerHead -> if (open) "peer heading (open)" else "peer heading"
is TranscriptUnit.PeerBlock -> "peer block"
is TranscriptUnit.UserChunk -> "user slice"