Cap what the transcript draws, and let a LazySpan clip itself

Four things Iris asked for on 2026-09-08.

**A LazySpan no longer cares about masks.** It asserted that something
around it had called `.masked()` and refused to draw otherwise, which is
why a plain full-screen list -- the benchmark, any simple app -- panicked.
It cared only because it draws a row straddling an edge in full and relied
on somebody else to cut off the overhang; it clips itself to the box it
was offered now. Strictly stronger than the assert, which a mask *larger*
than the list's box satisfied while letting the overhang through anyway --
the fault it was written for. The transcript's `.masked()` wrapper goes
with it, and `Painter::is_masked` with that.

**Everything on the transcript screen is capped.** 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 rather than 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, in iris)
    a message             200 lines or 16 KiB  -> "Show all N lines"

The input is what the edit-card report needed: an Edit's old_string and
new_string arrive whole and are routinely the biggest text on screen.
Messages are capped in both apps, user and agent alike.

Three rules that took a screenshot to get right. A message is cut on a
block boundary, never 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 --
except 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. And the input's two blocks share one "Show all", while input and
output have their own.

**Compose stops wrapping raw text**, per Iris's call: a tool's leftover
input fields and its output pan sideways like the command already did.

`on_tap` and hold-the-edge move to `transcript-ui/src/tap.rs`, since a
message's "Show all" needs exactly what a tool card's tap already had.
This commit is contained in:
iris committed 2026-09-08 22:59:18 -04:00
1 parent 1318e149f5
commit afbc2ad132
18 files changed
+1237 -255

No files matched your search

@@ -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<String>()) }
// 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<Capped>()) }
var shownWholeRows by remember { mutableStateOf(setOf<Any>()) }
// 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<String?>(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)
@@ -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"
@@ -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
},
)
}
@@ -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<QuestionAnswer>, 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<QuestionAnswer>, 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) }
}
}
}
}
@@ -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<Any>): Pair<TranscriptRow, Int?> {
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<TranscriptRow>,
replies: ParsedReplies,
openNotes: Set<Long>,
shownWhole: Set<Any> = emptySet(),
): List<TranscriptUnit> {
val started = System.nanoTime()
val units = ArrayList<TranscriptUnit>(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,8 +329,15 @@ 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<TranscriptRow>, replies: ParsedReplies): List<TranscriptItem> =
rows.mapIndexedNotNull { index, row ->
fun unwarmedReplies(
rows: List<TranscriptRow>,
replies: ParsedReplies,
shownWhole: Set<Any> = emptySet(),
): List<TranscriptItem> = 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) }
}
@@ -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"
@@ -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"))
}
}
+1
View File
@@ -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;
+134
View File
@@ -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"
);
}
}
+58 -15
View File
@@ -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`
+11 -12
View File
@@ -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
+2 -19
View File
@@ -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();
-9
View File
@@ -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<W: ?Sized>(&mut self, id: &StrongWidget<W>) -> Size {
+24 -20
View File
@@ -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);
+10 -4
View File
@@ -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;
}
+82 -9
View File
@@ -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(&paragraphs(client_core::text_cap::MESSAGE_LINES * 4));
let bigger = shapes_for_message(&paragraphs(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.
+264 -6
View File
@@ -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<String>,
/// 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<Block> {
}
}
/// `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<Block>, cap: bool) -> (Vec<Block>, Option<usize>) {
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<String>,
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: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
@@ -320,11 +407,41 @@ fn build_text_row<Rsc: HasEvents>(
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: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
source: Rc<RowSource>,
ptr: WeakWidget<WidgetPtr>,
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: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
source: Rc<RowSource>,
ptr: WeakWidget<WidgetPtr>,
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<Rsc: HasEvents>(
selection: Rc<RefCell<Selection>>,
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: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
row: &FoldedRow,
working: bool,
cap: bool,
) -> (RowKey, StrongWidget, Option<TailRow>)
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<Block> {
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());
}
}
+80
View File
@@ -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: HasEvents>(
rsc: &mut Rsc,
ptr: WeakWidget<WidgetPtr>,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
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<LazySpan>, 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);
}
}
+132 -147
View File
@@ -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<String, bool>,
whole_output: HashMap<String, bool>,
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<Shared>,
}
/// 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: HasEvents>(
rsc: &mut Rsc,
ptr: WeakWidget<WidgetPtr>,
shared: &Rc<Shared>,
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<Rsc>(content: impl Into<String>, size: f32, color: UiColor) -> TextBuilder<Rsc> {
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;
/// `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),
}
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()))
}
/// 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: HasEvents>(
rsc: &mut Rsc,
shared: &Rc<Shared>,
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,8 +551,13 @@ 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);
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;
@@ -593,7 +571,8 @@ where
.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: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>)
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<String> = 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