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