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

+58 -15
View File
@@ -12,7 +12,56 @@ things still stay out.
An entry gives the date, what changed, why, and a short before/after where
it helps judge the change without the session that made it. Newest first.
## 2026-09-08 (newest): redrawing one widget cost O(its own primitives squared)
## 2026-09-08 (newest): a `LazySpan` clips itself, and nothing is unbounded
Four things you asked for, in one change.
**A lazy span no longer cares about masks.** It used to assert that
something around it had called `.masked()` and refuse to draw otherwise,
which is why a plain full-screen list -- the benchmark, any simple app --
panicked on its second line. Your question was the right one: it cared
only because it draws a row straddling an edge *in full* (virtualisation
decides which rows, never how much of one) and relied on somebody else to
cut off the overhang. It clips itself to the box it was offered now, so a
caller places it like any other widget. That is also strictly stronger
than the assert was: a mask *larger* than the list's box satisfied
`is_masked` and let the overhang through anyway, which is the fault the
assert was written for. The transcript's own `.masked()` wrapper is gone
with it.
**Everything on the transcript screen is capped now.** One rule in one
place -- `client_core::text_cap`, mirrored as `TextCap.kt` with the same
numbers, so a bench comparing the apps compares renderers and not
policies:
a tool call's input 80 lines or 4 KiB -> "Show all N lines"
a tool call's output 80 lines or 4 KiB -> (already was)
a message 200 lines or 16 KiB -> "Show all N lines"
The input is what your edit card needed: an `Edit`'s `old_string` and
`new_string` arrive whole and are routinely the biggest text on screen.
Messages are capped for the reason you gave -- both user and agent, in
both apps.
Three rules that took a screenshot to get right. A message is cut on a
**block boundary**, not mid-block: cut to its own opening line a fence
renders as an empty panel, which reads as a fault rather than as a cap
(the exception is a message that is *one* enormous block, which is
truncated, since dropping it would leave the row blank). A reply still
streaming is **never** capped, because a row that stopped growing at two
hundred lines while the model was plainly still writing reads as the
stream having died. And the input's two blocks share **one** "Show all",
since they are two halves of one answer -- while input and output have
their own, since wanting the whole of a `new_string` says nothing about
wanting the whole of the build log under it.
**The Compose app does not wrap raw text any more**, per your call: a
tool's leftover input fields and its output pan sideways like the command
already did. A wrapped log destroys the column alignment that carried its
meaning, one line at a time and only on the long lines -- so iris was
right and Compose was the one to change.
## 2026-09-08: redrawing one widget cost O(its own primitives squared)
Your report -- expanding a tool card with a long horizontally-scrolling
edit in it lags -- is a framework defect, not a text-layout one, and the
@@ -39,21 +88,15 @@ where that slot's handle sits in its owner's `ActiveData::primitives`
`benches/message_list.rs` grew scenario **(g)** for it, and the number to
read is per-glyph: flat as N grows is the pass condition, and a total
hides it. Two things about that file: it also caught the quadratic in
`--phone`-sized text, and it had stopped running at all -- scenarios (a)
and (e) built a `LazySpan` with no mask around it, which the span now
asserts against, so the whole benchmark panicked on its second line.
Fixed in the same change.
hides it. That file had also stopped running at all -- scenarios (a) and
(e) built a `LazySpan` with no mask around it, which the span asserted
against, so the whole benchmark panicked on its second line. Worked around
here and fixed properly in the entry above, which deletes the assert.
**What this does not fix, and what I would do next.** An open card still
shapes, rasterises and submits *every* glyph of its input and output, not
the screenful you can see -- iris does not cull within a widget, and a
tool card is the one place that bites, because an `Edit`'s `old_string`
and `new_string` go onto the card whole. The output half is already capped
(`OUTPUT_LINES`/`OUTPUT_BYTES` in `tool.rs`, 80 lines or 4 KiB behind a
"Show all"); the *input* half has no cap at all, and that is the
asymmetry to close. Wrapping the block, which you asked for, changes the
shape but not this cost: the same glyphs are laid out either way.
**What this does not fix**, and what the entry below closes: an open card
still shapes, rasterises and submits *every* glyph of its input and
output, not the screenful you can see. iris does not cull within a widget,
so the only bound available is a cap on what goes in.
## 2026-09-08: one `ScrollController`, a `Scrollable` trait, and `Pin`
+11 -12
View File
@@ -944,21 +944,20 @@ diagnosis and what building it actually costs.
the shaped-mask work (`.masked_by`, 38bf630) is the likeliest, since the
old chain was `.masked()` *inside* the padding. Left ticked with the
original symptom recorded rather than deleted, in case it comes back.
- [ ] **An open tool card lays out every glyph of its input, however
- [x] **An open tool card lays out every glyph of its input, however
long.** iris does not cull within a widget -- a `Text` shapes,
rasterises and submits the whole string whether or not the box it sits
in can show it -- and a tool card is where that bites, because an
`Edit`'s `old_string` and `new_string` go onto the card whole. The
*output* half is already capped at 80 lines or 4 KiB behind a "Show
all" (`tool.rs`'s `OUTPUT_LINES`/`OUTPUT_BYTES`); the input half has no
cap at all, which is the asymmetry to close, and the cheaper fix of the
two. Culling inside a `Text` is the other, and is a real design
question: the shaped layout knows where each glyph is, so a viewport
test is possible, but nothing else in iris cares where the screen is.
Found 2026-09-08 chasing Iris's "expanding the edit card lags" report,
whose actual cause was the quadratic `apply_free` (fixed; docs/IRIS.md).
Wrapping the block does not change this cost -- the same glyphs are laid
out either way.
`Edit`'s `old_string` and `new_string` go onto the card whole. Found
2026-09-08 chasing Iris's "expanding the edit card lags" report, whose
actual cause was the quadratic `apply_free` (fixed; docs/IRIS.md).
**Done 2026-09-08**: the input is capped exactly as the output already
was, behind a "Show all N lines" -- and so are messages, in both apps
(`client_core::text_cap`, `TextCap.kt`). What is *not* done is culling
inside a `Text`, which stays a real design question: the shaped layout
knows where each glyph is, so a viewport test is possible, but nothing
else in iris cares where the screen is, and with the caps in place
nothing on this screen is unbounded any more.
- [ ] **No overflow ellipsis.** `TextAttrs` can wrap or not wrap; there is
no "one line, ellipsised" the way `maxLines = 1` + `TextOverflow.
Ellipsis` gives Compose. A tool card's summary is clipped instead, so