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