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

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

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

**Everything on the transcript screen is capped.** One rule in one place,
`client_core::text_cap`, mirrored as `TextCap.kt` with the same numbers so
a bench comparing the apps compares renderers rather than policies:

    a tool call's input    80 lines or 4 KiB   -> "Show all N lines"
    a tool call's output   80 lines or 4 KiB   -> (already was, in iris)
    a message             200 lines or 16 KiB  -> "Show all N lines"

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

Three rules that took a screenshot to get right. A message is cut on a
block boundary, never mid-block -- cut to its own opening line a fence
renders as an empty panel, which reads as a fault rather than as a cap --
except a message that is one enormous block, which is truncated, since
dropping it would leave the row blank. A reply still streaming is never
capped. And the input's two blocks share one "Show all", while input and
output have their own.

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

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

No files matched your search

+1
View File
@@ -12,6 +12,7 @@ pub mod log_ring;
pub mod markdown_blocks;
pub mod notifications;
pub mod sse;
pub mod text_cap;
pub mod tool_summary;
pub mod transcript_cache;
pub mod transcript_fold;
+134
View File
@@ -0,0 +1,134 @@
//! How much of a long thing a transcript draws before offering the rest
//! behind a tap.
//!
//! One rule, four surfaces: a tool call's input, its output, and a user or
//! assistant message. It lives here rather than at any one of them because
//! four copies would eventually disagree about what "too long" is, and
//! because the Compose app has to answer the same question the same way --
//! `TextCap.kt` is the Kotlin half, and the two are checked against the
//! same numbers so a benchmark comparing the apps is comparing renderers
//! rather than policies.
//!
//! **Lines and bytes both, whichever runs out first**, because they run
//! out on different things: a diff is thousands of short lines, a minified
//! file or a base64 blob is one enormous one, and a cap that only counted
//! one of them draws the whole of the other.
//!
//! **Cut at the head, keeping the beginning.** A tool's output is read
//! from the top and the line saying what went wrong is nearly always the
//! first; a message is read from the top for the obvious reason. (A path
//! is identified by its other end -- none of these is a path.)
/// The default bound on a verbatim block -- a tool call's input or its
/// output. Short, because this text is a machine's and the reader is
/// looking for one line of it.
pub const VERBATIM_LINES: usize = 80;
pub const VERBATIM_BYTES: usize = 4096;
/// The bound on a message, a person's or the model's. Larger than a
/// verbatim block's in bytes and smaller in lines: prose is read whole and
/// wraps, so a screenful of it is far fewer lines than a screenful of a
/// log, and cutting a reply at 80 lines would cut most long answers that
/// nobody would call long.
pub const MESSAGE_LINES: usize = 200;
pub const MESSAGE_BYTES: usize = 16 * 1024;
/// A cap of nothing would draw an empty panel and a "Show all" for
/// everything there is, which reads as a rendering fault rather than as a
/// cap. Checked at compile time, since all four are constants.
const _: () = assert!(VERBATIM_LINES > 0 && VERBATIM_BYTES > 0);
const _: () = assert!(MESSAGE_LINES > 0 && MESSAGE_BYTES > 0);
/// `text` cut to `max_lines` lines and `max_bytes` bytes, with the line
/// count it was cut *from*; `None` when the whole of it fits.
///
/// The count is the whole text's, not the shown part's -- it is what the
/// "Show all N lines" offer says, and a reader deciding whether to ask for
/// the rest wants to know how much the rest is.
pub fn cut(text: &str, max_lines: usize, max_bytes: usize) -> Option<(&str, usize)> {
debug_assert!(
max_lines > 0 && max_bytes > 0,
"a cap of nothing shows an empty block and a 'Show all' for every value there is",
);
let by_lines = text
.char_indices()
.filter(|(_, c)| *c == '\n')
.nth(max_lines - 1)
.map(|(i, _)| i);
let by_bytes = (text.len() > max_bytes).then(|| {
let mut end = max_bytes;
// Back up to a character boundary: a cut inside a multi-byte
// character panics on the slice below, and a transcript is full of
// them.
while !text.is_char_boundary(end) {
end -= 1;
}
end
});
let cut = match (by_lines, by_bytes) {
(Some(a), Some(b)) => a.min(b),
(a, b) => a.or(b)?,
};
Some((&text[..cut], text.lines().count()))
}
/// What a "Show all" offer says, so the wording is one string rather than
/// one per surface.
pub fn show_all_label(lines: usize) -> String {
format!("Show all {lines} lines")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_under_both_bounds_is_not_cut() {
assert_eq!(cut("one\ntwo\nthree", 80, 4096), None);
}
#[test]
fn the_line_bound_cuts_at_a_line_boundary() {
let text = "a\nb\nc\nd\n";
let (shown, lines) = cut(text, 2, 4096).expect("four lines is over a bound of two");
assert_eq!(shown, "a\nb");
assert_eq!(
lines, 4,
"the count is the whole text's, not the shown part's"
);
}
/// The half the line bound cannot catch: one enormous line, which is
/// what a minified file or an embedded image arrives as.
#[test]
fn the_byte_bound_cuts_one_long_line() {
let text = "x".repeat(5000);
let (shown, lines) = cut(&text, 80, 4096).expect("5000 bytes is over a bound of 4096");
assert_eq!(shown.len(), 4096);
assert_eq!(lines, 1);
}
/// Whichever bites first, rather than whichever was checked first.
#[test]
fn the_tighter_of_the_two_bounds_wins() {
let text = "aaaa\n".repeat(100);
let (shown, _) = cut(&text, 80, 100).expect("over both");
assert_eq!(shown.len(), 100, "the byte bound is the tighter one here");
let (shown, _) = cut(&text, 4, 4096).expect("over the line bound");
assert_eq!(shown, "aaaa\naaaa\naaaa\naaaa");
}
/// A cut that lands inside a multi-byte character has to back up to
/// the boundary; slicing there would panic, and a transcript carries
/// em dashes and box drawing in every other line.
#[test]
fn a_cut_inside_a_multibyte_character_backs_up_to_the_boundary() {
let text = "é".repeat(100);
let (shown, _) = cut(&text, 80, 11).expect("200 bytes is over a bound of 11");
assert_eq!(
shown,
"é".repeat(5),
"11 bytes lands mid-character; 10 is the cut"
);
}
}