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 e524d98..dbbe847 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -295,6 +295,12 @@ fun SessionScreen( // Which memory notes are open, by the note's own text. Held here rather than in the card so a // note opened and scrolled past is still open on the way back. var openMemories by remember { mutableStateOf(setOf()) } + // Which capped blocks the reader has asked to see whole: a tool call's input or output + // ([Capped]), and long messages, by the row key that identifies them. Held here rather than in + // the card or the row for `openMemories`' reason -- something opened and scrolled past is still + // open on the way back, and a lazy list drops the composition of anything off screen. + var shownWholeCalls by remember { mutableStateOf(setOf()) } + var shownWholeRows by remember { mutableStateOf(setOf()) } // The image being looked at full screen, by ref. Here rather than in the row that drew the // thumbnail: a row regrouped underneath the reader takes its whole subtree with it. var fullImage by remember { mutableStateOf(null) } @@ -382,12 +388,14 @@ fun SessionScreen( // Bumped when a cold reply's parses become ready, so the flatten runs again and can split it. var warmedTick by remember { mutableIntStateOf(0) } val units = - remember(rows, expandedNotes, warmedTick) { transcriptUnits(rows, replies, expandedNotes) } + remember(rows, expandedNotes, warmedTick, shownWholeRows) { + transcriptUnits(rows, replies, expandedNotes, shownWholeRows) + } // The reply that just finished streaming is the one row whose parses nobody has made: pages // warm before their fold lands, but nothing warms live deltas. Off the composing thread, then // the tick re-flattens, so settling never costs a whole-message parse in a frame. - LaunchedEffect(rows) { - val cold = unwarmedReplies(rows, replies) + LaunchedEffect(rows, shownWholeRows) { + val cold = unwarmedReplies(rows, replies, shownWholeRows) if (cold.isNotEmpty()) { warm(replies, cold) warmedTick++ @@ -550,6 +558,20 @@ fun SessionScreen( toggle() } + /** + * [toggleAnchored] for a control that sits *below* the row it grows -- a capped message's "Show + * all", which is its own list item under the message it reveals. + * + * Always the top edge, with no reading of which half was touched: the control is at the bottom + * of the row by construction, and the whole point of pressing it is that the text just above it + * continues. Held from the bottom instead, the revealed lines would push everything the reader + * had been reading up off the screen and leave them at the end of the message. + */ + fun expandAnchored(key: Any, reveal: () -> Unit) = expanding { + topEdgeHeld.key = key + reveal() + } + /** * Whether the row holding transcript position [seq] is loaded, with older history behind it. * @@ -1486,6 +1508,15 @@ fun SessionScreen( ) { unit -> when (unit) { is TranscriptUnit.Block -> MarkdownPiece(unit.text, unit.piece, replies) + is TranscriptUnit.ShowAll -> + ShowAllRow(unit.lines) { + // Anchored like every other control that changes a row's + // height: the reader is looking at the row this belongs to, and + // it is about to get much taller. + expandAnchored(unit.row) { + shownWholeRows = shownWholeRows + unit.row + } + } is TranscriptUnit.PeerHead -> PeerHeadRow( unit.item, @@ -1576,6 +1607,12 @@ fun SessionScreen( ::openImage, ) }, + isWhole = { it in shownWholeCalls }, + onShowAll = { capped -> + toggleAnchored(row) { + shownWholeCalls = shownWholeCalls + capped + } + }, ) is TranscriptRow.Single -> when (val item = row.item) { @@ -1621,6 +1658,16 @@ fun SessionScreen( ::openImage, ) }, + isWhole = { part -> + Capped(item.id, part) in shownWholeCalls + }, + onShowAll = { part -> + toggleAnchored(row) { + shownWholeCalls = + shownWholeCalls + + Capped(item.id, part) + } + }, ) is TranscriptItem.QuestionCard -> QuestionRow(item, ::answerAll) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TextCap.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TextCap.kt new file mode 100644 index 0000000..35c2009 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TextCap.kt @@ -0,0 +1,108 @@ +package com.example.aiapp + +/** + * How much of a long thing the transcript draws before offering the rest behind a tap. + * + * One rule, four surfaces: a tool call's input, its output, and a user or assistant message. Kept + * in one file because four copies would eventually disagree about what "too long" is -- and because + * the Rust app answers the same question with the same numbers (`client-core`'s `text_cap.rs`, the + * other half of this). The two are deliberately identical so that a benchmark comparing the apps is + * comparing renderers rather than policies. + * + * **Lines and bytes both, whichever runs out first**, because they run out on different things: a + * diff is thousands of short lines, a minified file or a base64 blob is one enormous one, and a cap + * counting only one of them draws the whole of the other. + * + * **Cut at the head, keeping the beginning.** A tool's output is read from the top and the line + * saying what went wrong is nearly always the first; a message is read from the top for the obvious + * reason. (A path is identified by its other end -- none of these is a path.) + */ +object TextCap { + /** + * The bound on a verbatim block -- a tool call's input or its output. Short, because this text + * is a machine's and the reader is looking for one line of it. + */ + const val VERBATIM_LINES = 80 + const val VERBATIM_BYTES = 4096 + + /** + * The bound on a message. Larger than a verbatim block's in bytes and smaller in lines: prose + * is read whole and wraps, so a screenful of it is far fewer lines than a screenful of a log, + * and cutting a reply at 80 lines would cut most long answers that nobody would call long. + */ + const val MESSAGE_LINES = 200 + const val MESSAGE_BYTES = 16 * 1024 +} + +/** [text] cut down to a bound, with the line count of the whole of it. See [cutText]. */ +data class CutText( + /** What to draw. */ + val shown: String, + /** + * The line count of the **whole** text, not of [shown] -- it is what the "Show all N lines" + * offer says, and a reader deciding whether to ask for the rest wants to know how much the rest + * is. + */ + val lines: Int, +) + +/** + * [text] cut to [maxLines] lines and [maxBytes] bytes, or `null` when the whole of it fits. + * + * Bytes rather than characters, so that this and the Rust half cut a multi-byte character at the + * same place. UTF-8 is what the wire carries and what `client-core` measures. + */ +fun cutText(text: String, maxLines: Int, maxBytes: Int): CutText? { + require(maxLines > 0 && maxBytes > 0) { + "a cap of nothing shows an empty block and a 'Show all' for every value there is" + } + val bytes = text.toByteArray(Charsets.UTF_8) + var byLines = -1 + var seen = 0 + for (i in text.indices) { + if (text[i] == '\n') { + seen++ + if (seen == maxLines) { + byLines = i + break + } + } + } + val byBytes = + if (bytes.size > maxBytes) { + // Back up to a character boundary. A UTF-8 continuation byte is `10xxxxxx`; cutting on + // one would split a character in half and `String(bytes)` would draw a replacement mark + // where an em dash was. + var end = maxBytes + while (end > 0 && (bytes[end].toInt() and 0xC0) == 0x80) end-- + String(bytes, 0, end, Charsets.UTF_8).length + } else { + -1 + } + val cut = + when { + byLines >= 0 && byBytes >= 0 -> minOf(byLines, byBytes) + byLines >= 0 -> byLines + byBytes >= 0 -> byBytes + else -> return null + } + return CutText(text.take(cut), lineCount(text)) +} + +/** + * How many lines [text] holds, counted the way Rust's `str::lines` counts them -- a trailing + * newline ends the last line rather than starting an empty one. + * + * Said here rather than left to `lineSequence().count()`, which disagrees on exactly that case: the + * two apps have to offer "Show all N lines" with the same N for the same message, and a count that + * is one out on every text ending in a newline (which is most tool output) would show it. + */ +fun lineCount(text: String): Int = + when { + text.isEmpty() -> 0 + text.endsWith("\n") -> text.count { it == '\n' } + else -> text.count { it == '\n' } + 1 + } + +/** What a "Show all" offer says, so the wording is one string rather than one per surface. */ +fun showAllLabel(lines: Int): String = "Show all $lines lines" diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt index 029b139..debcf52 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt @@ -1,5 +1,6 @@ package com.example.aiapp +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -9,6 +10,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import org.json.JSONObject @@ -105,33 +108,112 @@ fun parseToolInput(tool: String, input: String): ToolInput { * * The description is *not* here. It is the tool's own prose about what it is doing, so it belongs * with the reader's text rather than inside the machine's; [ToolCard] draws it above this. + * + * **Capped.** An `Edit`'s `old_string` and `new_string` arrive here whole and are routinely the + * largest text on the screen, so the input is cut to [TextCap.VERBATIM_LINES] / + * [TextCap. VERBATIM_BYTES] with a "Show all" under it -- one control for both blocks, because the + * subject and the leftover fields are two halves of the same answer to "what was this call given", + * and two would make the reader ask twice. [whole] is the reader having already asked. */ @Composable -fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) { +fun ToolInputView( + tool: String, + input: String, + modifier: Modifier = Modifier, + whole: Boolean = false, + onShowAll: () -> Unit = {}, +) { val parsed = remember(tool, input) { parseToolInput(tool, input) } if (parsed.subject == null && parsed.rest.isEmpty()) return + val subject = remember(parsed.subject, whole) { capped(parsed.subject, whole) } + val rest = + remember(parsed.rest, whole) { + capped(parsed.rest.takeIf { it.isNotEmpty() }?.joinToString("\n"), whole) + } RawBlock(modifier) { - parsed.subject?.let { subject -> + subject.shown?.let { shown -> // Not wrapped: a wrapped command hides where its arguments end, and the long one is the // one being read closely. Text( + // Highlighted over what is *drawn* rather than over the whole subject, so a cut + // cannot leave a span pointing past the end of the text it styles. + // // 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) }, + remember(shown, parsed.language) { highlight(shown, parsed.language) }, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, softWrap = false, modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), ) } - parsed.rest.forEach { + rest.shown?.let { shown -> + // Never dropped: a field left out would be claiming the tool has no other input when it + // might. Not wrapped, for the subject's reason -- Iris, 2026-09-08: "for 'raw' text + // like + // tool results I think it should not be wrapped". Text( - it, + shown, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 2.dp), + softWrap = false, + modifier = + Modifier.padding(top = 2.dp) + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), ) } + // The count is the whole input's, both blocks together, because that is what the one + // control reveals. + if (subject.cut || rest.cut) { + ShowAllRow(lines = subject.lines + rest.lines, onClick = onShowAll) + } } } + +/** One verbatim block as it will be drawn; see [capped]. */ +data class CappedBlock( + /** The text to draw, or `null` when there was none to begin with. */ + val shown: String?, + /** The line count of the whole of it. */ + val lines: Int, + /** Whether anything was left out. */ + val cut: Boolean, +) + +/** + * [text] as an open card draws it: the whole of it when [whole], or [TextCap]'s worth otherwise. + * + * `null` in, `null` out, so a caller with nothing to draw reads the same three fields as one with + * something. + */ +private fun capped(text: String?, whole: Boolean): CappedBlock { + if (text == null) return CappedBlock(null, 0, false) + val cut = if (whole) null else cutText(text, TextCap.VERBATIM_LINES, TextCap.VERBATIM_BYTES) + return when (cut) { + null -> CappedBlock(text, lineCount(text), false) + else -> CappedBlock(cut.shown, cut.lines, true) + } +} + +/** + * The "Show all N lines" under a capped block. + * + * It says the count rather than "more" because the reader is deciding whether to ask for it: "Show + * all 4,000 lines" and "Show all 12 lines" are different decisions, and "more" tells them apart not + * at all. + */ +@Composable +fun ShowAllRow(lines: Int, onClick: () -> Unit) { + val label = showAllLabel(lines) + Text( + label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier.padding(top = 4.dp).clickable(onClick = onClick).semantics { + contentDescription = label + }, + ) +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt index 6fc28c4..3054535 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -2,6 +2,7 @@ package com.example.aiapp import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CornerBasedShape import androidx.compose.foundation.shape.CornerSize import androidx.compose.material3.Card @@ -163,6 +165,9 @@ fun ToolGroup( onToolToggle: (String) -> Unit, onAnswer: (List, onSettled: () -> Unit) -> Unit, image: @Composable (String) -> Unit, + /** Which capped blocks the reader has asked to see whole; see [Capped]. */ + isWhole: (Capped) -> Boolean = { false }, + onShowAll: (Capped) -> Unit = {}, ) { val heading = "Called ${group.calls.size} tools" if (!expanded) { @@ -202,6 +207,8 @@ fun ToolGroup( onToggle = { onToolToggle(call.id) }, onAnswer = onAnswer, image = image, + isWhole = { part -> isWhole(Capped(call.id, part)) }, + onShowAll = { part -> onShowAll(Capped(call.id, part)) }, shape = connectedShape(index, group.calls.size), ) } @@ -272,6 +279,21 @@ private val GROUP_INSET = 4.dp /** Enough to read the join as a join rather than as one tall card. */ private val GROUP_GAP = 2.dp +/** + * Which half of an open card a cap and its "Show all" belong to. + * + * The two are capped and revealed independently: opening the whole of a call's input says nothing + * about wanting the whole of its output, and one control revealing both would make the card jump by + * the sum of two things when it was asked about one. + */ +enum class ToolPart { + INPUT, + OUTPUT, +} + +/** One capped thing on the screen that the reader may ask to see whole. */ +data class Capped(val call: String, val part: ToolPart) + /** * One tool call. * @@ -291,6 +313,11 @@ fun ToolCard( onToggle: () -> Unit, onAnswer: (List, onSettled: () -> Unit) -> Unit, image: @Composable (String) -> Unit = {}, + /** + * Whether the reader has asked for the whole of this call's input or output; see [ToolPart]. + */ + isWhole: (ToolPart) -> Boolean = { false }, + onShowAll: (ToolPart) -> Unit = {}, /** Square where this card faces another in a group; see [connectedShape]. */ shape: Shape = CardDefaults.shape, ) { @@ -353,11 +380,28 @@ fun ToolCard( // something answerable; dumping the same JSON above them would be the decision // stated twice, once unreadably. if (tool.tool != ASK_USER_QUESTION) { - ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp)) + ToolInputView( + tool.tool, + tool.input, + Modifier.padding(top = 4.dp), + whole = isWhole(ToolPart.INPUT), + onShowAll = { onShowAll(ToolPart.INPUT) }, + ) } if (tool.output.isNotEmpty()) { Spacer(Modifier.height(8.dp)) Text("Output", style = MaterialTheme.typography.labelSmall) + // Capped like the input, and revealed separately from it: a reader who wants + // the whole of a 900-line `new_string` rarely also wants the whole of the build + // log underneath it. + val wholeOutput = isWhole(ToolPart.OUTPUT) + val cut = + remember(tool.output, wholeOutput) { + if (wholeOutput) null + else + cutText(tool.output, TextCap.VERBATIM_LINES, TextCap.VERBATIM_BYTES) + } + val shown = cut?.shown ?: tool.output // What the tool printed, on the surface everything verbatim gets and in the // face it was written for: this is column-aligned far more often than it is // prose, and a proportional font silently destroys the alignment that carried @@ -367,13 +411,23 @@ fun ToolCard( // often the whole of what a diff or a test run is saying. Remembered against // the text, so a card that is open through a scroll parses once. val palette = remember { ansiPalette() } - val styled = remember(tool.output, palette) { ansiStyled(tool.output, palette) } + val styled = remember(shown, palette) { ansiStyled(shown, palette) } RawBlock(Modifier.padding(top = 2.dp)) { + // Not wrapped, and panning sideways instead -- Iris, 2026-09-08: "for 'raw' + // text like tool results I think it should not be wrapped". Wrapping a + // column-aligned log is what destroys the alignment that carried its + // meaning, one line at a time and only on the long lines. Text( styled, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, + softWrap = false, + modifier = + Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), ) + if (cut != null) { + ShowAllRow(cut.lines) { onShowAll(ToolPart.OUTPUT) } + } } } } 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 2733f54..8374d19 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt @@ -130,6 +130,26 @@ sealed class TranscriptUnit { get() = "u$seq:$ordinal" } + /** + * The "Show all N lines" under a message drawn only as far as [TextCap.MESSAGE_LINES]. + * + * Its own unit rather than something inside the row above it, because the row above it is a + * *bounded* item now and this is what says so -- and because a control that lives inside the + * thing it reveals moves the moment it is pressed. + */ + data class ShowAll( + override val seq: Long, + override val ordinal: Int, + /** The row this belongs to; what goes into the set of rows shown whole. */ + val row: Any, + /** The line count of the whole message, which is what the offer says. */ + val lines: Int, + override val gap: Dp, + ) : TranscriptUnit() { + override val key: Any + get() = "s$row" + } + /** One memory note of a settled reply; see [MemoryNote]. */ data class Memory( override val seq: Long, @@ -142,6 +162,36 @@ sealed class TranscriptUnit { } } +/** + * A message row cut to [TextCap]'s worth of itself, and the line count of the whole of it. + * + * The cut happens **before** the flatten below decides how to draw the row, so everything after it + * -- pieces, chunks, warming -- sees a shorter message and needs to know nothing about caps. The + * shortened row keeps its key and its seq, so the list's identity and every saved scroll anchor are + * untouched by a reader opening or closing one. + * + * A reply still arriving is never capped: it grows by deltas, and a row that stopped growing at two + * hundred lines while the model was plainly still writing would read as the stream having died. + * `iris`'s `row::build_row` states the same rule for the same reason. + */ +private fun capRow(row: TranscriptRow, shownWhole: Set): Pair { + val item = (row as? TranscriptRow.Single)?.item ?: return row to null + if (row.key in shownWhole) return row to null + val cut = + when { + item is TranscriptItem.UserMsg -> + cutText(item.text, TextCap.MESSAGE_LINES, TextCap.MESSAGE_BYTES)?.let { + it to TranscriptRow.Single(item.copy(text = it.shown)) + } + item is TranscriptItem.AssistantMsg && item.settled -> + cutText(item.text, TextCap.MESSAGE_LINES, TextCap.MESSAGE_BYTES)?.let { + it to TranscriptRow.Single(item.copy(text = it.shown)) + } + else -> null + } ?: return row to null + return cut.second to cut.first.lines +} + /** * 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. @@ -161,11 +211,14 @@ fun transcriptUnits( rows: List, replies: ParsedReplies, openNotes: Set, + shownWhole: Set = emptySet(), ): List { val started = System.nanoTime() val units = ArrayList(rows.size) - rows.forEachIndexed { index, row -> + rows.forEachIndexed { index, whole -> val rowGap = if (index == 0) 0.dp else TRANSCRIPT_SPACING + val (row, hidden) = capRow(whole, shownWhole) + val rowStart = units.size val item = (row as? TranscriptRow.Single)?.item if (item is TranscriptItem.PeerNote) { val open = item.seq in openNotes @@ -240,6 +293,16 @@ fun transcriptUnits( } else { units += TranscriptUnit.Whole(row, rowGap) } + if (hidden != null) { + units += + TranscriptUnit.ShowAll( + row.startSeq, + units.size - rowStart, + row.key, + hidden, + BLOCK_SPACING, + ) + } } units.reverse() reportDuplicateKeys(units) @@ -266,11 +329,18 @@ private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex * what this returns off-thread and re-flattens, so the whole-to-blocks swap always composes against * ready parses. */ -fun unwarmedReplies(rows: List, replies: ParsedReplies): List = - rows.mapIndexedNotNull { index, row -> - val item = (row as? TranscriptRow.Single)?.item as? TranscriptItem.AssistantMsg - item?.takeIf { splitWanted(it, index, rows.lastIndex) && !replies.splitReady(it.text) } - } +fun unwarmedReplies( + rows: List, + replies: ParsedReplies, + shownWhole: Set = emptySet(), +): List = rows.mapIndexedNotNull { index, whole -> + // The *capped* row's text, since that is what the flatten will draw and so what has to be + // ready: a capped row draws its head, which is a different string from the message and so a + // different cache entry. + val row = capRow(whole, shownWhole).first + val item = (row as? TranscriptRow.Single)?.item as? TranscriptItem.AssistantMsg + item?.takeIf { splitWanted(it, index, rows.lastIndex) && !replies.splitReady(it.text) } +} /** * Above this many characters, a user message is drawn in slices rather than as one bubble. @@ -375,6 +445,7 @@ private val TranscriptUnit?.kind: String is TranscriptUnit.PeerBlock -> "peer block" is TranscriptUnit.UserChunk -> "user slice" is TranscriptUnit.Memory -> "memory note" + is TranscriptUnit.ShowAll -> "show all" is TranscriptUnit.Whole -> when (val row = row) { is TranscriptRow.Tools -> "tool group" diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/TextCapTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/TextCapTest.kt new file mode 100644 index 0000000..c4c1094 --- /dev/null +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/TextCapTest.kt @@ -0,0 +1,63 @@ +package com.example.aiapp + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The cap rule, which this app and the Rust one have to answer identically -- these mirror + * `client-core/src/text_cap.rs`'s own tests case for case, because "Show all 4,000 lines" appearing + * in one app and "Show all 4,001" in the other is exactly the kind of difference a benchmark + * comparing the two would report as a rendering difference. + */ +class TextCapTest { + @Test + fun textUnderBothBoundsIsNotCut() { + assertNull(cutText("one\ntwo\nthree", 80, 4096)) + } + + @Test + fun theLineBoundCutsAtALineBoundary() { + val cut = cutText("a\nb\nc\nd\n", 2, 4096)!! + assertEquals("a\nb", cut.shown) + assertEquals(4, cut.lines, "the count is the whole text's, not the shown part's") + } + + /** + * The half the line bound cannot catch: one enormous line, which is what a minified file is. + */ + @Test + fun theByteBoundCutsOneLongLine() { + val cut = cutText("x".repeat(5000), 80, 4096)!! + assertEquals(4096, cut.shown.length) + assertEquals(1, cut.lines) + } + + @Test + fun theTighterOfTheTwoBoundsWins() { + val text = "aaaa\n".repeat(100) + assertEquals(100, cutText(text, 80, 100)!!.shown.length) + assertEquals("aaaa\naaaa\naaaa\naaaa", cutText(text, 4, 4096)!!.shown) + } + + /** + * A cut landing inside a multi-byte character has to back up to the boundary. The Rust half + * measures in UTF-8 bytes, so this one does too -- counting UTF-16 characters instead would cut + * the same text at a different place in every message with an em dash in it. + */ + @Test + fun aCutInsideAMultibyteCharacterBacksUpToTheBoundary() { + val cut = cutText("é".repeat(100), 80, 11)!! + assertEquals("é".repeat(5), cut.shown, "11 bytes lands mid-character; 10 is the cut") + } + + /** A trailing newline ends the last line rather than starting an empty one; see [lineCount]. */ + @Test + fun lineCountMatchesRustsStrLines() { + assertEquals(0, lineCount("")) + assertEquals(1, lineCount("a")) + assertEquals(1, lineCount("a\n")) + assertEquals(2, lineCount("a\nb")) + assertEquals(2, lineCount("a\nb\n")) + } +} diff --git a/client-core/src/lib.rs b/client-core/src/lib.rs index a0ebc6c..2aa0c72 100644 --- a/client-core/src/lib.rs +++ b/client-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod log_ring; pub mod markdown_blocks; pub mod notifications; pub mod sse; +pub mod text_cap; pub mod tool_summary; pub mod transcript_cache; pub mod transcript_fold; diff --git a/client-core/src/text_cap.rs b/client-core/src/text_cap.rs new file mode 100644 index 0000000..6601281 --- /dev/null +++ b/client-core/src/text_cap.rs @@ -0,0 +1,134 @@ +//! How much of a long thing a transcript draws before offering the rest +//! behind a tap. +//! +//! One rule, four surfaces: a tool call's input, its output, and a user or +//! assistant message. It lives here rather than at any one of them because +//! four copies would eventually disagree about what "too long" is, and +//! because the Compose app has to answer the same question the same way -- +//! `TextCap.kt` is the Kotlin half, and the two are checked against the +//! same numbers so a benchmark comparing the apps is comparing renderers +//! rather than policies. +//! +//! **Lines and bytes both, whichever runs out first**, because they run +//! out on different things: a diff is thousands of short lines, a minified +//! file or a base64 blob is one enormous one, and a cap that only counted +//! one of them draws the whole of the other. +//! +//! **Cut at the head, keeping the beginning.** A tool's output is read +//! from the top and the line saying what went wrong is nearly always the +//! first; a message is read from the top for the obvious reason. (A path +//! is identified by its other end -- none of these is a path.) + +/// The default bound on a verbatim block -- a tool call's input or its +/// output. Short, because this text is a machine's and the reader is +/// looking for one line of it. +pub const VERBATIM_LINES: usize = 80; +pub const VERBATIM_BYTES: usize = 4096; + +/// The bound on a message, a person's or the model's. Larger than a +/// verbatim block's in bytes and smaller in lines: prose is read whole and +/// wraps, so a screenful of it is far fewer lines than a screenful of a +/// log, and cutting a reply at 80 lines would cut most long answers that +/// nobody would call long. +pub const MESSAGE_LINES: usize = 200; +pub const MESSAGE_BYTES: usize = 16 * 1024; + +/// A cap of nothing would draw an empty panel and a "Show all" for +/// everything there is, which reads as a rendering fault rather than as a +/// cap. Checked at compile time, since all four are constants. +const _: () = assert!(VERBATIM_LINES > 0 && VERBATIM_BYTES > 0); +const _: () = assert!(MESSAGE_LINES > 0 && MESSAGE_BYTES > 0); + +/// `text` cut to `max_lines` lines and `max_bytes` bytes, with the line +/// count it was cut *from*; `None` when the whole of it fits. +/// +/// The count is the whole text's, not the shown part's -- it is what the +/// "Show all N lines" offer says, and a reader deciding whether to ask for +/// the rest wants to know how much the rest is. +pub fn cut(text: &str, max_lines: usize, max_bytes: usize) -> Option<(&str, usize)> { + debug_assert!( + max_lines > 0 && max_bytes > 0, + "a cap of nothing shows an empty block and a 'Show all' for every value there is", + ); + let by_lines = text + .char_indices() + .filter(|(_, c)| *c == '\n') + .nth(max_lines - 1) + .map(|(i, _)| i); + let by_bytes = (text.len() > max_bytes).then(|| { + let mut end = max_bytes; + // Back up to a character boundary: a cut inside a multi-byte + // character panics on the slice below, and a transcript is full of + // them. + while !text.is_char_boundary(end) { + end -= 1; + } + end + }); + let cut = match (by_lines, by_bytes) { + (Some(a), Some(b)) => a.min(b), + (a, b) => a.or(b)?, + }; + Some((&text[..cut], text.lines().count())) +} + +/// What a "Show all" offer says, so the wording is one string rather than +/// one per surface. +pub fn show_all_label(lines: usize) -> String { + format!("Show all {lines} lines") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_under_both_bounds_is_not_cut() { + assert_eq!(cut("one\ntwo\nthree", 80, 4096), None); + } + + #[test] + fn the_line_bound_cuts_at_a_line_boundary() { + let text = "a\nb\nc\nd\n"; + let (shown, lines) = cut(text, 2, 4096).expect("four lines is over a bound of two"); + assert_eq!(shown, "a\nb"); + assert_eq!( + lines, 4, + "the count is the whole text's, not the shown part's" + ); + } + + /// The half the line bound cannot catch: one enormous line, which is + /// what a minified file or an embedded image arrives as. + #[test] + fn the_byte_bound_cuts_one_long_line() { + let text = "x".repeat(5000); + let (shown, lines) = cut(&text, 80, 4096).expect("5000 bytes is over a bound of 4096"); + assert_eq!(shown.len(), 4096); + assert_eq!(lines, 1); + } + + /// Whichever bites first, rather than whichever was checked first. + #[test] + fn the_tighter_of_the_two_bounds_wins() { + let text = "aaaa\n".repeat(100); + let (shown, _) = cut(&text, 80, 100).expect("over both"); + assert_eq!(shown.len(), 100, "the byte bound is the tighter one here"); + let (shown, _) = cut(&text, 4, 4096).expect("over the line bound"); + assert_eq!(shown, "aaaa\naaaa\naaaa\naaaa"); + } + + /// A cut that lands inside a multi-byte character has to back up to + /// the boundary; slicing there would panic, and a transcript carries + /// em dashes and box drawing in every other line. + #[test] + fn a_cut_inside_a_multibyte_character_backs_up_to_the_boundary() { + let text = "é".repeat(100); + let (shown, _) = cut(&text, 80, 11).expect("200 bytes is over a bound of 11"); + assert_eq!( + shown, + "é".repeat(5), + "11 bytes lands mid-character; 10 is the cut" + ); + } +} diff --git a/docs/IRIS.md b/docs/IRIS.md index 3c3d031..412b07c 100644 --- a/docs/IRIS.md +++ b/docs/IRIS.md @@ -12,7 +12,56 @@ things still stay out. An entry gives the date, what changed, why, and a short before/after where it helps judge the change without the session that made it. Newest first. -## 2026-09-08 (newest): redrawing one widget cost O(its own primitives squared) +## 2026-09-08 (newest): a `LazySpan` clips itself, and nothing is unbounded + +Four things you asked for, in one change. + +**A lazy span no longer cares about masks.** It used to assert that +something around it had called `.masked()` and refuse to draw otherwise, +which is why a plain full-screen list -- the benchmark, any simple app -- +panicked on its second line. Your question was the right one: it cared +only because it draws a row straddling an edge *in full* (virtualisation +decides which rows, never how much of one) and relied on somebody else to +cut off the overhang. It clips itself to the box it was offered now, so a +caller places it like any other widget. That is also strictly stronger +than the assert was: a mask *larger* than the list's box satisfied +`is_masked` and let the overhang through anyway, which is the fault the +assert was written for. The transcript's own `.masked()` wrapper is gone +with it. + +**Everything on the transcript screen is capped now.** One rule in one +place -- `client_core::text_cap`, mirrored as `TextCap.kt` with the same +numbers, so a bench comparing the apps compares renderers and not +policies: + + a tool call's input 80 lines or 4 KiB -> "Show all N lines" + a tool call's output 80 lines or 4 KiB -> (already was) + a message 200 lines or 16 KiB -> "Show all N lines" + +The input is what your edit card needed: an `Edit`'s `old_string` and +`new_string` arrive whole and are routinely the biggest text on screen. +Messages are capped for the reason you gave -- both user and agent, in +both apps. + +Three rules that took a screenshot to get right. A message is cut on a +**block boundary**, not mid-block: cut to its own opening line a fence +renders as an empty panel, which reads as a fault rather than as a cap +(the exception is a message that is *one* enormous block, which is +truncated, since dropping it would leave the row blank). A reply still +streaming is **never** capped, because a row that stopped growing at two +hundred lines while the model was plainly still writing reads as the +stream having died. And the input's two blocks share **one** "Show all", +since they are two halves of one answer -- while input and output have +their own, since wanting the whole of a `new_string` says nothing about +wanting the whole of the build log under it. + +**The Compose app does not wrap raw text any more**, per your call: a +tool's leftover input fields and its output pan sideways like the command +already did. A wrapped log destroys the column alignment that carried its +meaning, one line at a time and only on the long lines -- so iris was +right and Compose was the one to change. + +## 2026-09-08: redrawing one widget cost O(its own primitives squared) Your report -- expanding a tool card with a long horizontally-scrolling edit in it lags -- is a framework defect, not a text-layout one, and the @@ -39,21 +88,15 @@ where that slot's handle sits in its owner's `ActiveData::primitives` `benches/message_list.rs` grew scenario **(g)** for it, and the number to read is per-glyph: flat as N grows is the pass condition, and a total -hides it. Two things about that file: it also caught the quadratic in -`--phone`-sized text, and it had stopped running at all -- scenarios (a) -and (e) built a `LazySpan` with no mask around it, which the span now -asserts against, so the whole benchmark panicked on its second line. -Fixed in the same change. +hides it. That file had also stopped running at all -- scenarios (a) and +(e) built a `LazySpan` with no mask around it, which the span asserted +against, so the whole benchmark panicked on its second line. Worked around +here and fixed properly in the entry above, which deletes the assert. -**What this does not fix, and what I would do next.** An open card still -shapes, rasterises and submits *every* glyph of its input and output, not -the screenful you can see -- iris does not cull within a widget, and a -tool card is the one place that bites, because an `Edit`'s `old_string` -and `new_string` go onto the card whole. The output half is already capped -(`OUTPUT_LINES`/`OUTPUT_BYTES` in `tool.rs`, 80 lines or 4 KiB behind a -"Show all"); the *input* half has no cap at all, and that is the -asymmetry to close. Wrapping the block, which you asked for, changes the -shape but not this cost: the same glyphs are laid out either way. +**What this does not fix**, and what the entry below closes: an open card +still shapes, rasterises and submits *every* glyph of its input and +output, not the screenful you can see. iris does not cull within a widget, +so the only bound available is a cap on what goes in. ## 2026-09-08: one `ScrollController`, a `Scrollable` trait, and `Pin` diff --git a/docs/IRIS_TODO.md b/docs/IRIS_TODO.md index 43ea59b..7a588e2 100644 --- a/docs/IRIS_TODO.md +++ b/docs/IRIS_TODO.md @@ -944,21 +944,20 @@ diagnosis and what building it actually costs. the shaped-mask work (`.masked_by`, 38bf630) is the likeliest, since the old chain was `.masked()` *inside* the padding. Left ticked with the original symptom recorded rather than deleted, in case it comes back. -- [ ] **An open tool card lays out every glyph of its input, however +- [x] **An open tool card lays out every glyph of its input, however long.** iris does not cull within a widget -- a `Text` shapes, rasterises and submits the whole string whether or not the box it sits in can show it -- and a tool card is where that bites, because an - `Edit`'s `old_string` and `new_string` go onto the card whole. The - *output* half is already capped at 80 lines or 4 KiB behind a "Show - all" (`tool.rs`'s `OUTPUT_LINES`/`OUTPUT_BYTES`); the input half has no - cap at all, which is the asymmetry to close, and the cheaper fix of the - two. Culling inside a `Text` is the other, and is a real design - question: the shaped layout knows where each glyph is, so a viewport - test is possible, but nothing else in iris cares where the screen is. - Found 2026-09-08 chasing Iris's "expanding the edit card lags" report, - whose actual cause was the quadratic `apply_free` (fixed; docs/IRIS.md). - Wrapping the block does not change this cost -- the same glyphs are laid - out either way. + `Edit`'s `old_string` and `new_string` go onto the card whole. Found + 2026-09-08 chasing Iris's "expanding the edit card lags" report, whose + actual cause was the quadratic `apply_free` (fixed; docs/IRIS.md). + **Done 2026-09-08**: the input is capped exactly as the output already + was, behind a "Show all N lines" -- and so are messages, in both apps + (`client_core::text_cap`, `TextCap.kt`). What is *not* done is culling + inside a `Text`, which stays a real design question: the shaped layout + knows where each glyph is, so a viewport test is possible, but nothing + else in iris cares where the screen is, and with the caps in place + nothing on this screen is unbounded any more. - [ ] **No overflow ellipsis.** `TextAttrs` can wrap or not wrap; there is no "one line, ellipsised" the way `maxLines = 1` + `TextOverflow. Ellipsis` gives Compose. A tool card's summary is clipped instead, so diff --git a/iris/benches/message_list.rs b/iris/benches/message_list.rs index 33ae3c8..1bf0b50 100644 --- a/iris/benches/message_list.rs +++ b/iris/benches/message_list.rs @@ -127,19 +127,10 @@ fn build_message_list( list.push_back(LazyItem::new(i as u64, row)); } let list = rsc.ui.widgets.add_strong(list); - let weak = list.weak(); - // Masked because a `LazySpan` requires it -- it draws a row straddling - // an edge in full and relies on the clip to cut it off, and asserts as - // much rather than letting the overhang reach the screen. The app's - // own transcript screen puts the same mask around the same widget. - let root = rsc.ui.widgets.add_strong(Masked { - shape: None, - inner: list.any(), - }); // Driven through the span's own `ScrollController`, like every other // scroll area in iris: what this measures has to be the path the app // actually takes. - (weak, root.any()) + (list.weak(), list.any()) } fn report(label: &str, elapsed: std::time::Duration, draws: u64, rewrites: u64, moves: u64) { @@ -376,15 +367,7 @@ fn bench_expand_holds_edge(n: usize, growths: usize) { } let list = rsc.ui.widgets.add_strong(list); let list_weak = list.weak(); - // Masked for `build_message_list`'s reason -- a `LazySpan` requires it. - let root = rsc - .ui - .widgets - .add_strong(Masked { - shape: None, - inner: list.any(), - }) - .any(); + let root = list.any(); let growable = growable.unwrap(); let mut render = UiRenderState::new(); diff --git a/iris/core/src/ui/painter.rs b/iris/core/src/ui/painter.rs index 66f015d..9d6e54b 100644 --- a/iris/core/src/ui/painter.rs +++ b/iris/core/src/ui/painter.rs @@ -181,15 +181,6 @@ impl<'a> Painter<'a> { self.mask = self.own_mask; } - /// Whether anything is clipping what this widget draws -- its own - /// [`Self::set_mask`], or one an ancestor set that it inherited. What - /// a widget whose contents may legitimately extend past its own box - /// (`iris::widget::LazySpan`, which draws a row straddling an edge in - /// full) asserts before relying on being cut off there. - pub fn is_masked(&self) -> bool { - self.mask != MaskIdx::NONE - } - /// Draws a widget within this widget's region, returning the size it /// reported using. pub fn widget(&mut self, id: &StrongWidget) -> Size { diff --git a/iris/src/widget/position/lazy_span.rs b/iris/src/widget/position/lazy_span.rs index a04e134..7d233c7 100644 --- a/iris/src/widget/position/lazy_span.rs +++ b/iris/src/widget/position/lazy_span.rs @@ -11,6 +11,26 @@ //! //! ## Design //! +//! **It clips itself to the region it is drawn in.** A row that straddles +//! either edge is drawn in *full* -- virtualisation decides which rows are +//! drawn, never how much of one -- so the overhang past this list's box +//! has to be cut off, and the box it is cut to is the one the list was +//! offered. `draw` sets that clip itself, so **a caller places a lazy span +//! the way it places any other widget** and never has to know a mask is +//! involved. +//! +//! It used to be the caller's job, enforced by asserting on +//! `Painter::is_masked` and refusing to draw otherwise -- wrong twice +//! over. An ordinary full-screen list (every benchmark, every simple app) +//! panicked for want of ceremony that would have changed nothing on +//! screen; and the case that actually bites still passed the check, since +//! a mask *larger* than the list's box satisfies `is_masked` and lets the +//! overhang through anyway. That is the fault it was written for: the +//! transcript panned to its top edge, drawing code through the header bar +//! above it on Iris's phone (docs/IRIS_TODO.md, 2026-09-07). Clipping to +//! its own region cannot get that wrong. Iris, 2026-09-08: "why does it +//! care about mask at all?" +//! //! **Rows are keyed by a `u64` (`RowKey`), not a generic type.** Every real //! row source in this codebase (a transcript's monotonic sequence number, a //! chat message id) is already an integer; a generic key would cost every @@ -1229,26 +1249,10 @@ impl Widget for LazySpan { fn draw(&mut self, painter: &mut Painter) -> Size { let axis = self.dir.axis; - // A row that straddles either edge is drawn in full - // (`intersects_viewport`), so the part of it outside this list's - // box is on screen unless something clips it -- and with nothing - // clipping it, a transcript panned to its top edge drew code and - // paragraphs straight through the header bar above it on Iris's - // phone (docs/IRIS_TODO.md, 2026-09-07). Clipping is `.masked()`, - // one mechanism, applied by whoever places the list -- a `LazySpan` - // cannot set the mask itself, since `Painter::set_mask` allows one - // mask per widget and rows of this list already use their own - // (`transcript-ui`'s `row.rs`, `tool.rs`). So it checks instead. - // - // `assert!`, not `debug_assert!`: one bool per draw, and what it - // catches is a `LazySpan` painting over its surroundings with nothing - // on screen saying so -- the fault e922b73 was written to fix. - // Every build that runs is release (docs/REVIEW-2026-09-07.md's R1). - assert!( - painter.is_masked(), - "a `LazySpan` must be drawn inside something `.masked()`: it draws rows straddling both \ - edges in full, so the parts outside its own box reach the screen otherwise", - ); + // The clip is this list's own box -- see the module doc. Set here + // rather than required of the caller, so that placing a lazy span + // is placing a widget and nothing else. + painter.set_mask(painter.region()); let output_len = painter.output_size().axis(axis); self.viewport_len = painter.region().axis(axis).len().to_abs(output_len); diff --git a/iris/transcript-fixture/tests/top_edge.rs b/iris/transcript-fixture/tests/top_edge.rs index 1cff4a8..2b55564 100644 --- a/iris/transcript-fixture/tests/top_edge.rs +++ b/iris/transcript-fixture/tests/top_edge.rs @@ -113,15 +113,21 @@ fn the_row_across_the_top_edge_is_drawn() { /// so the only thing between its earlier lines and the header bar is this /// mask -- with none, the phone drew `version = "0.1.0"` behind the "Run /// benchmark" button. +/// +/// The clip is the list's **own** mask (`own_mask`), not one it inherited: +/// a `LazySpan` clips itself to the box it is offered, so nothing around +/// it has to (its module doc, 2026-09-08). Reading `mask` -- what it +/// inherited -- is what this asserted while the caller supplied the clip, +/// and it is now `NONE` for a list nothing else wraps. #[test] fn the_list_is_clipped_to_its_own_box() { let (h, screen) = opened(); let active = h.render.active.get(&screen.list.id()).expect("drawn"); assert!( - active.mask != MaskIdx::NONE, + active.own_mask != MaskIdx::NONE, "the transcript's list is drawn with nothing clipping it", ); - let clip = h.render.mask_region(active.mask, &h.rsc); + let clip = h.render.mask_region(active.own_mask, &h.rsc); let list = list_box(&h, &screen); assert!( clip.top_left.y >= list.top_left.y - 0.5 && clip.bot_right.y <= list.bot_right.y + 0.5, @@ -145,11 +151,11 @@ fn the_list_is_clipped_to_its_own_box() { for row in rows { for prim in primitives_under(&h, row) { assert!( - mask_chain(&h, prim).contains(&active.mask), + mask_chain(&h, prim).contains(&active.own_mask), "a primitive of row {row:?} clips to {:?}, a chain that never reaches the list's \ own mask {:?}", mask_chain(&h, prim), - active.mask, + active.own_mask, ); checked += 1; } diff --git a/iris/transcript-ui/src/lib.rs b/iris/transcript-ui/src/lib.rs index 7e27e1d..39ab13f 100644 --- a/iris/transcript-ui/src/lib.rs +++ b/iris/transcript-ui/src/lib.rs @@ -47,6 +47,7 @@ pub mod composer; pub mod markdown; pub mod row; pub mod selection; +pub(crate) mod tap; pub mod tool; use client_core::transcript_fold::TranscriptRow as FoldedRow; @@ -92,12 +93,18 @@ impl TranscriptScreen { where Rsc::State: FocusHost + OpenUrl, { + // Capped like any other row (`row::build_row`'s `cap`). A reply + // that goes on to *grow* past the cap is never capped, because it + // grows through `RowBlocks::apply_delta`, which appends to what is + // already drawn -- so the cap only ever catches a row that arrived + // long, which is the one nobody is watching arrive. let (key, widget, tail) = row::build_row( rsc, self.list, self.selection.clone(), row, self.session_working.get(), + true, ); (self.list)(rsc).push_back(LazyItem::new(key, widget)); *self.tail.borrow_mut() = tail.map(|t| (key, t)); @@ -281,12 +288,17 @@ impl TranscriptScreen { // pointing at widgets the `drop` below frees (the shape // docs/REVIEW-2026-09-06.md's finding 1 called out). self.selection.borrow_mut().unregister(old_key); + // Uncapped: this is the row a delta just failed to land + // in, and the reason may be that it *is* capped + // (`RowBlocks::capped`). Rebuilding it capped again would + // refuse the next delta the same way, once per event. let (new_key, widget, kept) = row::build_row( rsc, self.list, self.selection.clone(), &new_rows[common], self.session_working.get(), + false, ); let evicted = (self.list)(rsc).replace_back(LazyItem::new(new_key, widget)); drop(evicted); // frees the old row's widget, same as a pop would @@ -368,12 +380,16 @@ where // exists to avoid, and nothing on screen or in `take_rebuilds` would // say so. let mut tail = None; - for row in &rows { + for (i, row) in rows.iter().enumerate() { // `false`: a row built here is history until the caller says the // session is working (`TranscriptScreen::set_session_working`), // and claiming a call is running because the screen happens to be // opening is exactly the inferred-as-measured mistake. - let (key, widget, kept) = row::build_row(rsc, list, selection.clone(), row, false); + // `cap`: every row but the last. The last is the tail, which may + // be a reply already streaming when this screen opened, and a + // capped row cannot take a delta (`RowBlocks::capped`). + let cap = i + 1 < rows.len(); + let (key, widget, kept) = row::build_row(rsc, list, selection.clone(), row, false, cap); list(rsc).push_back(LazyItem::new(key, widget)); tail = kept.map(|t| (key, t)); } @@ -432,13 +448,12 @@ where let (composer, composer_bar) = composer::build_composer(rsc); - // `.masked()`: the list draws the row straddling each of its edges in - // full (`LazySpan::intersects_viewport`), so without a clip the top of - // that row is drawn above the list -- through whatever the app put - // there, which on the phone is the header bar (docs/IRIS_TODO.md, - // 2026-09-07: "code and a paragraph visible behind Run benchmark"). - // The same clip is what `LazySpan::draw` asserts it has. - let tree = (list.width(rest(1)).height(rest(1)).masked(), composer_bar) + // No `.masked()` around the list: a `LazySpan` clips itself to the box + // it is offered (its module doc), which is the clip this used to + // supply -- the one that keeps the straddling top row off the header + // bar above it (docs/IRIS_TODO.md, 2026-09-07: "code and a paragraph + // visible behind Run benchmark"). + let tree = (list.width(rest(1)).height(rest(1)), composer_bar) .span(Dir::DOWN) .add_strong(rsc) .any(); @@ -974,6 +989,64 @@ mod apply_tests { ); } + /// The text shapes a screen holding `text` as its first message costs + /// to draw. A second, tiny row follows it, so the message under test + /// is **not** the tail -- the tail is deliberately uncapped + /// (`row::build_row`'s `cap`), and measuring it would measure the one + /// row the cap does not apply to. + fn shapes_for_message(text: &str) -> u64 { + let mut rsc = TestRsc { + ui: UiData::default(), + events: EventManager::default(), + }; + let items = vec![ + TranscriptItem::AssistantMsg { + seq: 1, + text: text.to_string(), + settled: true, + }, + TranscriptItem::AssistantMsg { + seq: 2, + text: "ok".to_string(), + settled: true, + }, + ]; + let (_screen, tree) = build_tree( + &mut rsc, + client_core::transcript_fold::group_tool_runs(&items), + ); + let mut render = UiRenderState::new(); + render.resize((1080.0, 20000.0)); + render.update(&tree, &mut rsc); + let (_, _, _, shapes) = render.take_counters(); + shapes + } + + /// **A long message is drawn as far as the cap and no further** + /// (Iris, 2026-09-08). Counted in text shapes rather than draws, + /// because that is the cost that grows with the message: every block + /// past the cap is a parley layout of text nobody asked for. + /// + /// The bound is the cap's own, not the short message's -- the capped + /// row genuinely draws more than a two-word one -- so this asserts + /// that the cost stops growing with the message rather than that it + /// is zero. + #[test] + fn a_long_message_is_drawn_only_as_far_as_the_cap() { + let paragraphs = |n: usize| "a paragraph of a reply\n\n".repeat(n); + let capped = shapes_for_message(¶graphs(client_core::text_cap::MESSAGE_LINES * 4)); + let bigger = shapes_for_message(¶graphs(client_core::text_cap::MESSAGE_LINES * 40)); + assert!( + capped > 0, + "the screen shaped nothing, so this compares zeroes" + ); + assert_eq!( + capped, bigger, + "a message ten times longer cost {bigger} text layouts against {capped} -- the cap \ + is not bounding what gets laid out", + ); + } + /// What one arriving result costs, in `Widget::draw` calls, in a run of /// `count` calls -- with the group open, so every card is really on /// screen and a rebuild of the wrong scope would show. diff --git a/iris/transcript-ui/src/row.rs b/iris/transcript-ui/src/row.rs index f1b0b82..4b38403 100644 --- a/iris/transcript-ui/src/row.rs +++ b/iris/transcript-ui/src/row.rs @@ -25,8 +25,10 @@ use crate::markdown::{BlockFrame, Link, frame_of, render_block}; use crate::selection::{SelKey, Selection}; +use crate::tap::{hold_edge, on_tap}; use crate::tool::ToolRow; use client_core::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks}; +use client_core::text_cap::{MESSAGE_BYTES, MESSAGE_LINES, cut, show_all_label}; use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow}; use iris::prelude::*; use std::{cell::RefCell, rc::Rc}; @@ -142,6 +144,17 @@ pub struct RowBlocks { /// The sender label the row was built with. A delta that changes it is /// not a delta into the same message, so it falls back to a rebuild. sender: Option, + /// Whether this row draws less than the whole message + /// ([`cap_message`]). A delta cannot be appended to a capped row -- + /// the new text would go on *below* the "Show all" that says it is + /// hidden -- so [`RowBlocks::apply_delta`] refuses one and the caller + /// rebuilds instead. + /// + /// Never `true` for the row a reply is actually streaming into: the + /// live tail is built uncapped ([`build_row`]'s `cap`), which is what + /// keeps the refusal from costing anything in practice. This field is + /// the belt to that braces. + capped: bool, } /// Split for display: never empty, so a row with nothing in it yet is @@ -159,6 +172,75 @@ fn display_blocks(markdown_src: &str) -> Vec { } } +/// `blocks` cut to what a row draws, with the line count of the **whole** +/// message; `None` when all of it fits. +/// +/// A message is capped for the same reason a tool's output is: one row can +/// be a hundred kilobytes of text, all of it shaped and rasterised whether +/// or not it is on screen, and a reader scrolling past a wall of it wanted +/// the next message anyway. Iris asked for it on 2026-09-08 -- "also do it +/// for messages (both user and agent) please, they're desperately needed +/// for long messages". +/// +/// The cut prefers a **block boundary**, because a message is markdown and +/// a whole paragraph is a smaller version of a message in a way that half +/// a paragraph is not. Where one block is over the bound by itself -- the +/// reply that is one enormous fence -- that block is truncated instead of +/// being dropped or drawn whole: dropping it would leave a row saying +/// nothing, and a truncated fence still renders as a fence, since the +/// renderer already knows the block's kind and pulldown-cmark closes an +/// unterminated one at the end of its input. +fn cap_message(blocks: Vec, cap: bool) -> (Vec, Option) { + let total = || blocks.iter().map(|b| b.source.lines().count()).sum(); + if !cap { + return (blocks, None); + } + let mut kept = Vec::with_capacity(blocks.len()); + let (mut lines_left, mut bytes_left) = (MESSAGE_LINES, MESSAGE_BYTES); + for block in &blocks { + if lines_left == 0 || bytes_left == 0 { + return (kept, Some(total())); + } + // `cut` is the test as well as the cutter: asking it whether this + // block fits in what is left is the same question, answered once, + // so the walk cannot disagree with the bound it is walking to. + match cut(&block.source, lines_left, bytes_left) { + // Over the bound by itself, with nothing kept yet: truncate, + // since dropping it would leave the row saying nothing. + Some((head, _)) if kept.is_empty() => { + kept.push(Block { + kind: block.kind, + source: head.to_string(), + }); + return (kept, Some(total())); + } + // Over the bound with something already kept: stop on the + // boundary rather than half-drawing this one. A block + // truncated to its opening line is a *worse* answer than no + // block -- a fence cut to its own ``` renders as an empty + // panel, which reads as a rendering fault rather than as a + // cap (seen 2026-09-08 with the bound wound down to three + // lines to look at it). + Some(_) => return (kept, Some(total())), + None => { + lines_left -= block.source.lines().count().min(lines_left); + bytes_left -= block.source.len().min(bytes_left); + kept.push(block.clone()); + } + } + } + (kept, None) +} + +/// A message's own text, kept so that asking for the whole of a capped row +/// can rebuild it. `Rc` rather than a copy per closure: the source of a +/// long message is the largest string in the row, and the tap handler +/// would otherwise hold a second one for the lifetime of the row. +struct RowSource { + sender: Option, + markdown: String, +} + /// The room a fence or a table's text gets inside its panel, and the /// gap between a quote's bar and its words. `CodeFence.kt` charges the /// renderer's `codeBlock` padding inside the tinted box and 8dp above and @@ -313,6 +395,11 @@ where /// `client_core::markdown_blocks` for the split. Selection still runs /// across the whole transcript; the unit it steps in is a block now rather /// than a row (`selection::SelKey`). +/// +/// `cap` draws at most [`cap_message`]'s worth of it with a "Show all" +/// under the rest. The row's content sits inside a `WidgetPtr` so that +/// answering that offer replaces it in place, which is the same shape a +/// tool card uses to open (`tool.rs`'s `build_card_ptr`). fn build_text_row( rsc: &mut Rsc, list: WeakWidget, @@ -320,11 +407,41 @@ fn build_text_row( key: RowKey, sender: Option<&str>, markdown_src: &str, + cap: bool, ) -> (StrongWidget, RowBlocks) where Rsc::State: FocusHost + OpenUrl, { - let blocks = display_blocks(markdown_src); + let source = Rc::new(RowSource { + sender: sender.map(str::to_string), + markdown: markdown_src.to_string(), + }); + let strong = WidgetPtr::new().add_strong(rsc); + let ptr = strong.weak(); + let (content, blocks) = row_content(rsc, list, selection, key, source, ptr, cap); + ptr(rsc).set(content); + (strong.any(), blocks) +} + +/// One row's header, blocks, and -- when [`cap_message`] left something +/// out -- the "Show all" that replaces the lot with the whole message. +/// +/// Separate from [`build_text_row`] because the tap calls it a second +/// time, with `cap` false, and writes the result back into the same +/// `WidgetPtr`. +fn row_content( + rsc: &mut Rsc, + list: WeakWidget, + selection: Rc>, + key: RowKey, + source: Rc, + ptr: WeakWidget, + cap: bool, +) -> (StrongWidget, RowBlocks) +where + Rsc::State: FocusHost + OpenUrl, +{ + let (blocks, hidden) = cap_message(display_blocks(&source.markdown), cap); let mut column = Span::empty(Dir::DOWN).gap(dp(BLOCK_GAP_DP)); let mut fields = Vec::with_capacity(blocks.len()); let mut links = Vec::with_capacity(blocks.len()); @@ -335,6 +452,17 @@ where links.push(block_links); column.push(framed); } + if let Some(lines) = hidden { + column.push(show_all( + rsc, + list, + selection.clone(), + key, + source.clone(), + ptr, + lines, + )); + } let column = column.add(rsc); // `.add` (weak), not `.add_strong` -- `header` is about to be embedded @@ -345,8 +473,8 @@ where // twice and panicked with "was already added" // (`core/src/widget/like.rs:12`) -- found running this crate's own // `run-headless.sh` example, the first real render of a row. - let header: WeakWidget = match sender { - Some(name) => wtext(name.to_string()) + let header: WeakWidget = match &source.sender { + Some(name) => wtext(name.clone()) .size(13.0) .color(UiColor::new(150, 150, 160, 255)) .add(rsc), @@ -366,11 +494,57 @@ where fields, links, column, - sender: sender.map(str::to_string), + sender: source.sender.clone(), + capped: hidden.is_some(), }, ) } +/// The "Show all N lines" under a capped message, and the tap that +/// replaces the row with the whole of it. +/// +/// The `RowBlocks` the rebuild produces is **discarded**, because a capped +/// row is never the row a reply is streaming into (`build_row`'s `cap`) -- +/// so nothing is holding one for it, and there is nothing to keep in step. +#[allow(clippy::too_many_arguments)] +fn show_all( + rsc: &mut Rsc, + list: WeakWidget, + selection: Rc>, + key: RowKey, + source: Rc, + ptr: WeakWidget, + lines: usize, +) -> StrongWidget +where + Rsc::State: FocusHost + OpenUrl, +{ + let label = show_all_label(lines); + let more_strong = WidgetPtr::new().add_strong(rsc); + let more = more_strong.weak(); + let words = wtext(label.clone()) + .size(13.0) + .color(UiColor::new(150, 150, 160, 255)) + .text_align(Align::LEFT) + .label(label) + .add_strong(rsc); + more(rsc).set(words); + on_tap(rsc, more, list, selection.clone(), move |rsc| { + hold_edge(rsc, list, key); + let (content, _blocks) = row_content( + rsc, + list, + selection.clone(), + key, + source.clone(), + ptr, + false, + ); + let _old = ptr(rsc).replace(content); + }); + more_strong.any() +} + impl RowBlocks { /// Bring this row up to date with `markdown_src` **without** re-laying /// out the blocks that did not change, and say whether that was @@ -397,6 +571,14 @@ impl RowBlocks { if self.sender.as_deref() != sender { return false; } + // A capped row draws less than the message it was built from, so + // appending to it would put the new text *below* the "Show all" + // saying the rest is hidden. The caller rebuilds instead, and + // rebuilds uncapped (`TranscriptScreen::apply`), so this refusal + // costs one rebuild per message rather than one per delta. + if self.capped { + return false; + } let new_blocks = display_blocks(markdown_src); let common = common_prefix(&self.blocks, &new_blocks); // Everything already drawn must either be kept whole (`common == @@ -463,12 +645,13 @@ fn build_single( selection: Rc>, key: RowKey, item: &TranscriptItem, + cap: bool, ) -> (StrongWidget, RowBlocks) where Rsc::State: FocusHost + OpenUrl, { let (sender, markdown_src) = item_content(item); - build_text_row(rsc, list, selection, key, sender, &markdown_src) + build_text_row(rsc, list, selection, key, sender, &markdown_src, cap) } /// What a row keeps so the next event can change part of it instead of @@ -487,12 +670,18 @@ pub enum TailRow { Tools(ToolRow), } +/// `cap` draws a long message as [`cap_message`]'s worth of it behind a +/// "Show all"; the caller passes `false` for the **live tail**, the row a +/// reply is streaming into, because a row that grows while it is capped +/// would appear to stop growing (`RowBlocks::capped`). Every other row is +/// capped. pub fn build_row( rsc: &mut Rsc, list: WeakWidget, selection: Rc>, row: &FoldedRow, working: bool, + cap: bool, ) -> (RowKey, StrongWidget, Option) where Rsc::State: FocusHost + OpenUrl, @@ -518,6 +707,75 @@ where unreachable!("every Tools row took the branch above"); }; let key = row_key(&item.key()); - let (widget, blocks) = build_single(rsc, list, selection, key, item); + let (widget, blocks) = build_single(rsc, list, selection, key, item, cap); (key, widget, Some(TailRow::Blocks(blocks))) } + +#[cfg(test)] +mod tests { + use super::*; + + fn blocks(src: &str) -> Vec { + display_blocks(src) + } + + #[test] + fn a_message_inside_the_bounds_is_not_capped() { + let (kept, hidden) = cap_message(blocks("hello\n\nthere"), true); + assert_eq!(kept.len(), 2); + assert_eq!(hidden, None); + } + + /// Off by default at the call site that matters: the row a reply is + /// streaming into is built with `cap` false, and must come back whole + /// however long it has got. + #[test] + fn cap_false_keeps_everything() { + let src = "a\n\n".repeat(MESSAGE_LINES * 2); + let (kept, hidden) = cap_message(blocks(&src), false); + assert_eq!(kept.len(), MESSAGE_LINES * 2); + assert_eq!(hidden, None); + } + + /// The ordinary case: the cut lands between two blocks, so every + /// block drawn is a whole one. + #[test] + fn a_long_message_is_cut_on_a_block_boundary() { + let src = "a paragraph\n\n".repeat(MESSAGE_LINES * 2); + let all = blocks(&src); + let (kept, hidden) = cap_message(all.clone(), true); + assert!(kept.len() < all.len(), "nothing was left out"); + assert!( + kept.iter().zip(&all).all(|(k, a)| k == a), + "a block was truncated where a boundary was available", + ); + assert_eq!( + hidden, + Some(all.iter().map(|b| b.source.lines().count()).sum()), + "the offer says the whole message's line count, not the shown part's", + ); + } + + /// The half a block boundary cannot answer: one enormous fence, which + /// is what a reply pasting a file arrives as. Truncated rather than + /// dropped -- a row that drew nothing would say less than the line it + /// replaced -- and still a fence, since the kind is decided before the + /// truncation and an unterminated one closes at the end of its input. + #[test] + fn one_block_over_the_bound_by_itself_is_truncated() { + let src = format!("```\n{}```", "x\n".repeat(MESSAGE_LINES * 2)); + let all = blocks(&src); + assert_eq!(all.len(), 1, "the fixture must be a single block"); + let (kept, hidden) = cap_message(all.clone(), true); + assert_eq!(kept.len(), 1); + assert_eq!( + kept[0].kind, all[0].kind, + "truncation changed the block's kind" + ); + assert!( + kept[0].source.len() < all[0].source.len(), + "the one over-long block was drawn whole", + ); + assert!(hidden.is_some()); + } +} diff --git a/iris/transcript-ui/src/tap.rs b/iris/transcript-ui/src/tap.rs new file mode 100644 index 0000000..245a354 --- /dev/null +++ b/iris/transcript-ui/src/tap.rs @@ -0,0 +1,80 @@ +//! A tap on something in the transcript, and holding the reader's edge +//! while what they tapped changes height. +//! +//! Both halves are shared by everything in the transcript that opens: a +//! tool card and its group ([`crate::tool`]), and a message's "Show all" +//! ([`crate::row`]). They are here rather than in either of those because +//! there is one right answer to "was that a tap?" on this screen, and two +//! copies of it would eventually disagree -- which on a scrolling screen +//! means one of them firing at the end of a pan. + +use crate::selection::Selection; +use iris::prelude::*; +use std::{cell::RefCell, rc::Rc}; + +/// Register `f` as `ptr`'s **tap**, panning the list instead when the +/// finger moves. +/// +/// `Selection::drag` with no row is the same call `row.rs` makes with one: +/// it drives the shared `DragArbiter`, so a drag starting on a card +/// scrolls (and flings) the transcript exactly as one starting on a +/// paragraph does, and only a press that committed to nothing comes back +/// as `Tapped`. A bare `CursorSense::click()` would be a second, +/// disagreeing detector -- it fires at the end of a pan too, so every +/// scroll that began on a card would also toggle it. +pub(crate) fn on_tap( + rsc: &mut Rsc, + ptr: WeakWidget, + list: WeakWidget, + selection: Rc>, + f: impl Fn(&mut Rsc) + 'static, +) where + Rsc::State: FocusHost + OpenUrl, +{ + // The whole `drag_senses()` set -- what any widget driving a + // `DragGesture` registers, `Cancel` included. See `row.rs`'s twin + // registration for what leaving `Cancel` out did. + ptr.on(CursorSense::drag_senses(), move |ctx, rsc| { + let outcome = selection.borrow_mut().drag( + rsc, + list, + None, + ctx.data.cursor.pos, + ctx.data.sense, + ctx.data.cursor.time, + ctx.data.pointer, + ); + if outcome == GestureOutcome::Tapped { + f(rsc); + } + }) + .add(rsc); +} + +/// Hold the edge the reader is looking at while row `key` changes height. +/// +/// `LazySpan::note_tap` wants a viewport-relative position and a row only +/// knows its own box, so `LazySpan::extent` (last frame's on-screen box +/// for this key) turns the two into the position `lazy_span.rs`'s +/// hold-the-edge pass resolves against -- the two-step contract that +/// module's doc describes for `AGENTS.md`'s `holdTopEdge`. +/// +/// Call it **before** the change, from every handler that makes a row +/// taller or shorter: opening a card, opening a group, asking for the +/// whole of a capped block or message. A handler that skips it is one +/// where the transcript jumps under the reader's finger. +pub(crate) fn hold_edge(rsc: &mut impl UiRsc, list: WeakWidget, key: RowKey) { + // Only when the list actually has an extent for this row. `None` + // means the row has not been drawn yet -- which happens the moment + // something opens a group before the first frame + // (`TranscriptScreen::expand_tail_tools`, the headless screenshot) -- + // and standing in `0.0` for it tells the layout pass to hold an edge + // at the top of the viewport that nothing was ever at. The whole list + // then places itself against that invented anchor: rows drawn at each + // other's cached heights, tool cards as empty bars with their text a + // group's height below them (`docs/bench/p1b-2026-09-06/`'s first + // attempt). Nothing to hold is not the same as an edge at zero. + if let Some((top, _bottom)) = list(rsc).extent(key) { + list(rsc).note_tap(top); + } +} diff --git a/iris/transcript-ui/src/tool.rs b/iris/transcript-ui/src/tool.rs index 83bbcfc..6e73110 100644 --- a/iris/transcript-ui/src/tool.rs +++ b/iris/transcript-ui/src/tool.rs @@ -34,6 +34,8 @@ use crate::markdown::{TEXT_COLOR, VERBATIM_BACKGROUND, highlight_into}; use crate::selection::Selection; +use crate::tap::{hold_edge, on_tap}; +use client_core::text_cap::{VERBATIM_BYTES, VERBATIM_LINES, cut, show_all_label}; use client_core::tool_summary::{ToolInput, parse_tool_input}; use client_core::transcript_fold::{ToolState, TranscriptItem}; use iris::prelude::*; @@ -92,23 +94,6 @@ const RAW_PAD_DP: f32 = 8.0; /// How far a group holds its cards off its own edge (`ToolRows.kt`). const GROUP_INSET_DP: f32 = 4.0; -/// How much of a tool's output an open card draws before it offers the -/// rest behind a tap. -/// -/// **A divergence from Compose, on purpose.** `ToolCard` draws the whole -/// output however long, and gets away with it because a Compose `Text` -/// inside a `LazyColumn` is laid out lazily; here the output is one text -/// widget, and shaping a hundred kilobytes of it through parley is the -/// cost `docs/EXPLORER.md`'s `EDIT_LIMIT` was measured against. Lines -/// *and* bytes because the two run out at different times -- a diff is -/// many short lines, a minified file is one enormous one. -const OUTPUT_LINES: usize = 80; -const OUTPUT_BYTES: usize = 4096; -/// A cap of nothing would draw an empty panel and a "Show all" for every -/// output there is, which reads as a rendering fault rather than as a cap. -/// Checked at compile time, since both are constants. -const _: () = assert!(OUTPUT_LINES > 0 && OUTPUT_BYTES > 0); - /// The size of the disclosure mark, as a font size in dp. /// /// The mark is a glyph in the icon font iris ships (`iris::icon`, built by @@ -133,7 +118,19 @@ const MARK_DP: f32 = 9.0; struct ToolRowState { group_expanded: bool, open: HashMap, - whole_output: HashMap, + whole: HashMap<(String, Part), bool>, +} + +/// Which half of an open card a cap and its "Show all" belong to. +/// +/// The two are capped and revealed **independently**: a reader who wants +/// the whole of a 900-line `new_string` rarely also wants the whole of the +/// build log underneath it, and one control revealing both would make the +/// card jump by the sum of two things when it was asked about one. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +enum Part { + Input, + Output, } /// Everything a handler needs to redraw part of this row, in one `Rc` so @@ -172,69 +169,6 @@ pub struct ToolRow { shared: Rc, } -/// Register `f` as this widget's **tap**, panning the list instead when -/// the finger moves. -/// -/// The one gesture entry point in this file. `Selection::drag` with no row -/// is the same call `row.rs` makes with one: it drives the shared -/// `DragArbiter`, so a drag starting on a card scrolls (and flings) the -/// transcript exactly as one starting on a paragraph does, and only a -/// press that committed to nothing comes back as `Tapped`. A bare -/// `CursorSense::click()` here would be a second, disagreeing detector -- -/// it fires at the end of a pan too, so every scroll that began on a card -/// would also toggle it. -fn on_tap( - rsc: &mut Rsc, - ptr: WeakWidget, - shared: &Rc, - f: impl Fn(&mut Rsc) + 'static, -) where - Rsc::State: FocusHost + OpenUrl, -{ - let (list, selection) = (shared.list, shared.selection.clone()); - // The whole `drag_senses()` set -- what any widget driving a - // `DragGesture` registers, `Cancel` included. See `row.rs`'s twin - // registration for what leaving `Cancel` out did. - ptr.on(CursorSense::drag_senses(), move |ctx, rsc| { - let outcome = selection.borrow_mut().drag( - rsc, - list, - None, - ctx.data.cursor.pos, - ctx.data.sense, - ctx.data.cursor.time, - ctx.data.pointer, - ); - if outcome == GestureOutcome::Tapped { - f(rsc); - } - }) - .add(rsc); -} - -/// Hold the edge the reader is looking at while this row changes height. -/// -/// `LazySpan::note_tap` wants a viewport-relative position and this row only -/// knows its own box, so `LazySpan::extent` (last frame's on-screen box for -/// this key) turns the two into the position `lazy_span.rs`'s hold-the-edge -/// pass resolves against -- the two-step contract that module's doc -/// describes for `AGENTS.md`'s `holdTopEdge`. -fn note_tap(rsc: &mut impl UiRsc, shared: &Shared) { - // Only when the list actually has an extent for this row. `None` - // means the row has not been drawn yet -- which happens the moment - // something opens a group before the first frame - // (`TranscriptScreen::expand_tail_tools`, the headless screenshot) -- - // and standing in `0.0` for it tells the layout pass to hold an edge - // at the top of the viewport that nothing was ever at. The whole list - // then places itself against that invented anchor: rows drawn at each - // other's cached heights, tool cards as empty bars with their text a - // group's height below them (`docs/bench/p1b-2026-09-06/`'s first - // attempt). Nothing to hold is not the same as an edge at zero. - if let Some((top, _bottom)) = (shared.list)(rsc).extent(shared.key) { - (shared.list)(rsc).note_tap(top); - } -} - fn text(content: impl Into, size: f32, color: UiColor) -> TextBuilder { wtext(content) .size(size) @@ -324,30 +258,73 @@ fn group_label(count: usize) -> String { format!("Called {count} tools") } -/// `output` cut to what an open card draws, with the line count it was cut -/// from; `None` when the whole of it fits. +/// One verbatim block as it will be drawn: the text, the line count of +/// the *whole* of it, and whether anything was left out. /// -/// Cut at the **head**, keeping the beginning: a tool's output is read -/// from the top, and the line saying what went wrong is nearly always the -/// first. (A path is identified by its other end; this is not a path.) -fn capped(output: &str) -> Option<(&str, usize)> { - let by_lines = output - .char_indices() - .filter(|(_, c)| *c == '\n') - .nth(OUTPUT_LINES - 1) - .map(|(i, _)| i); - let by_bytes = (output.len() > OUTPUT_BYTES).then(|| { - let mut end = OUTPUT_BYTES; - while !output.is_char_boundary(end) { - end -= 1; - } - end - }); - let cut = match (by_lines, by_bytes) { - (Some(a), Some(b)) => a.min(b), - (a, b) => a.or(b)?, - }; - Some((&output[..cut], output.lines().count())) +/// `whole` is the reader having already asked for all of it, folded in +/// here so that every caller reads the same three values whichever answer +/// it was. +fn capped(body: &str, whole: bool) -> (&str, usize, bool) { + match cut(body, VERBATIM_LINES, VERBATIM_BYTES) { + Some((head, lines)) if !whole => (head, lines, true), + Some((_, lines)) => (body, lines, false), + None => (body, body.lines().count(), false), + } +} + +/// Whether the reader has asked for the whole of `part` on this call. +fn wants_whole(shared: &Shared, id: &str, part: Part) -> bool { + shared + .state + .borrow() + .whole + .get(&(id.to_string(), part)) + .copied() + .unwrap_or(false) +} + +/// The "Show all N lines" a capped block is followed by. +/// +/// A control rather than a note, and it says the count rather than "more", +/// because the reader is deciding whether to ask for it: "Show all 4,000 +/// lines" and "Show all 12 lines" are different decisions and the word +/// "more" tells them apart not at all. +fn show_all( + rsc: &mut Rsc, + shared: &Rc, + index: usize, + id: &str, + part: Part, + lines: usize, +) -> StrongWidget +where + Rsc::State: FocusHost + OpenUrl, +{ + let label = show_all_label(lines); + let more_strong = WidgetPtr::new().add_strong(rsc); + let more = more_strong.weak(); + let words = text(label.clone(), LABEL_SIZE, MUTED_COLOR) + .label(label) + .add_strong(rsc); + more(rsc).set(words); + let shared_for_tap = shared.clone(); + let key = (id.to_string(), part); + on_tap( + rsc, + more, + shared.list, + shared.selection.clone(), + move |rsc| { + hold_edge(rsc, shared_for_tap.list, shared_for_tap.key); + shared_for_tap + .state + .borrow_mut() + .whole + .insert(key.clone(), true); + redraw_card(rsc, &shared_for_tap, index); + }, + ); + more_strong.any() } /// The tool's output, or the reason there is none to show. @@ -376,45 +353,16 @@ where return text(words, LABEL_SIZE, colour).add_strong(rsc).any(); } - let whole = shared - .state - .borrow() - .whole_output - .get(id) - .copied() - .unwrap_or(false); - let shown = if whole { None } else { capped(output) }; + let (shown, lines, was_cut) = capped(output, wants_whole(shared, id, Part::Output)); let mut column = Span::empty(Dir::DOWN).gap(dp(2)); column.push(text("Output", LABEL_SIZE, NAME_COLOR).add_strong(rsc).any()); // What the tool printed, in the face it was written for: this is // column-aligned far more often than it is prose, and a proportional // font destroys the alignment that carried the meaning. - let body = text( - shown.map_or(output, |(head, _)| head).to_string(), - BODY_SIZE, - NAME_COLOR, - ); + let body = text(shown.to_string(), BODY_SIZE, NAME_COLOR); column.push(raw_block(rsc, body)); - if let Some((_, lines)) = shown { - let label = format!("Show all {lines} lines"); - let more_strong = WidgetPtr::new().add_strong(rsc); - let more = more_strong.weak(); - let words = text(label.clone(), LABEL_SIZE, MUTED_COLOR) - .label(label) - .add_strong(rsc); - more(rsc).set(words); - let shared_for_tap = shared.clone(); - let id = id.to_string(); - on_tap(rsc, more, shared, move |rsc| { - note_tap(rsc, &shared_for_tap); - shared_for_tap - .state - .borrow_mut() - .whole_output - .insert(id.clone(), true); - redraw_card(rsc, &shared_for_tap, index); - }); - column.push(more_strong.any()); + if was_cut { + column.push(show_all(rsc, shared, index, id, Part::Output, lines)); } column.width(rest(1)).add_strong(rsc).any() } @@ -502,11 +450,27 @@ where .any(), ); } + // The input's blocks are capped as **one** thing, with one "Show + // all" under the last of them: the subject and the leftover fields + // are two halves of the same answer to "what was this call given", + // and two controls would make the reader ask twice. An `Edit` is + // why the input needs a cap at all -- its `old_string` and + // `new_string` arrive here whole, and are routinely the largest + // text on the screen. + let whole = wants_whole(shared, id, Part::Input); + let mut input_lines = 0usize; + let mut input_cut = false; if let Some(subject) = &parsed.subject { + let (shown, lines, was_cut) = capped(subject, whole); + input_lines += lines; + input_cut |= was_cut; let spans = match parsed.language { + // Highlighted over what is *drawn*, not over the whole + // subject: a span past the end of the text it styles is a + // range into nothing. Some(language) => { let mut spans = Vec::new(); - highlight_into(&mut spans, subject, 0..subject.len(), language); + highlight_into(&mut spans, shown, 0..shown.len(), language); spans } // An unknown language is drawn plain rather than coloured @@ -514,15 +478,24 @@ where // same reason: a wrong highlight is read as a fact. None => Vec::new(), }; - let body = text(subject.clone(), BODY_SIZE, NAME_COLOR).spans(spans); + let body = text(shown.to_string(), BODY_SIZE, NAME_COLOR).spans(spans); column.push(raw_block(rsc, body)); } if !parsed.rest.is_empty() { // Never dropped: a field left out would be claiming the tool - // had no other input when it might (`ToolInput.kt`). - let body = text(parsed.rest.join("\n"), BODY_SIZE, MUTED_COLOR); + // had no other input when it might (`ToolInput.kt`). Capped is + // not dropped -- the field is still there, with its size said + // out loud. + let joined = parsed.rest.join("\n"); + let (shown, lines, was_cut) = capped(&joined, whole); + input_lines += lines; + input_cut |= was_cut; + let body = text(shown.to_string(), BODY_SIZE, MUTED_COLOR); column.push(raw_block(rsc, body)); } + if input_cut { + column.push(show_all(rsc, shared, index, id, Part::Input, input_lines)); + } column.push(output_block(rsc, shared, index, id, output, call_state)); } @@ -578,22 +551,28 @@ where let content = build_card(rsc, shared, index); ptr(rsc).set(content); let for_tap = shared.clone(); - on_tap(rsc, ptr, shared, move |rsc| { - note_tap(rsc, &for_tap); - let Some(id) = for_tap.call_id(index) else { - debug_assert!(false, "tapped card {index} is no longer in the row"); - return; - }; - let was = for_tap - .state - .borrow() - .open - .get(&id) - .copied() - .unwrap_or(false); - for_tap.state.borrow_mut().open.insert(id, !was); - redraw_card(rsc, &for_tap, index); - }); + on_tap( + rsc, + ptr, + shared.list, + shared.selection.clone(), + move |rsc| { + hold_edge(rsc, for_tap.list, for_tap.key); + let Some(id) = for_tap.call_id(index) else { + debug_assert!(false, "tapped card {index} is no longer in the row"); + return; + }; + let was = for_tap + .state + .borrow() + .open + .get(&id) + .copied() + .unwrap_or(false); + for_tap.state.borrow_mut().open.insert(id, !was); + redraw_card(rsc, &for_tap, index); + }, + ); (strong.any(), ptr) } @@ -620,7 +599,13 @@ where .add_strong(rsc); ptr(rsc).set(mark); let for_tap = shared.clone(); - on_tap(rsc, ptr, shared, move |rsc| toggle_group(rsc, &for_tap)); + on_tap( + rsc, + ptr, + shared.list, + shared.selection.clone(), + move |rsc| toggle_group(rsc, &for_tap), + ); strong.any() } @@ -704,7 +689,7 @@ fn toggle_group(rsc: &mut Rsc, shared: &Rc) where Rsc::State: FocusHost + OpenUrl, { - note_tap(rsc, shared); + hold_edge(rsc, shared.list, shared.key); let was = shared.state.borrow().group_expanded; shared.state.borrow_mut().group_expanded = !was; let content = build_content(rsc, shared); @@ -853,7 +838,7 @@ impl ToolRow { self.shared.working.set(working); *self.shared.calls.borrow_mut() = calls.to_vec(); // The path out for the reader's own state: a call that is no - // longer in this row keeps no entry in `open`/`whole_output`. + // longer in this row keeps no entry in `open` or `whole`. let ids: std::collections::HashSet = calls .iter() .filter_map(|c| match c { @@ -864,7 +849,7 @@ impl ToolRow { { let mut state = self.shared.state.borrow_mut(); state.open.retain(|id, _| ids.contains(id)); - state.whole_output.retain(|id, _| ids.contains(id)); + state.whole.retain(|(id, _), _| ids.contains(id)); } // A collapsed group draws no cards, so a changed call is worth