Tool-call cards and grouping, with the state a result never arrived in

P1b (docs/RUST.md). `transcript-ui/src/tool.rs` draws a card per tool
call and a group per run: collapsed, a card is its name and the one-line
summary `parse_tool_input` derives; open, it is the description, the
input (highlighted, on the verbatim surface) and the output, capped with
a "Show all N lines". A run is one surface with a heading and a chevron
bar at its foot, so it closes from either end.

Three things worth knowing.

**A collapsed card lays out its summary line and nothing else.** The
fixture's tool outputs are tens of kilobytes and a collapsed card never
builds a widget for one -- `collapsed_cards_shape_only_their_summary_
lines` opens a three-card group over 88 kB of output each and asserts the
text-shape count equals the same group's over three bytes (17 either
way; 17 against 20 when the discipline is deliberately broken, so the
test is real).

**A result arriving replaces one card.** `ToolRow::apply_calls` is the
group's half of `RowBlocks::apply_delta`'s rule, and `build_row` now
hands back one `TailRow` -- blocks for a message, cards for a run --
rather than two mechanisms chosen at each call site.

**Every tap is a tap**: `GestureOutcome::Tapped` out of the `DragArbiter`
`Selection` already owns, so a drag that started on a card scrolls the
transcript instead of opening it.

Three defects found by looking at the render, all recorded with their
repro in docs/IRIS_TODO.md: a `Span` of padded children inside another
`Span` places them a slot out of step (worked around by building the
group as one span, which costs the 4dp inset); `scrollable_on(Axis::X)`
on a non-editable text draws nothing, so a card's command is clipped
rather than pannable; and `NotoSans-Regular` has no U+25B8/25BE/25B4 at
all, so the expander mark is set in the monospace face.

Screenshots: docs/bench/p1b-2026-09-06/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-06 22:49:51 -04:00
1 parent b332873894
commit 7e7cbb5402
13 files changed
+356 -111

No files matched your search

+39
View File
@@ -5,6 +5,45 @@ they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md
for iris API changes); this file is only the summary. Newest first. Items
marked **DEFERRED** are ones the agent chose not to decide alone.
## 2026-09-06 (how a tool call looks, P1b)
- **A card that never got a result says "no result", in yellow, and it is
a state Compose cannot say.** A call that finished having printed
nothing and a call whose turn was interrupted before anything came back
both leave an empty output. Compose draws both as an ordinary finished
call, which reads as a fact somebody established. There are five states
now, each with a word and a colour: nothing at all for a call that
worked, "running" (grey), "your turn" (peach, Compose's own wording and
colour), "failed" (red), "no result" (yellow).
- **A failed call is drawn as failed, which needed a field on the wire.**
`is_error` is on the CLI's `tool_result` and was being dropped; the
server now carries it to the phone. Reversible, but the alternative is a
card that says a call succeeded because it cannot tell.
- **A group's cards do not each carry their own surface.** Compose gives
each card a fill and squares the corners where it faces a neighbour, so
a run reads as one object broken into parts. iris has no per-corner
radius, and -- more to the point -- a group built the way Compose builds
it hit a framework layout defect that drew every card's text a card
below its own box. So a group is one surface with its cards on it,
separated by a small gap, and the 4dp inset Compose holds them off the
edge by is gone. Worth revisiting once the layout defect is fixed
(docs/IRIS_TODO.md).
- **A long tool output is capped at 80 lines or 4 kB with a "Show all N
lines".** Compose draws the whole thing, and gets away with it because
its `Text` inside a `LazyColumn` lays out lazily; here the output is one
text widget and shaping a hundred kilobytes of it costs what the file
editor's 32 kB limit was measured against. If iris's text gets cheaper,
this is the number to move.
- **A card's command is clipped, not pannable, and its summary line is
clipped rather than ellipsised.** Both are framework gaps rather than
choices (`scrollable_on` on a non-editable text draws nothing; there is
no overflow ellipsis), and both are worse than Compose today. Named here
because they are visible.
## 2026-09-06 (how a markdown block looks, P1a)
- **A table is drawn as padded monospace columns, not as a grid.** Your
+45
View File
@@ -8,6 +8,51 @@ capability that moved. Small and trivial changes do not go here.
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-06: tool cards, `ToolState`, and a screen that knows whether its session is working
`transcript_ui::tool` is new: a card per tool call, a group per run
(P1b). Three things in the public surface follow from it.
**`client_core::transcript_fold::ToolState`** is what a card colours
itself by -- `Running`, `Deciding`, `Succeeded`, `Failed`, `NoResult` --
built by `ToolState::of(&item, session_working)`. The pair it exists for
is `Succeeded` against `NoResult`: a call that finished having printed
nothing and a call whose result never arrived both leave an empty
`output`, and drawing them the same way states a verdict nobody reached.
Only the session's own status separates them, which is why `of` takes it.
**`event_model::Event::ToolEnd` gained `is_error`** (`#[serde(default)]`,
so an older transcript still parses), and
`client_core::transcript_fold::TranscriptItem::ToolRun` gained `failed`.
Without them a result was everything a card knew and a broken call drew
exactly as confidently as one that worked -- the missing state, not a
wrong one. Every construction site of both had to gain a field; the value
comes from the CLI's own `tool_result`, read in one place
(`import::tool_result_is_error`) by both the live translator and the
import replay.
**`TranscriptScreen::set_session_working(rsc, bool)`** is new, and is the
only thing that writes it. Before: a card with no result was drawn the
same whether its turn was still going or had been interrupted. After:
only the *newest* row can say "running", because every row behind it
belongs to a turn that has ended, and changing the flag redraws that one
row rather than the screen. `TranscriptScreen::expand_tail_tools(rsc,
bool)` joins it, answering whether there was a tool run to act on -- a
group's expanded appearance is otherwise unreachable from anything that
cannot press the screen.
**`transcript_ui::row::build_row` now returns a `TailRow`** rather than an
`Option<RowBlocks>`: `Blocks` for a message (a delta costs the last
markdown block) or `Tools` for a run (an arriving result costs one card).
One mechanism for "what can this row change cheaply", asked of the row
rather than decided again at each call site. It also takes the row's own
`working` flag.
Two smaller ones. `client_core::tool_summary::parse_tool_input` is
`ToolInput.kt`'s subject/description/timeout/rest split, and
`client_core::durations::format_millis` is `Durations.kt`'s -- both pure,
both with the Kotlin's own tests ported.
## 2026-09-06: a tap is its own gesture outcome, and opening a URL is a backend capability
Three related additions, all for following a markdown link.
+43
View File
@@ -639,6 +639,49 @@ agent ticks it here with the evidence.
it is its own item, most likely the report pane not masking or not
claiming its region.
## Found by P1b (2026-09-06), all with a headless repro
Each was found by looking at `iris/run-headless.sh transcript -- -p
transcript-ui` rather than at a diff, and each is worked around in
`transcript-ui/src/tool.rs` rather than fixed here. docs/RUST.md's P1b box
has the fuller account.
- [ ] **A `Span` of `Pad`ded children inside another `Span` places those
children a slot out of step.** Each child drew its content one sibling's
height below its own box. Repro: `IRIS_TOOLS_EXPANDED=1
iris/run-headless.sh transcript --shot /tmp/x.png -- -p transcript-ui`
with `tool.rs`'s group built as `Span(DOWN)[header, Pad(Span(DOWN)
[cards]), bar]` instead of the single `Span` it uses now. Bisected:
removing the inner `Span` fixes it, and so does removing the children's
own `Pad`; the background `Stack`, the `Sized` wrappers and the
`WidgetPtr` per child make no difference. **Not** the `mov`-vs-
`reposition` fault f5b8893 fixed -- it survives that commit. The
workaround costs the group the 4dp inset its Compose counterpart holds
its cards off the edge by, so this is worth fixing.
- [ ] **`scrollable_on(Axis::X)` on a non-editable `Text` draws nothing.**
The panel is drawn and the text inside it is not. A markdown fence does
the same to a `TextEdit` and is fine, so it is the widget kind rather
than the chain. `tool.rs`'s `raw_block` is `masked()` only until this is
fixed, which means a long command is clipped rather than pannable.
- [ ] **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
nothing on screen says it was cut. Whichever end is cut has to be a
choice when this lands: a path is identified by its tail, a command by
its head.
- [ ] **A drawn chevron.** `Chevron.kt` draws its own strokes precisely
because a chevron from a font is a glyph a system font may not have --
and the bundled `NotoSans-Regular.ttf` indeed has no U+25B8/25BE/25B4,
while `NotoSansMono-Regular.ttf` does. `tool.rs` sets the mark in the
monospace face as a result. A real fix needs a line/path primitive;
iris has rects, text and textures only.
- [ ] **A tool card's text is not selectable.** `Selection` is keyed
`(RowKey, block index)` and a card has no markdown blocks, so nothing in
a card registers. Compose's `SelectionContainer` covers tool output,
which is the text people most want to copy. Needs a key for "the nth
text of this row" that a card can mint without colliding with a
message's blocks.
## Build (for the port)
Widgets `RUST.md`'s "The port, in order (decided 2026-09-05)" needs and
+107 -6
View File
@@ -5687,12 +5687,113 @@ device.
correctly, and an emulator bench run with assertions live and no
abort (`2438 frames over 147.7s, p50 27.2ms`).
- [ ] **P1b — tool-call cards and grouping.** `ToolRows.kt`/
`ToolInput.kt`'s cards: a collapsed row per call with name
and a one-line summary, expand to input and output, runs of
calls grouped (`adopt_run` already groups in `client-core`),
the busy/failed states, and the kilobyte outputs the fixture
carries without laying them out while collapsed.
- [x] **P1b — tool-call cards and grouping.** Done 2026-09-06.
`ToolRows.kt`/`ToolInput.kt` ported to
`iris/transcript-ui/src/tool.rs` plus two new pure modules in
`client-core`. **Screenshots:
`docs/bench/p1b-2026-09-06/iris-tools-collapsed.png` and
`iris-tools-expanded.png`**, both from
`iris/run-headless.sh transcript -- -p transcript-ui` on the
desktop/winit backend (the emulator was not touched this pass
-- another agent held this checkout's AVD). The expanded one
is taken with `IRIS_TOOLS_EXPANDED=1`, which the example reads
to call `TranscriptScreen::expand_tail_tools` -- the expanded
appearance is otherwise unreachable on a machine with no
display and no finger.
**What the cards look like, against `ToolRows.kt`:**
- *A collapsed card* -- a mark, the tool's name (14pt), the
one-line summary `parse_tool_input` derives (12pt, Subtext
0, one line, clipped), and the state word at the far right.
Same as Compose, except that Compose ellipsises the summary
and iris clips it: there is no overflow-ellipsis in
`TextAttrs` yet (IRIS_TODO).
- *An open card* -- the timeout at the top right, the tool's
own description, the subject in a `Verbatim` panel with
`client_core::highlight`'s spans, the leftover input fields
under it, then the output. Same order as Compose.
- *A group* -- "Called N tools" (Compose's exact wording, and
so the name a `ui-trace` script taps), the cards on a Mantle
surface, and a chevron bar at the foot that closes it from
the end the reader is looking at.
- *States* -- `client_core::transcript_fold::ToolState`, five
of them, each with its own word and colour: nothing for
`Succeeded`, "running" (Subtext 0), "your turn" (Peach, the
Compose card's own wording and colour), "failed" (Red) and
**"no result" (Yellow)**. The last two are new -- Compose
can say neither.
**Two things the port had to add to be able to say "it
broke".** `event_model::Event::ToolEnd` gained `is_error`
(`#[serde(default)]`), read from the CLI's own `tool_result`
by one function used by both the live translator and the
import replay (`import::tool_result_is_error`); without it a
result was all a card had and a failed call drew exactly as
confidently as one that worked. And `ToolState` separates
`Succeeded`-with-empty-output from `NoResult`: both leave the
same empty string, and only the session's own status tells
them apart, which is why `TranscriptScreen::
set_session_working` exists and why only the *newest* row can
be "running" (every row behind it belongs to a turn that has
ended).
**Pass condition, met**:
`collapsed_cards_shape_only_their_summary_lines`
(`transcript-ui/src/lib.rs`) opens a group of three cards
whose calls carry 88 kB of output each and asserts the
text-shape count equals the same group's over three bytes.
**17 either way.** Confirmed to be a real test, not a
tautology, by pushing the output block into the collapsed
branch: **17 against 20**.
`a_result_arriving_redraws_one_card_whatever_the_run_holds` is
the second: one `ToolEnd` costs the same number of
`Widget::draw` calls in a twelve-call run as in a three-call
one.
**Three defects found on the way, all by looking at the
render rather than at the diff:**
1. **A `Span` of `Pad`ded children inside another `Span`
places those children a slot out of step.** Every card drew
its content one card's height below its own box, so the
group read as empty bars with somebody else's summary in
them. Bisected against
`IRIS_TOOLS_EXPANDED=1 iris/run-headless.sh transcript`:
removing the inner `Span` fixes it, and so does removing
the cards' own `Pad`; the card background, the `Sized`
wrappers and the per-card `WidgetPtr` all make no
difference. Worked around by building the group as **one**
`Span` (header, cards, collapse bar), which costs the 4dp
inset Compose holds its cards off the group's edge by. The
framework defect is still open -- IRIS_TODO has it, and it
is not the `mov`/`reposition` one f5b8893 fixed (it
survives that commit).
2. **`scrollable_on(Axis::X)` on a non-editable `Text` draws
nothing at all** -- an empty panel where the command should
be. A markdown fence does the same thing to a `TextEdit`
and is fine. So a card's verbatim block is `masked()` and
clips rather than panning; when this is fixed the pan
belongs there too, because the long command is the one
being read closely.
3. **`NotoSans-Regular.ttf` has no U+25B8/25BE/25B4** (read
out of the bundled `cmap`s) while `NotoSansMono-Regular`
does, so the expander mark is set in the monospace face at
the one place the character is written. The old
`build_tools` summary drew that codepoint in the sans face,
which was a missing glyph nobody had looked closely enough
to see.
**Checks**: `cargo fmt --all --check` clean in both
workspaces; `cargo clippy -p iris -p iris-core -p
transcript-ui -p desktop-app -p tabs-ui --all-targets` and
`cargo clippy --all-targets` in `client-core`/`server`/
`event-model` warning-free; tests 86 (iris) + 13 (iris-core) +
36 (transcript-ui, +5) + 137 (client-core, +11) + 160
(server, +1).
**Not done**: nothing on the emulator or the phone (the AVD
was another agent's this pass, so no frame times were taken);
a card's text is not selectable, unlike Compose's, since
`Selection` is keyed per markdown block and a card has none
(IRIS_TODO); no per-corner radius, so the "connected stack"
shape `connectedShape` draws is a 2dp gap instead;
`AskUserQuestionBody`/`PermissionAsk`'s answer buttons are not
ported -- an unanswered ask forces its card open and says
"your turn", but there is nothing to press yet, which is P1d's
modal/controls work.
- [ ] **P1c — history paging and jump-to-latest.** Wire
`client-core::transcript_source` into `transcript-ui`:
the opening page, paging back on scroll with the cushion
Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 114 KiB