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
+1254 -272

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 // 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. // note opened and scrolled past is still open on the way back.
var openMemories by remember { mutableStateOf(setOf<String>()) } 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 // 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. // thumbnail: a row regrouped underneath the reader takes its whole subtree with it.
var fullImage by remember { mutableStateOf<String?>(null) } 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. // Bumped when a cold reply's parses become ready, so the flatten runs again and can split it.
var warmedTick by remember { mutableIntStateOf(0) } var warmedTick by remember { mutableIntStateOf(0) }
val units = 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 // 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 // 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. // the tick re-flattens, so settling never costs a whole-message parse in a frame.
LaunchedEffect(rows) { LaunchedEffect(rows, shownWholeRows) {
val cold = unwarmedReplies(rows, replies) val cold = unwarmedReplies(rows, replies, shownWholeRows)
if (cold.isNotEmpty()) { if (cold.isNotEmpty()) {
warm(replies, cold) warm(replies, cold)
warmedTick++ warmedTick++
@@ -550,6 +558,20 @@ fun SessionScreen(
toggle() 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. * Whether the row holding transcript position [seq] is loaded, with older history behind it.
* *
@@ -1486,6 +1508,15 @@ fun SessionScreen(
) { unit -> ) { unit ->
when (unit) { when (unit) {
is TranscriptUnit.Block -> MarkdownPiece(unit.text, unit.piece, replies) 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 -> is TranscriptUnit.PeerHead ->
PeerHeadRow( PeerHeadRow(
unit.item, unit.item,
@@ -1576,6 +1607,12 @@ fun SessionScreen(
::openImage, ::openImage,
) )
}, },
isWhole = { it in shownWholeCalls },
onShowAll = { capped ->
toggleAnchored(row) {
shownWholeCalls = shownWholeCalls + capped
}
},
) )
is TranscriptRow.Single -> is TranscriptRow.Single ->
when (val item = row.item) { when (val item = row.item) {
@@ -1621,6 +1658,16 @@ fun SessionScreen(
::openImage, ::openImage,
) )
}, },
isWhole = { part ->
Capped(item.id, part) in shownWholeCalls
},
onShowAll = { part ->
toggleAnchored(row) {
shownWholeCalls =
shownWholeCalls +
Capped(item.id, part)
}
},
) )
is TranscriptItem.QuestionCard -> is TranscriptItem.QuestionCard ->
QuestionRow(item, ::answerAll) 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 package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -9,6 +10,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.json.JSONObject 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 * 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. * 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 @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) } val parsed = remember(tool, input) { parseToolInput(tool, input) }
if (parsed.subject == null && parsed.rest.isEmpty()) return 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) { 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 // Not wrapped: a wrapped command hides where its arguments end, and the long one is the
// one being read closely. // one being read closely.
Text( 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 -- // 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. // 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, style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
softWrap = false, softWrap = false,
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), 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( Text(
it, shown,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CornerBasedShape import androidx.compose.foundation.shape.CornerBasedShape
import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -163,6 +165,9 @@ fun ToolGroup(
onToolToggle: (String) -> Unit, onToolToggle: (String) -> Unit,
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit, onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
image: @Composable (String) -> 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" val heading = "Called ${group.calls.size} tools"
if (!expanded) { if (!expanded) {
@@ -202,6 +207,8 @@ fun ToolGroup(
onToggle = { onToolToggle(call.id) }, onToggle = { onToolToggle(call.id) },
onAnswer = onAnswer, onAnswer = onAnswer,
image = image, image = image,
isWhole = { part -> isWhole(Capped(call.id, part)) },
onShowAll = { part -> onShowAll(Capped(call.id, part)) },
shape = connectedShape(index, group.calls.size), 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. */ /** Enough to read the join as a join rather than as one tall card. */
private val GROUP_GAP = 2.dp 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. * One tool call.
* *
@@ -291,6 +313,11 @@ fun ToolCard(
onToggle: () -> Unit, onToggle: () -> Unit,
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit, onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
image: @Composable (String) -> 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]. */ /** Square where this card faces another in a group; see [connectedShape]. */
shape: Shape = CardDefaults.shape, shape: Shape = CardDefaults.shape,
) { ) {
@@ -353,11 +380,28 @@ fun ToolCard(
// something answerable; dumping the same JSON above them would be the decision // something answerable; dumping the same JSON above them would be the decision
// stated twice, once unreadably. // stated twice, once unreadably.
if (tool.tool != ASK_USER_QUESTION) { 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()) { if (tool.output.isNotEmpty()) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text("Output", style = MaterialTheme.typography.labelSmall) 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 // 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 // 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 // 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 // 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. // the text, so a card that is open through a scroll parses once.
val palette = remember { ansiPalette() } 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)) { 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( Text(
styled, styled,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace, 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" 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]. */ /** One memory note of a settled reply; see [MemoryNote]. */
data class Memory( data class Memory(
override val seq: Long, 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 * 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. * screen, which is what a reversed lazy list calls the start.
@@ -161,11 +211,14 @@ fun transcriptUnits(
rows: List<TranscriptRow>, rows: List<TranscriptRow>,
replies: ParsedReplies, replies: ParsedReplies,
openNotes: Set<Long>, openNotes: Set<Long>,
shownWhole: Set<Any> = emptySet(),
): List<TranscriptUnit> { ): List<TranscriptUnit> {
val started = System.nanoTime() val started = System.nanoTime()
val units = ArrayList<TranscriptUnit>(rows.size) 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 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 val item = (row as? TranscriptRow.Single)?.item
if (item is TranscriptItem.PeerNote) { if (item is TranscriptItem.PeerNote) {
val open = item.seq in openNotes val open = item.seq in openNotes
@@ -240,6 +293,16 @@ fun transcriptUnits(
} else { } else {
units += TranscriptUnit.Whole(row, rowGap) units += TranscriptUnit.Whole(row, rowGap)
} }
if (hidden != null) {
units +=
TranscriptUnit.ShowAll(
row.startSeq,
units.size - rowStart,
row.key,
hidden,
BLOCK_SPACING,
)
}
} }
units.reverse() units.reverse()
reportDuplicateKeys(units) 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 * what this returns off-thread and re-flattens, so the whole-to-blocks swap always composes against
* ready parses. * ready parses.
*/ */
fun unwarmedReplies(rows: List<TranscriptRow>, replies: ParsedReplies): List<TranscriptItem> = fun unwarmedReplies(
rows.mapIndexedNotNull { index, row -> rows: List<TranscriptRow>,
val item = (row as? TranscriptRow.Single)?.item as? TranscriptItem.AssistantMsg replies: ParsedReplies,
item?.takeIf { splitWanted(it, index, rows.lastIndex) && !replies.splitReady(it.text) } 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) }
}
/** /**
* Above this many characters, a user message is drawn in slices rather than as one bubble. * 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.PeerBlock -> "peer block"
is TranscriptUnit.UserChunk -> "user slice" is TranscriptUnit.UserChunk -> "user slice"
is TranscriptUnit.Memory -> "memory note" is TranscriptUnit.Memory -> "memory note"
is TranscriptUnit.ShowAll -> "show all"
is TranscriptUnit.Whole -> is TranscriptUnit.Whole ->
when (val row = row) { when (val row = row) {
is TranscriptRow.Tools -> "tool group" 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 markdown_blocks;
pub mod notifications; pub mod notifications;
pub mod sse; pub mod sse;
pub mod text_cap;
pub mod tool_summary; pub mod tool_summary;
pub mod transcript_cache; pub mod transcript_cache;
pub mod transcript_fold; 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 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. 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 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 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 `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 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 hides it. That file had also stopped running at all -- scenarios (a) and
`--phone`-sized text, and it had stopped running at all -- scenarios (a) (e) built a `LazySpan` with no mask around it, which the span asserted
and (e) built a `LazySpan` with no mask around it, which the span now against, so the whole benchmark panicked on its second line. Worked around
asserts against, so the whole benchmark panicked on its second line. here and fixed properly in the entry above, which deletes the assert.
Fixed in the same change.
**What this does not fix, and what I would do next.** An open card still **What this does not fix**, and what the entry below closes: an open card
shapes, rasterises and submits *every* glyph of its input and output, not still shapes, rasterises and submits *every* glyph of its input and
the screenful you can see -- iris does not cull within a widget, and a output, not the screenful you can see. iris does not cull within a widget,
tool card is the one place that bites, because an `Edit`'s `old_string` so the only bound available is a cap on what goes in.
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.
## 2026-09-08: one `ScrollController`, a `Scrollable` trait, and `Pin` ## 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 the shaped-mask work (`.masked_by`, 38bf630) is the likeliest, since the
old chain was `.masked()` *inside* the padding. Left ticked with the old chain was `.masked()` *inside* the padding. Left ticked with the
original symptom recorded rather than deleted, in case it comes back. 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, long.** iris does not cull within a widget -- a `Text` shapes,
rasterises and submits the whole string whether or not the box it sits 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 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 `Edit`'s `old_string` and `new_string` go onto the card whole. Found
*output* half is already capped at 80 lines or 4 KiB behind a "Show 2026-09-08 chasing Iris's "expanding the edit card lags" report, whose
all" (`tool.rs`'s `OUTPUT_LINES`/`OUTPUT_BYTES`); the input half has no actual cause was the quadratic `apply_free` (fixed; docs/IRIS.md).
cap at all, which is the asymmetry to close, and the cheaper fix of the **Done 2026-09-08**: the input is capped exactly as the output already
two. Culling inside a `Text` is the other, and is a real design was, behind a "Show all N lines" -- and so are messages, in both apps
question: the shaped layout knows where each glyph is, so a viewport (`client_core::text_cap`, `TextCap.kt`). What is *not* done is culling
test is possible, but nothing else in iris cares where the screen is. inside a `Text`, which stays a real design question: the shaped layout
Found 2026-09-08 chasing Iris's "expanding the edit card lags" report, knows where each glyph is, so a viewport test is possible, but nothing
whose actual cause was the quadratic `apply_free` (fixed; docs/IRIS.md). else in iris cares where the screen is, and with the caps in place
Wrapping the block does not change this cost -- the same glyphs are laid nothing on this screen is unbounded any more.
out either way.
- [ ] **No overflow ellipsis.** `TextAttrs` can wrap or not wrap; there is - [ ] **No overflow ellipsis.** `TextAttrs` can wrap or not wrap; there is
no "one line, ellipsised" the way `maxLines = 1` + `TextOverflow. no "one line, ellipsised" the way `maxLines = 1` + `TextOverflow.
Ellipsis` gives Compose. A tool card's summary is clipped instead, so 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)); list.push_back(LazyItem::new(i as u64, row));
} }
let list = rsc.ui.widgets.add_strong(list); 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 // Driven through the span's own `ScrollController`, like every other
// scroll area in iris: what this measures has to be the path the app // scroll area in iris: what this measures has to be the path the app
// actually takes. // actually takes.
(weak, root.any()) (list.weak(), list.any())
} }
fn report(label: &str, elapsed: std::time::Duration, draws: u64, rewrites: u64, moves: u64) { 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 = rsc.ui.widgets.add_strong(list);
let list_weak = list.weak(); let list_weak = list.weak();
// Masked for `build_message_list`'s reason -- a `LazySpan` requires it. let root = list.any();
let root = rsc
.ui
.widgets
.add_strong(Masked {
shape: None,
inner: list.any(),
})
.any();
let growable = growable.unwrap(); let growable = growable.unwrap();
let mut render = UiRenderState::new(); let mut render = UiRenderState::new();
-9
View File
@@ -181,15 +181,6 @@ impl<'a> Painter<'a> {
self.mask = self.own_mask; 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 /// Draws a widget within this widget's region, returning the size it
/// reported using. /// reported using.
pub fn widget<W: ?Sized>(&mut self, id: &StrongWidget<W>) -> Size { pub fn widget<W: ?Sized>(&mut self, id: &StrongWidget<W>) -> Size {
+24 -20
View File
@@ -11,6 +11,26 @@
//! //!
//! ## Design //! ## 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 //! **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 //! 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 //! 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 { fn draw(&mut self, painter: &mut Painter) -> Size {
let axis = self.dir.axis; let axis = self.dir.axis;
// A row that straddles either edge is drawn in full // The clip is this list's own box -- see the module doc. Set here
// (`intersects_viewport`), so the part of it outside this list's // rather than required of the caller, so that placing a lazy span
// box is on screen unless something clips it -- and with nothing // is placing a widget and nothing else.
// clipping it, a transcript panned to its top edge drew code and painter.set_mask(painter.region());
// 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",
);
let output_len = painter.output_size().axis(axis); let output_len = painter.output_size().axis(axis);
self.viewport_len = painter.region().axis(axis).len().to_abs(output_len); 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 /// 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 /// mask -- with none, the phone drew `version = "0.1.0"` behind the "Run
/// benchmark" button. /// 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] #[test]
fn the_list_is_clipped_to_its_own_box() { fn the_list_is_clipped_to_its_own_box() {
let (h, screen) = opened(); let (h, screen) = opened();
let active = h.render.active.get(&screen.list.id()).expect("drawn"); let active = h.render.active.get(&screen.list.id()).expect("drawn");
assert!( assert!(
active.mask != MaskIdx::NONE, active.own_mask != MaskIdx::NONE,
"the transcript's list is drawn with nothing clipping it", "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); let list = list_box(&h, &screen);
assert!( assert!(
clip.top_left.y >= list.top_left.y - 0.5 && clip.bot_right.y <= list.bot_right.y + 0.5, 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 row in rows {
for prim in primitives_under(&h, row) { for prim in primitives_under(&h, row) {
assert!( 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 \ "a primitive of row {row:?} clips to {:?}, a chain that never reaches the list's \
own mask {:?}", own mask {:?}",
mask_chain(&h, prim), mask_chain(&h, prim),
active.mask, active.own_mask,
); );
checked += 1; checked += 1;
} }
+82 -9
View File
@@ -47,6 +47,7 @@ pub mod composer;
pub mod markdown; pub mod markdown;
pub mod row; pub mod row;
pub mod selection; pub mod selection;
pub(crate) mod tap;
pub mod tool; pub mod tool;
use client_core::transcript_fold::TranscriptRow as FoldedRow; use client_core::transcript_fold::TranscriptRow as FoldedRow;
@@ -92,12 +93,18 @@ impl TranscriptScreen {
where where
Rsc::State: FocusHost + OpenUrl, 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( let (key, widget, tail) = row::build_row(
rsc, rsc,
self.list, self.list,
self.selection.clone(), self.selection.clone(),
row, row,
self.session_working.get(), self.session_working.get(),
true,
); );
(self.list)(rsc).push_back(LazyItem::new(key, widget)); (self.list)(rsc).push_back(LazyItem::new(key, widget));
*self.tail.borrow_mut() = tail.map(|t| (key, t)); *self.tail.borrow_mut() = tail.map(|t| (key, t));
@@ -281,12 +288,17 @@ impl TranscriptScreen {
// pointing at widgets the `drop` below frees (the shape // pointing at widgets the `drop` below frees (the shape
// docs/REVIEW-2026-09-06.md's finding 1 called out). // docs/REVIEW-2026-09-06.md's finding 1 called out).
self.selection.borrow_mut().unregister(old_key); 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( let (new_key, widget, kept) = row::build_row(
rsc, rsc,
self.list, self.list,
self.selection.clone(), self.selection.clone(),
&new_rows[common], &new_rows[common],
self.session_working.get(), self.session_working.get(),
false,
); );
let evicted = (self.list)(rsc).replace_back(LazyItem::new(new_key, widget)); 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 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 // exists to avoid, and nothing on screen or in `take_rebuilds` would
// say so. // say so.
let mut tail = None; 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 // `false`: a row built here is history until the caller says the
// session is working (`TranscriptScreen::set_session_working`), // session is working (`TranscriptScreen::set_session_working`),
// and claiming a call is running because the screen happens to be // and claiming a call is running because the screen happens to be
// opening is exactly the inferred-as-measured mistake. // 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)); list(rsc).push_back(LazyItem::new(key, widget));
tail = kept.map(|t| (key, t)); tail = kept.map(|t| (key, t));
} }
@@ -432,13 +448,12 @@ where
let (composer, composer_bar) = composer::build_composer(rsc); let (composer, composer_bar) = composer::build_composer(rsc);
// `.masked()`: the list draws the row straddling each of its edges in // No `.masked()` around the list: a `LazySpan` clips itself to the box
// full (`LazySpan::intersects_viewport`), so without a clip the top of // it is offered (its module doc), which is the clip this used to
// that row is drawn above the list -- through whatever the app put // supply -- the one that keeps the straddling top row off the header
// there, which on the phone is the header bar (docs/IRIS_TODO.md, // bar above it (docs/IRIS_TODO.md, 2026-09-07: "code and a paragraph
// 2026-09-07: "code and a paragraph visible behind Run benchmark"). // visible behind Run benchmark").
// The same clip is what `LazySpan::draw` asserts it has. let tree = (list.width(rest(1)).height(rest(1)), composer_bar)
let tree = (list.width(rest(1)).height(rest(1)).masked(), composer_bar)
.span(Dir::DOWN) .span(Dir::DOWN)
.add_strong(rsc) .add_strong(rsc)
.any(); .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 /// 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 /// `count` calls -- with the group open, so every card is really on
/// screen and a rebuild of the wrong scope would show. /// 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::markdown::{BlockFrame, Link, frame_of, render_block};
use crate::selection::{SelKey, Selection}; use crate::selection::{SelKey, Selection};
use crate::tap::{hold_edge, on_tap};
use crate::tool::ToolRow; use crate::tool::ToolRow;
use client_core::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks}; 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 client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
use iris::prelude::*; use iris::prelude::*;
use std::{cell::RefCell, rc::Rc}; 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 /// 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. /// not a delta into the same message, so it falls back to a rebuild.
sender: Option<String>, 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 /// 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 /// 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 /// 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 /// 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 /// `client_core::markdown_blocks` for the split. Selection still runs
/// across the whole transcript; the unit it steps in is a block now rather /// across the whole transcript; the unit it steps in is a block now rather
/// than a row (`selection::SelKey`). /// 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>( fn build_text_row<Rsc: HasEvents>(
rsc: &mut Rsc, rsc: &mut Rsc,
list: WeakWidget<LazySpan>, list: WeakWidget<LazySpan>,
@@ -320,11 +407,41 @@ fn build_text_row<Rsc: HasEvents>(
key: RowKey, key: RowKey,
sender: Option<&str>, sender: Option<&str>,
markdown_src: &str, markdown_src: &str,
cap: bool,
) -> (StrongWidget, RowBlocks) ) -> (StrongWidget, RowBlocks)
where where
Rsc::State: FocusHost + OpenUrl, 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 column = Span::empty(Dir::DOWN).gap(dp(BLOCK_GAP_DP));
let mut fields = Vec::with_capacity(blocks.len()); let mut fields = Vec::with_capacity(blocks.len());
let mut links = Vec::with_capacity(blocks.len()); let mut links = Vec::with_capacity(blocks.len());
@@ -335,6 +452,17 @@ where
links.push(block_links); links.push(block_links);
column.push(framed); 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); let column = column.add(rsc);
// `.add` (weak), not `.add_strong` -- `header` is about to be embedded // `.add` (weak), not `.add_strong` -- `header` is about to be embedded
@@ -345,8 +473,8 @@ where
// twice and panicked with "was already added" // twice and panicked with "was already added"
// (`core/src/widget/like.rs:12`) -- found running this crate's own // (`core/src/widget/like.rs:12`) -- found running this crate's own
// `run-headless.sh` example, the first real render of a row. // `run-headless.sh` example, the first real render of a row.
let header: WeakWidget = match sender { let header: WeakWidget = match &source.sender {
Some(name) => wtext(name.to_string()) Some(name) => wtext(name.clone())
.size(13.0) .size(13.0)
.color(UiColor::new(150, 150, 160, 255)) .color(UiColor::new(150, 150, 160, 255))
.add(rsc), .add(rsc),
@@ -366,11 +494,57 @@ where
fields, fields,
links, links,
column, 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 { impl RowBlocks {
/// Bring this row up to date with `markdown_src` **without** re-laying /// Bring this row up to date with `markdown_src` **without** re-laying
/// out the blocks that did not change, and say whether that was /// out the blocks that did not change, and say whether that was
@@ -397,6 +571,14 @@ impl RowBlocks {
if self.sender.as_deref() != sender { if self.sender.as_deref() != sender {
return false; 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 new_blocks = display_blocks(markdown_src);
let common = common_prefix(&self.blocks, &new_blocks); let common = common_prefix(&self.blocks, &new_blocks);
// Everything already drawn must either be kept whole (`common == // Everything already drawn must either be kept whole (`common ==
@@ -463,12 +645,13 @@ fn build_single<Rsc: HasEvents>(
selection: Rc<RefCell<Selection>>, selection: Rc<RefCell<Selection>>,
key: RowKey, key: RowKey,
item: &TranscriptItem, item: &TranscriptItem,
cap: bool,
) -> (StrongWidget, RowBlocks) ) -> (StrongWidget, RowBlocks)
where where
Rsc::State: FocusHost + OpenUrl, Rsc::State: FocusHost + OpenUrl,
{ {
let (sender, markdown_src) = item_content(item); 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 /// What a row keeps so the next event can change part of it instead of
@@ -487,12 +670,18 @@ pub enum TailRow {
Tools(ToolRow), 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>( pub fn build_row<Rsc: HasEvents>(
rsc: &mut Rsc, rsc: &mut Rsc,
list: WeakWidget<LazySpan>, list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>, selection: Rc<RefCell<Selection>>,
row: &FoldedRow, row: &FoldedRow,
working: bool, working: bool,
cap: bool,
) -> (RowKey, StrongWidget, Option<TailRow>) ) -> (RowKey, StrongWidget, Option<TailRow>)
where where
Rsc::State: FocusHost + OpenUrl, Rsc::State: FocusHost + OpenUrl,
@@ -518,6 +707,75 @@ where
unreachable!("every Tools row took the branch above"); unreachable!("every Tools row took the branch above");
}; };
let key = row_key(&item.key()); 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))) (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);
}
}
+146 -161
View File
@@ -34,6 +34,8 @@
use crate::markdown::{TEXT_COLOR, VERBATIM_BACKGROUND, highlight_into}; use crate::markdown::{TEXT_COLOR, VERBATIM_BACKGROUND, highlight_into};
use crate::selection::Selection; 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::tool_summary::{ToolInput, parse_tool_input};
use client_core::transcript_fold::{ToolState, TranscriptItem}; use client_core::transcript_fold::{ToolState, TranscriptItem};
use iris::prelude::*; 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`). /// How far a group holds its cards off its own edge (`ToolRows.kt`).
const GROUP_INSET_DP: f32 = 4.0; 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 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 /// 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 { struct ToolRowState {
group_expanded: bool, group_expanded: bool,
open: HashMap<String, 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 /// Everything a handler needs to redraw part of this row, in one `Rc` so
@@ -172,69 +169,6 @@ pub struct ToolRow {
shared: Rc<Shared>, 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> { fn text<Rsc>(content: impl Into<String>, size: f32, color: UiColor) -> TextBuilder<Rsc> {
wtext(content) wtext(content)
.size(size) .size(size)
@@ -324,30 +258,73 @@ fn group_label(count: usize) -> String {
format!("Called {count} tools") format!("Called {count} tools")
} }
/// `output` cut to what an open card draws, with the line count it was cut /// One verbatim block as it will be drawn: the text, the line count of
/// from; `None` when the whole of it fits. /// the *whole* of it, and whether anything was left out.
/// ///
/// Cut at the **head**, keeping the beginning: a tool's output is read /// `whole` is the reader having already asked for all of it, folded in
/// from the top, and the line saying what went wrong is nearly always the /// here so that every caller reads the same three values whichever answer
/// first. (A path is identified by its other end; this is not a path.) /// it was.
fn capped(output: &str) -> Option<(&str, usize)> { fn capped(body: &str, whole: bool) -> (&str, usize, bool) {
let by_lines = output match cut(body, VERBATIM_LINES, VERBATIM_BYTES) {
.char_indices() Some((head, lines)) if !whole => (head, lines, true),
.filter(|(_, c)| *c == '\n') Some((_, lines)) => (body, lines, false),
.nth(OUTPUT_LINES - 1) None => (body, body.lines().count(), false),
.map(|(i, _)| i); }
let by_bytes = (output.len() > OUTPUT_BYTES).then(|| { }
let mut end = OUTPUT_BYTES;
while !output.is_char_boundary(end) { /// Whether the reader has asked for the whole of `part` on this call.
end -= 1; fn wants_whole(shared: &Shared, id: &str, part: Part) -> bool {
} shared
end .state
}); .borrow()
let cut = match (by_lines, by_bytes) { .whole
(Some(a), Some(b)) => a.min(b), .get(&(id.to_string(), part))
(a, b) => a.or(b)?, .copied()
}; .unwrap_or(false)
Some((&output[..cut], output.lines().count())) }
/// 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. /// 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(); return text(words, LABEL_SIZE, colour).add_strong(rsc).any();
} }
let whole = shared let (shown, lines, was_cut) = capped(output, wants_whole(shared, id, Part::Output));
.state
.borrow()
.whole_output
.get(id)
.copied()
.unwrap_or(false);
let shown = if whole { None } else { capped(output) };
let mut column = Span::empty(Dir::DOWN).gap(dp(2)); let mut column = Span::empty(Dir::DOWN).gap(dp(2));
column.push(text("Output", LABEL_SIZE, NAME_COLOR).add_strong(rsc).any()); 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 // 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 // column-aligned far more often than it is prose, and a proportional
// font destroys the alignment that carried the meaning. // font destroys the alignment that carried the meaning.
let body = text( let body = text(shown.to_string(), BODY_SIZE, NAME_COLOR);
shown.map_or(output, |(head, _)| head).to_string(),
BODY_SIZE,
NAME_COLOR,
);
column.push(raw_block(rsc, body)); column.push(raw_block(rsc, body));
if let Some((_, lines)) = shown { if was_cut {
let label = format!("Show all {lines} lines"); column.push(show_all(rsc, shared, index, id, Part::Output, 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());
} }
column.width(rest(1)).add_strong(rsc).any() column.width(rest(1)).add_strong(rsc).any()
} }
@@ -502,11 +450,27 @@ where
.any(), .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 { 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 { 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) => { Some(language) => {
let mut spans = Vec::new(); let mut spans = Vec::new();
highlight_into(&mut spans, subject, 0..subject.len(), language); highlight_into(&mut spans, shown, 0..shown.len(), language);
spans spans
} }
// An unknown language is drawn plain rather than coloured // An unknown language is drawn plain rather than coloured
@@ -514,15 +478,24 @@ where
// same reason: a wrong highlight is read as a fact. // same reason: a wrong highlight is read as a fact.
None => Vec::new(), 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)); column.push(raw_block(rsc, body));
} }
if !parsed.rest.is_empty() { if !parsed.rest.is_empty() {
// Never dropped: a field left out would be claiming the tool // Never dropped: a field left out would be claiming the tool
// had no other input when it might (`ToolInput.kt`). // had no other input when it might (`ToolInput.kt`). Capped is
let body = text(parsed.rest.join("\n"), BODY_SIZE, MUTED_COLOR); // 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)); 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)); column.push(output_block(rsc, shared, index, id, output, call_state));
} }
@@ -578,22 +551,28 @@ where
let content = build_card(rsc, shared, index); let content = build_card(rsc, shared, index);
ptr(rsc).set(content); ptr(rsc).set(content);
let for_tap = shared.clone(); let for_tap = shared.clone();
on_tap(rsc, ptr, shared, move |rsc| { on_tap(
note_tap(rsc, &for_tap); rsc,
let Some(id) = for_tap.call_id(index) else { ptr,
debug_assert!(false, "tapped card {index} is no longer in the row"); shared.list,
return; shared.selection.clone(),
}; move |rsc| {
let was = for_tap hold_edge(rsc, for_tap.list, for_tap.key);
.state let Some(id) = for_tap.call_id(index) else {
.borrow() debug_assert!(false, "tapped card {index} is no longer in the row");
.open return;
.get(&id) };
.copied() let was = for_tap
.unwrap_or(false); .state
for_tap.state.borrow_mut().open.insert(id, !was); .borrow()
redraw_card(rsc, &for_tap, index); .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) (strong.any(), ptr)
} }
@@ -620,7 +599,13 @@ where
.add_strong(rsc); .add_strong(rsc);
ptr(rsc).set(mark); ptr(rsc).set(mark);
let for_tap = shared.clone(); 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() strong.any()
} }
@@ -704,7 +689,7 @@ fn toggle_group<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>)
where where
Rsc::State: FocusHost + OpenUrl, Rsc::State: FocusHost + OpenUrl,
{ {
note_tap(rsc, shared); hold_edge(rsc, shared.list, shared.key);
let was = shared.state.borrow().group_expanded; let was = shared.state.borrow().group_expanded;
shared.state.borrow_mut().group_expanded = !was; shared.state.borrow_mut().group_expanded = !was;
let content = build_content(rsc, shared); let content = build_content(rsc, shared);
@@ -853,7 +838,7 @@ impl ToolRow {
self.shared.working.set(working); self.shared.working.set(working);
*self.shared.calls.borrow_mut() = calls.to_vec(); *self.shared.calls.borrow_mut() = calls.to_vec();
// The path out for the reader's own state: a call that is no // 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 let ids: std::collections::HashSet<String> = calls
.iter() .iter()
.filter_map(|c| match c { .filter_map(|c| match c {
@@ -864,7 +849,7 @@ impl ToolRow {
{ {
let mut state = self.shared.state.borrow_mut(); let mut state = self.shared.state.borrow_mut();
state.open.retain(|id, _| ids.contains(id)); 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 // A collapsed group draws no cards, so a changed call is worth