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:
1 parent
1318e149f5
commit
afbc2ad132
18 files changed
+1254
-272
No files matched your search
@@ -130,6 +130,26 @@ sealed class TranscriptUnit {
|
||||
get() = "u$seq:$ordinal"
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Show all N lines" under a message drawn only as far as [TextCap.MESSAGE_LINES].
|
||||
*
|
||||
* Its own unit rather than something inside the row above it, because the row above it is a
|
||||
* *bounded* item now and this is what says so -- and because a control that lives inside the
|
||||
* thing it reveals moves the moment it is pressed.
|
||||
*/
|
||||
data class ShowAll(
|
||||
override val seq: Long,
|
||||
override val ordinal: Int,
|
||||
/** The row this belongs to; what goes into the set of rows shown whole. */
|
||||
val row: Any,
|
||||
/** The line count of the whole message, which is what the offer says. */
|
||||
val lines: Int,
|
||||
override val gap: Dp,
|
||||
) : TranscriptUnit() {
|
||||
override val key: Any
|
||||
get() = "s$row"
|
||||
}
|
||||
|
||||
/** One memory note of a settled reply; see [MemoryNote]. */
|
||||
data class Memory(
|
||||
override val seq: Long,
|
||||
@@ -142,6 +162,36 @@ sealed class TranscriptUnit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A message row cut to [TextCap]'s worth of itself, and the line count of the whole of it.
|
||||
*
|
||||
* The cut happens **before** the flatten below decides how to draw the row, so everything after it
|
||||
* -- pieces, chunks, warming -- sees a shorter message and needs to know nothing about caps. The
|
||||
* shortened row keeps its key and its seq, so the list's identity and every saved scroll anchor are
|
||||
* untouched by a reader opening or closing one.
|
||||
*
|
||||
* A reply still arriving is never capped: it grows by deltas, and a row that stopped growing at two
|
||||
* hundred lines while the model was plainly still writing would read as the stream having died.
|
||||
* `iris`'s `row::build_row` states the same rule for the same reason.
|
||||
*/
|
||||
private fun capRow(row: TranscriptRow, shownWhole: Set<Any>): Pair<TranscriptRow, Int?> {
|
||||
val item = (row as? TranscriptRow.Single)?.item ?: return row to null
|
||||
if (row.key in shownWhole) return row to null
|
||||
val cut =
|
||||
when {
|
||||
item is TranscriptItem.UserMsg ->
|
||||
cutText(item.text, TextCap.MESSAGE_LINES, TextCap.MESSAGE_BYTES)?.let {
|
||||
it to TranscriptRow.Single(item.copy(text = it.shown))
|
||||
}
|
||||
item is TranscriptItem.AssistantMsg && item.settled ->
|
||||
cutText(item.text, TextCap.MESSAGE_LINES, TextCap.MESSAGE_BYTES)?.let {
|
||||
it to TranscriptRow.Single(item.copy(text = it.shown))
|
||||
}
|
||||
else -> null
|
||||
} ?: return row to null
|
||||
return cut.second to cut.first.lines
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows flattened into list units, newest first -- index zero is the item at the bottom of the
|
||||
* screen, which is what a reversed lazy list calls the start.
|
||||
@@ -161,11 +211,14 @@ fun transcriptUnits(
|
||||
rows: List<TranscriptRow>,
|
||||
replies: ParsedReplies,
|
||||
openNotes: Set<Long>,
|
||||
shownWhole: Set<Any> = emptySet(),
|
||||
): List<TranscriptUnit> {
|
||||
val started = System.nanoTime()
|
||||
val units = ArrayList<TranscriptUnit>(rows.size)
|
||||
rows.forEachIndexed { index, row ->
|
||||
rows.forEachIndexed { index, whole ->
|
||||
val rowGap = if (index == 0) 0.dp else TRANSCRIPT_SPACING
|
||||
val (row, hidden) = capRow(whole, shownWhole)
|
||||
val rowStart = units.size
|
||||
val item = (row as? TranscriptRow.Single)?.item
|
||||
if (item is TranscriptItem.PeerNote) {
|
||||
val open = item.seq in openNotes
|
||||
@@ -240,6 +293,16 @@ fun transcriptUnits(
|
||||
} else {
|
||||
units += TranscriptUnit.Whole(row, rowGap)
|
||||
}
|
||||
if (hidden != null) {
|
||||
units +=
|
||||
TranscriptUnit.ShowAll(
|
||||
row.startSeq,
|
||||
units.size - rowStart,
|
||||
row.key,
|
||||
hidden,
|
||||
BLOCK_SPACING,
|
||||
)
|
||||
}
|
||||
}
|
||||
units.reverse()
|
||||
reportDuplicateKeys(units)
|
||||
@@ -266,11 +329,18 @@ private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex
|
||||
* what this returns off-thread and re-flattens, so the whole-to-blocks swap always composes against
|
||||
* ready parses.
|
||||
*/
|
||||
fun unwarmedReplies(rows: List<TranscriptRow>, replies: ParsedReplies): List<TranscriptItem> =
|
||||
rows.mapIndexedNotNull { index, row ->
|
||||
val item = (row as? TranscriptRow.Single)?.item as? TranscriptItem.AssistantMsg
|
||||
item?.takeIf { splitWanted(it, index, rows.lastIndex) && !replies.splitReady(it.text) }
|
||||
}
|
||||
fun unwarmedReplies(
|
||||
rows: List<TranscriptRow>,
|
||||
replies: ParsedReplies,
|
||||
shownWhole: Set<Any> = emptySet(),
|
||||
): List<TranscriptItem> = rows.mapIndexedNotNull { index, whole ->
|
||||
// The *capped* row's text, since that is what the flatten will draw and so what has to be
|
||||
// ready: a capped row draws its head, which is a different string from the message and so a
|
||||
// different cache entry.
|
||||
val row = capRow(whole, shownWhole).first
|
||||
val item = (row as? TranscriptRow.Single)?.item as? TranscriptItem.AssistantMsg
|
||||
item?.takeIf { splitWanted(it, index, rows.lastIndex) && !replies.splitReady(it.text) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Above this many characters, a user message is drawn in slices rather than as one bubble.
|
||||
@@ -375,6 +445,7 @@ private val TranscriptUnit?.kind: String
|
||||
is TranscriptUnit.PeerBlock -> "peer block"
|
||||
is TranscriptUnit.UserChunk -> "user slice"
|
||||
is TranscriptUnit.Memory -> "memory note"
|
||||
is TranscriptUnit.ShowAll -> "show all"
|
||||
is TranscriptUnit.Whole ->
|
||||
when (val row = row) {
|
||||
is TranscriptRow.Tools -> "tool group"
|
||||
|
||||
Reference in new issue
Block a user