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"