WIP: tool-call cards and grouping (P1b)

`transcript-ui::tool` draws a card per call and a group per run, with
the states, the collapsed-lays-out-nothing discipline and the
one-card-per-result update. Screenshots in docs/bench/p1b-2026-09-06/.

Includes a local fix to `List::place`'s reposition-vs-mov clash, which
is about to be dropped for rustify's own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-06 21:33:24 -04:00
1 parent 9079276ec8
commit a4809b3026
9 files changed
+1443 -188

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

+11
View File
@@ -438,6 +438,17 @@ impl UiRenderState {
/// repeating the same `reposition` (e.g. an unrelated redraw elsewhere
/// re-running this widget's parent without its own layout changing)
/// must land on the same answer, not drift further each time.
///
/// **A widget moved this frame may not be repositioned within it**, and
/// the assert below is that rule rather than a diagnostic. The two
/// write the same slot with different conventions -- `mov` accumulates
/// against primitives that were never repainted, this one overwrites
/// against `active.region` -- so the second silently discards the
/// first. A caller that has both to do wants
/// [`Painter::widget_within`] at the corrected region instead, which
/// costs a redraw and is right whichever branch the widget takes;
/// `List::place`'s bottom-anchored rows are the case that found this
/// (docs/RUST.md's P1a box recorded the repro and left it open).
pub(super) fn reposition(&mut self, id: WidgetId, to: UiRegion, rsc: &mut dyn UiRsc) {
let Some(active) = self.active.get(&id) else {
return;
+20 -4
View File
@@ -829,11 +829,27 @@ impl List {
// anchored at the *offered* box's leading edge
// (`bottom - h`, per every widget in this crate's
// top-left-anchoring convention), not where its true
// height means its bottom edge should be; correct with
// an O(1) reposition, `Aligned`'s own trick for this
// exact "learned a size after already drawing" case.
// height means its bottom edge should be.
//
// Offered again at the right box rather than
// `reposition`ed to it, which is what this did until
// 2026-09-06. `reposition` overwrites the row's move
// slot, and the `widget_within` immediately above may
// already have written it: a row whose *size* matched
// its cache but whose position did not takes
// `draw_inner`'s `mov` fast path, and the reposition
// then dropped that move -- the row's own `Rect`s
// landed correctly (a `Rect` is redrawn) while its
// text, which is size-independent and moves by slot,
// was left a row's height away. Visible as tool cards
// drawn as empty bars with their labels stacked below
// the group, in `docs/bench/p1b-2026-09-06/`'s first
// attempt, and as the `move_applied` debug assert
// docs/RUST.md's P1a box left open. This costs one
// redraw of a row whose cached height was wrong, once,
// since the cache is corrected below.
let corrected = Self::abs_region(axis, bottom - height, bottom);
painter.reposition(self.slot_widget(slot), corrected);
painter.widget_within(self.slot_widget(slot), corrected);
}
(bottom - height, bottom, height)
}
+131 -34
View File
@@ -13,7 +13,8 @@
//! with `ui-trace record --do "tap 'Tools'"` on Android, to prove
//! hold-the-edge expand).
use client_core::transcript_fold::{TranscriptItem, TranscriptRow as FoldedRow};
use client_core::QuestionOption;
use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
use iris::prelude::*;
fn main() {
@@ -43,6 +44,60 @@ fn msg(seq: u64, from_user: bool, text: &str) -> FoldedRow {
})
}
/// One tool call. `result` is `None` for a call with no result yet and
/// `Some((output, failed))` for one that answered.
fn tool_call(id: &str, tool: &str, input: &str, result: Option<(&str, bool)>) -> TranscriptItem {
TranscriptItem::ToolRun {
seq: 3,
id: id.into(),
run_id: "run1".into(),
tool: tool.into(),
input: input.into(),
output: result.map(|(out, _)| out.to_string()).unwrap_or_default(),
done: result.is_some(),
failed: result.is_some_and(|(_, failed)| failed),
asks: Vec::new(),
images: Vec::new(),
}
}
/// A call stopped on the reader: one unanswered permission question.
fn asking(id: &str, tool: &str, input: &str) -> TranscriptItem {
let mut call = tool_call(id, tool, input, None);
if let TranscriptItem::ToolRun { asks, .. } = &mut call {
asks.push(QuestionCard {
seq: 9,
id: format!("{id}-q"),
prompt: "Allow this command?".into(),
header: None,
options: vec![
QuestionOption {
label: "Allow".into(),
description: None,
preview: None,
},
QuestionOption {
label: "Deny".into(),
description: None,
preview: None,
},
],
multi_select: false,
answers: Vec::new(),
});
}
call
}
/// Longer than the card's own cap, so the "Show all N lines" control is on
/// screen in the expanded shot.
fn long_output() -> String {
(0..200)
.map(|i| format!("test transcript_ui::case_{i} ... ok"))
.collect::<Vec<_>>()
.join("\n")
}
fn synthetic_rows() -> Vec<FoldedRow> {
vec![
msg(
@@ -55,41 +110,39 @@ fn synthetic_rows() -> Vec<FoldedRow> {
false,
"# Sure\n\nHere's a [link to the repo](https://example.com/ai-app-2) and a fenced block:\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```",
),
// Every state a tool card has to draw, in one run (P1b): a call
// that worked, one the tool reported as failed, one whose result
// never arrived, and one still running. The last two look the same
// in the events -- an empty output and `done: false` -- and are
// told apart only by whether the session is still working, which
// is what `TranscriptScreen::set_session_working` says.
FoldedRow::Tools(vec![
TranscriptItem::ToolRun {
seq: 3,
id: "t1".into(),
run_id: "run1".into(),
tool: "Read".into(),
input: "{\"file\": \"src/main.rs\"}".into(),
output: "fn main() {}\n".into(),
done: true,
asks: Vec::new(),
images: Vec::new(),
},
TranscriptItem::ToolRun {
seq: 4,
id: "t2".into(),
run_id: "run1".into(),
tool: "Edit".into(),
input: "{\"file\": \"src/main.rs\"}".into(),
output: "ok".into(),
done: true,
asks: Vec::new(),
images: Vec::new(),
},
TranscriptItem::ToolRun {
seq: 5,
id: "t3".into(),
run_id: "run1".into(),
tool: "Bash".into(),
input: "cargo build".into(),
output: "Compiling...\nFinished.".into(),
done: true,
asks: Vec::new(),
images: Vec::new(),
},
tool_call(
"t1",
"Read",
r#"{"file_path": "src/main.rs"}"#,
Some(("fn main() {}\n", false)),
),
tool_call(
"t2",
"Bash",
r#"{"command": "cargo build --release", "timeout": 480000, "description": "Build it"}"#,
Some((
"error: could not compile `iris`\nCaused by: linker not found",
true,
)),
),
tool_call("t3", "Grep", r#"{"pattern": "fn fold_event"}"#, None),
]),
// A lone call is a card too rather than a group of one -- and this
// one carries the kilobyte output a collapsed card must not lay
// out.
FoldedRow::Single(tool_call(
"t5",
"Bash",
r#"{"command": "cargo test -p transcript-ui -- --nocapture"}"#,
Some((&long_output(), false)),
)),
msg(6, true, "Looks good, thanks!"),
// Every block kind `client_core::markdown_blocks` names, in one
// row, so P1a's appearance can be looked at against the Compose
@@ -151,6 +204,50 @@ impl DefaultAppState for Client {
text: "clear".into(),
}),
);
// A second run at the live end, so the *running* state is on
// screen too. It cannot share a row with "no result": the two are
// the same events and are told apart only by whether the session
// is working, which is a property of the row rather than of the
// call (`TranscriptScreen::set_session_working`).
screen.push_row(
rsc,
&FoldedRow::Tools(vec![
tool_call(
"t6",
"Read",
r#"{"file_path": "docs/RUST.md"}"#,
Some(("# Moving the app to Rust\n", false)),
),
tool_call(
"t7",
"Bash",
r#"{"command": "cargo clippy --workspace --all-targets"}"#,
Some(("error: unused variable `x`", true)),
),
tool_call("t8", "Glob", r#"{"pattern": "**/*.rs"}"#, None),
// Waiting on a permission, so this card is drawn *open*
// whatever the reader last chose -- the command is the
// thing being decided, and a row saying only "Bash"
// cannot be decided on. It is also how the expanded card
// (input block, output block, timeout) gets into the
// screenshot without a finger.
asking(
"t9",
"Bash",
r#"{"command": "rm -rf target", "timeout": 120000, "description": "Clear the build"}"#,
),
]),
);
screen.set_session_working(rsc, true);
// The expanded picture has no other way to be looked at on a
// machine with no display and no finger -- see `run-headless.sh`
// and docs/RUST.md's P1b box.
if std::env::var_os("IRIS_TOOLS_EXPANDED").is_some() {
assert!(
screen.expand_tail_tools(rsc, true),
"the newest row must be the tool run this flag is about"
);
}
Self { ui_state, screen }
}
}
+360 -33
View File
@@ -47,6 +47,7 @@ pub mod composer;
pub mod markdown;
pub mod row;
pub mod selection;
pub mod tool;
use client_core::transcript_fold::TranscriptRow as FoldedRow;
use iris::prelude::*;
@@ -66,14 +67,17 @@ pub struct TranscriptScreen {
/// interior mutability, per `push_row`'s existing `&self`). Drained by
/// [`Self::take_rebuilds`].
rebuilds: std::cell::Cell<usize>,
/// The per-block widgets of the row at the live end of the list --
/// the only row a streamed delta ever lands in -- so
/// [`Self::apply`]'s `ReplaceLast` can replace one markdown block
/// instead of rebuilding the message
/// (`row::RowBlocks::apply_delta`). `None` for a tail that has no
/// delta path (a tool run) or before anything has been pushed. Its
/// removal is every path that replaces or drops the tail row, below.
tail: RefCell<Option<(RowKey, row::RowBlocks)>>,
/// What the row at the live end of the list kept so the next event
/// can change part of it rather than all of it -- one markdown block
/// of a streaming message (`row::RowBlocks::apply_delta`), or one card
/// of a tool run whose result just arrived (`tool::ToolRow::
/// apply_calls`). `None` before anything has been pushed. Its removal
/// is every path that replaces or drops the tail row, below.
tail: RefCell<Option<(RowKey, row::TailRow)>>,
/// Whether the session is still working -- see
/// [`Self::set_session_working`], which is the only thing that writes
/// it. `Cell`, like `rebuilds`, so every method here stays `&self`.
session_working: std::cell::Cell<bool>,
}
impl TranscriptScreen {
@@ -85,39 +89,114 @@ impl TranscriptScreen {
where
Rsc::State: FocusHost + OpenUrl,
{
let (key, widget, blocks) = row::build_row(rsc, self.list, self.selection.clone(), row);
let (key, widget, tail) = row::build_row(
rsc,
self.list,
self.selection.clone(),
row,
self.session_working.get(),
);
(self.list)(rsc).push_back(ListRow::new(key, widget));
*self.tail.borrow_mut() = blocks.map(|b| (key, b));
*self.tail.borrow_mut() = tail.map(|t| (key, t));
}
/// The `ReplaceLast` fast path: update the tail row's blocks in place
/// if this really is a delta into the same message, and say whether
/// that worked. `false` for anything the caller must rebuild instead
/// -- a tail with no block state (a tool run), a row that is not a
/// `Single`, or a change `RowBlocks::apply_delta` will not take.
/// Whether the session this transcript belongs to is still doing
/// something (`client_core::transcript_fold::session_working`).
///
/// The one thing a tool card cannot read off its own call: a call with
/// no result is *running* while the session works and *never came
/// back* once it stops, and those are different things to tell a
/// reader. Only the newest row is affected -- every row behind it
/// belongs to a turn that has already ended -- so changing it re-draws
/// that row and nothing else.
pub fn set_session_working<Rsc: HasEvents>(&self, rsc: &mut Rsc, working: bool)
where
Rsc::State: FocusHost + OpenUrl,
{
if self.session_working.replace(working) == working {
return;
}
let mut tail = self.tail.borrow_mut();
if let Some((_, row::TailRow::Tools(tools))) = tail.as_mut() {
let calls = tools.calls();
tools.apply_calls(rsc, &calls, working);
}
}
/// How many tool cards the newest row is drawing, `0` when it is not a
/// tool row or its group is closed. Only the tests read it; nothing on
/// screen is decided by it.
#[cfg(test)]
fn tail_card_count(&self) -> usize {
match self.tail.borrow().as_ref() {
Some((_, row::TailRow::Tools(tools))) => tools.card_count(),
_ => 0,
}
}
/// Open or close the newest row's tool run, when it is one -- what a
/// caller with no finger needs (`run-headless.sh`'s screenshot on this
/// displayless machine, and the tests below). Answers whether there
/// was such a row to act on, so a caller that expected one can say so
/// rather than silently producing the collapsed picture.
pub fn expand_tail_tools<Rsc: HasEvents>(&self, rsc: &mut Rsc, expanded: bool) -> bool
where
Rsc::State: FocusHost + OpenUrl,
{
let tail = self.tail.borrow();
let Some((_, row::TailRow::Tools(tools))) = tail.as_ref() else {
return false;
};
tools.set_group_expanded(rsc, expanded);
true
}
/// The `ReplaceLast` fast path: update the tail row in place if this
/// really is a change to the same row, and say whether that worked.
/// `false` for anything the caller must rebuild instead.
///
/// Two kinds of row have such a path and they are asked the same
/// question: a message's blocks take a delta into the last block, and
/// a tool row's cards take an arriving result on one card. Which one
/// this is comes from what the row kept, not from a second decision
/// here.
fn apply_tail_delta<Rsc: HasEvents>(&self, rsc: &mut Rsc, key: RowKey, row: &FoldedRow) -> bool
where
Rsc::State: FocusHost + OpenUrl,
{
let FoldedRow::Single(item) = row else {
return false;
};
let mut tail = self.tail.borrow_mut();
let Some((tail_key, blocks)) = tail.as_mut() else {
let Some((tail_key, kept)) = tail.as_mut() else {
return false;
};
if *tail_key != key {
return false;
}
let (sender, markdown_src) = row::item_content(item);
blocks.apply_delta(
rsc,
self.list,
self.selection.clone(),
key,
sender,
&markdown_src,
)
match (kept, row) {
(row::TailRow::Blocks(blocks), FoldedRow::Single(item)) => {
let (sender, markdown_src) = row::item_content(item);
// A tool call is drawn as a card, never as markdown, so a
// row that kept blocks and now holds one is a different
// row -- rebuild it.
if matches!(item, client_core::transcript_fold::TranscriptItem::ToolRun { .. }) {
return false;
}
blocks.apply_delta(
rsc,
self.list,
self.selection.clone(),
key,
sender,
&markdown_src,
)
}
(row::TailRow::Tools(tools), FoldedRow::Tools(calls)) => {
tools.apply_calls(rsc, calls, self.session_working.get())
}
(row::TailRow::Tools(tools), FoldedRow::Single(item)) => {
tools.apply_calls(rsc, std::slice::from_ref(item), self.session_working.get())
}
(row::TailRow::Blocks(_), FoldedRow::Tools(_)) => false,
}
}
/// Apply the effect of one more folded event without rebuilding the
@@ -196,11 +275,16 @@ impl TranscriptScreen {
// pointing at widgets the `drop` below frees (the shape
// docs/REVIEW-2026-09-06.md's finding 1 called out).
self.selection.borrow_mut().unregister(old_key);
let (new_key, widget, blocks) =
row::build_row(rsc, self.list, self.selection.clone(), &new_rows[common]);
let (new_key, widget, kept) = row::build_row(
rsc,
self.list,
self.selection.clone(),
&new_rows[common],
self.session_working.get(),
);
let evicted = (self.list)(rsc).replace_back(ListRow::new(new_key, widget));
drop(evicted); // frees the old row's widget, same as a pop would
*self.tail.borrow_mut() = blocks.map(|b| (new_key, b));
*self.tail.borrow_mut() = kept.map(|t| (new_key, t));
for row in &new_rows[common + 1..] {
self.push_row(rsc, row);
}
@@ -279,9 +363,13 @@ where
// say so.
let mut tail = None;
for row in &rows {
let (key, widget, blocks) = row::build_row(rsc, list, selection.clone(), row);
// `false`: a row built here is history until the caller says the
// session is working (`TranscriptScreen::set_session_working`),
// and claiming a call is running because the screen happens to be
// opening is exactly the inferred-as-measured mistake.
let (key, widget, kept) = row::build_row(rsc, list, selection.clone(), row, false);
list(rsc).push_back(ListRow::new(key, widget));
tail = blocks.map(|b| (key, b));
tail = kept.map(|t| (key, t));
}
// Wheel/trackpad scrolling -- the same idiom `trait_fns.rs`'s
@@ -339,6 +427,7 @@ where
(
TranscriptScreen {
tail: RefCell::new(tail),
session_working: std::cell::Cell::new(false),
list,
composer,
selection,
@@ -421,6 +510,7 @@ mod diff_tests {
input: "x".to_string(),
output: String::new(),
done: false,
failed: false,
asks: Vec::new(),
images: Vec::new(),
}
@@ -577,6 +667,7 @@ mod apply_tests {
input: "x".to_string(),
output: String::new(),
done: false,
failed: false,
asks: Vec::new(),
images: Vec::new(),
}
@@ -765,4 +856,240 @@ mod apply_tests {
Vec2::new(10.0, 10.0),
);
}
/// A tool call with `output` bytes of output, `done` or not.
fn call(id: &str, output: &str, done: bool) -> TranscriptItem {
TranscriptItem::ToolRun {
seq: 1,
id: id.to_string(),
run_id: "run".to_string(),
tool: "Bash".to_string(),
input: format!(r#"{{"command":"grep -rn {id} ."}}"#),
output: output.to_string(),
done,
failed: false,
asks: Vec::new(),
images: Vec::new(),
}
}
fn run_of(count: usize, output: &str, done: bool) -> Vec<TranscriptItem> {
(0..count)
.map(|i| call(&format!("t{i}"), output, done))
.collect()
}
/// A screen holding one tool run, with the group opened the way a tap
/// opens it, plus the counters drained -- so what a caller measures
/// next is only what it asked for.
fn open_run(
rsc: &mut TestRsc,
items: &[TranscriptItem],
) -> (TranscriptScreen, StrongWidget, UiRenderState) {
let (screen, tree) = build_tree(
rsc,
client_core::transcript_fold::group_tool_runs(items),
);
let mut render = UiRenderState::new();
render.resize((1080.0, 20000.0));
render.update(&tree, rsc);
assert!(
screen.expand_tail_tools(rsc, true),
"the fixture's only row must be the tool run"
);
render.update(&tree, rsc);
render.take_counters();
(screen, tree, render)
}
/// The text shapes it costs to *open* a group of three cards whose
/// calls carry `output` -- the cards themselves, since the collapsed
/// group before the expansion drew none.
fn shapes_to_open(output: &str) -> u64 {
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let items = run_of(3, output, 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);
render.take_counters();
assert!(
screen.expand_tail_tools(&mut rsc, true),
"the fixture's only row must be the tool run"
);
render.update(&tree, &mut rsc);
let (_, _, _, shapes) = render.take_counters();
shapes
}
/// **The O(last block) discipline, for tool cards** (RUST.md's P1b).
/// A collapsed card draws its summary line and nothing else, so the
/// kilobyte outputs the bench fixture carries cost nothing until
/// somebody opens one. Counted in *text shapes*, the number a draw
/// counter cannot stand in for: the widgets are the same either way,
/// and it is parley's work that would grow with the output.
///
/// The group is *opened* here, so all three cards are really drawn --
/// the cheap version of this test (a closed group, which draws no
/// cards at all) would pass without saying anything about a card.
#[test]
fn collapsed_cards_shape_only_their_summary_lines() {
let long: String = std::iter::repeat_n("a line of tool output\n", 4_000).collect();
assert!(long.len() > 80_000, "the long case must actually be long");
let short_shapes = shapes_to_open("ok\n");
let long_shapes = shapes_to_open(&long);
assert!(
short_shapes > 0,
"opening a group must shape something, or this compares two zeroes"
);
assert_eq!(
short_shapes, long_shapes,
"three collapsed cards shaped {long_shapes} text layouts over 80 kB of output \
against {short_shapes} over three bytes -- a collapsed card is laying out \
something it does not draw"
);
}
/// What one arriving result costs, in `Widget::draw` calls, in a run of
/// `count` calls -- with the group open, so every card is really on
/// screen and a rebuild of the wrong scope would show.
fn cost_of_one_result(count: usize) -> u64 {
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let before = run_of(count, "", false);
let mut after = before.clone();
after[0] = call("t0", "the result", true);
let (screen, tree, mut render) = open_run(&mut rsc, &before);
screen.apply(&mut rsc, &before, &after);
render.update(&tree, &mut rsc);
assert_eq!(
screen.take_rebuilds(),
0,
"a result arriving must not rebuild the whole screen"
);
let (draws, _, _, _) = render.take_counters();
draws
}
/// **A result changes one card**, whatever else is in the run --
/// `RowBlocks::apply_delta`'s discipline applied to a group, which is
/// a column of cards (`tool::ToolRow::apply_calls`). Stated as a
/// comparison rather than a number, because the number is whatever a
/// card happens to be made of and would have to be edited every time
/// the card gains a widget; what must not change is that it does not
/// grow with the run.
#[test]
fn a_result_arriving_redraws_one_card_whatever_the_run_holds() {
let small = cost_of_one_result(3);
let large = cost_of_one_result(12);
assert!(
small > 0,
"a result must redraw *something*, or this compares two zeroes"
);
assert_eq!(
small, large,
"one result redrew {large} widgets in a twelve-call run against {small} in a \
three-call one -- the other cards are being rebuilt with it"
);
}
/// The group's own state: opening it draws the cards, closing it takes
/// them away again, and the reader's choice survives a result arriving
/// in the middle of it.
#[test]
fn a_group_opens_and_closes_and_keeps_its_state_across_a_result() {
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let before = run_of(3, "", false);
let mut after = before.clone();
after[1] = call("t1", "done", true);
let (screen, tree) = build_tree(
&mut rsc,
client_core::transcript_fold::group_tool_runs(&before),
);
let mut render = UiRenderState::new();
render.resize((1080.0, 20000.0));
render.update(&tree, &mut rsc);
// Closed, a group is one line: no card is registered at all, which
// is what makes the kilobyte outputs free.
assert_eq!(screen.tail_card_count(), 0);
assert!(screen.expand_tail_tools(&mut rsc, true));
assert_eq!(screen.tail_card_count(), 3);
// A result arriving must not close what the reader opened -- the
// card is rebuilt, and being open is the reader's state rather
// than the event's.
screen.apply(&mut rsc, &before, &after);
render.update(&tree, &mut rsc);
assert_eq!(screen.take_rebuilds(), 0);
assert_eq!(screen.tail_card_count(), 3, "the group closed under a result");
assert!(screen.expand_tail_tools(&mut rsc, false));
assert_eq!(screen.tail_card_count(), 0);
}
/// A call that joins a run while it is the live row appends one card
/// rather than rebuilding the row -- the other half of `apply_calls`,
/// and the case a page join does *not* produce (that one goes through
/// `Rebuild`).
#[test]
fn a_call_joining_an_open_run_appends_one_card() {
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let before = run_of(2, "ok", true);
let mut after = before.clone();
after.push(call("t2", "", false));
let (screen, tree, mut render) = open_run(&mut rsc, &before);
assert_eq!(screen.tail_card_count(), 2);
screen.apply(&mut rsc, &before, &after);
render.update(&tree, &mut rsc);
assert_eq!(screen.take_rebuilds(), 0, "an appended call is not a rebuild");
assert_eq!(screen.tail_card_count(), 3);
}
/// A tool row that becomes something else is a different row, not a
/// changed one. Without the guard in `apply_calls` a `UserMsg` would
/// reach the card builder, whose `debug_assert` is the last line of
/// defence rather than the first.
#[test]
fn a_tail_that_stops_being_tool_calls_falls_back_to_a_rebuild() {
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let before = vec![user(1, "stable"), call("t0", "", false)];
let after = vec![user(1, "stable"), user(2, "not a tool call at all")];
let (screen, _tree) = build_tree(
&mut rsc,
client_core::transcript_fold::group_tool_runs(&before),
);
screen.apply(&mut rsc, &before, &after);
assert_eq!(
screen.take_rebuilds(),
0,
"this is a ReplaceLast, not a whole-screen rebuild"
);
// The row that replaced it is a message, so it keeps blocks rather
// than cards -- and nothing panicked on the way.
assert_eq!(screen.tail_card_count(), 0);
}
}
+1 -1
View File
@@ -399,7 +399,7 @@ fn options() -> Options {
/// (`highlight`'s module doc), so the offsets are walked once rather than
/// converted per span -- a fence is scanned on every delta that lands in
/// it, and it is the only block a delta re-renders.
fn highlight_into(spans: &mut Vec<SpanStyle>, text: &str, range: Range<usize>, language: Language) {
pub(crate) fn highlight_into(spans: &mut Vec<SpanStyle>, text: &str, range: Range<usize>, language: Language) {
let code = &text[range.clone()];
// char index -> byte offset within `code`, plus the end, so a span's
// `end` is always in range.
+44 -116
View File
@@ -25,6 +25,7 @@
use crate::markdown::{BlockFrame, Link, frame_of, render_block};
use crate::selection::{SelKey, Selection};
use crate::tool::ToolRow;
use client_core::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks};
use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
use iris::prelude::*;
@@ -461,105 +462,20 @@ where
build_text_row(rsc, list, selection, key, sender, &markdown_src)
}
/// A run of adjacent tool calls: collapsed to a one-line summary by
/// default, expanding in place to every call's own tool/input/output on
/// tap -- see the module doc for the hold-the-edge contract this wires
/// against `list`.
fn build_tools<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<List>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
calls: Vec<TranscriptItem>,
) -> StrongWidget
where
Rsc::State: FocusHost + OpenUrl,
{
let expanded = Rc::new(RefCell::new(false));
// `.add_strong` (not `.add`) because nothing else in the tree holds a
// strong reference to this `WidgetPtr` the way a container's own
// `add_strong`-on-its-children does for an ordinary child -- this row
// *is* the top of its own subtree, so it has to own itself.
let ptr_strong = WidgetPtr::new().add_strong(rsc);
let ptr = ptr_strong.weak();
let summary_text = format!("\u{25b8} {} tool calls", calls.len());
let full_text = calls
.iter()
.map(|c| match c {
TranscriptItem::ToolRun {
tool,
input,
output,
..
} => tool_call_markdown(tool, input, output),
other => item_content(other).1,
})
.collect::<Vec<_>>()
.join("\n\n");
fn build_content<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<List>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
expanded: bool,
summary: &str,
full: &str,
) -> StrongWidget
where
Rsc::State: FocusHost + OpenUrl,
{
// Every block of the previous content goes first: collapsing a
// five-block expansion back to a one-line summary registers only
// `(key, 0)`, and blocks 1..5 would be left in `Selection`
// pointing at widgets `ptr.replace` is about to free -- the same
// class of bug docs/REVIEW-2026-09-06.md's finding 1 found in the
// `Rebuild` arm, reached the other way.
selection.borrow_mut().unregister(key);
let text = if expanded { full } else { summary };
build_text_row(rsc, list, selection, key, Some("Tools"), text).0
}
let content = build_content(
rsc,
list,
selection.clone(),
key,
false,
&summary_text,
&full_text,
);
ptr(rsc).set(content);
ptr.on(CursorSense::click(), move |ctx, rsc| {
// `List::note_tap` wants a viewport-relative position, but the
// click event only knows where inside *this row* it landed
// (`ctx.data.pos`) -- `List::extent` (last frame's on-screen box
// for this row's key) is what turns the two into the position
// `list.rs`'s hold-the-edge layout pass resolves against, per the
// module doc's contract.
let (top, _bottom) = list(rsc).extent(key).unwrap_or((0.0, 0.0));
list(rsc).note_tap(top + ctx.data.pos.y);
let was_expanded = *expanded.borrow();
*expanded.borrow_mut() = !was_expanded;
let content = build_content(
rsc,
list,
selection.clone(),
key,
!was_expanded,
&summary_text,
&full_text,
);
// The old content's `StrongWidget` is freed when this drops --
// the removal half of the row this click just replaced.
let _old = ptr(rsc).replace(content);
})
.add(rsc);
ptr_strong.any()
/// What a row keeps so the next event can change part of it instead of
/// all of it -- one variant per kind of row that has such a path.
///
/// Two mechanisms would have been two answers to the same question ("what
/// can this row do cheaply?"), so the caller holds one of these for its
/// tail row and asks it, rather than holding a `RowBlocks` and a
/// `ToolRow` and choosing between them at each call site.
pub enum TailRow {
/// A message: a column of one text widget per markdown block, so a
/// streamed delta costs the last block.
Blocks(RowBlocks),
/// A tool call or a run of them: a column of cards, so an arriving
/// result costs one card.
Tools(ToolRow),
}
pub fn build_row<Rsc: HasEvents>(
@@ -567,26 +483,38 @@ pub fn build_row<Rsc: HasEvents>(
list: WeakWidget<List>,
selection: Rc<RefCell<Selection>>,
row: &FoldedRow,
) -> (RowKey, StrongWidget, Option<RowBlocks>)
working: bool,
) -> (RowKey, StrongWidget, Option<TailRow>)
where
Rsc::State: FocusHost + OpenUrl,
{
match row {
FoldedRow::Single(item) => {
let key = row_key(&item.key());
let (widget, blocks) = build_single(rsc, list, selection, key, item);
(key, widget, Some(blocks))
}
FoldedRow::Tools(calls) => {
let key = row_key(&calls[0].key());
// `None`: a run of tool calls is never what a reply streams
// into, and its own expand/collapse replaces the whole
// content anyway, so there is no delta path to keep state for.
(
key,
build_tools(rsc, list, selection, key, calls.clone()),
None,
)
// A lone tool call is a card too, not a message with markdown in it:
// `group_tool_runs` leaves one call as a `Single` because "Called 1
// tool" hides a card to say the same thing in more words, and the
// *card* is what both cases draw (`ToolRows.kt`).
let calls = match row {
FoldedRow::Single(item @ TranscriptItem::ToolRun { .. }) => {
Some(std::slice::from_ref(item))
}
FoldedRow::Tools(calls) => Some(calls.as_slice()),
FoldedRow::Single(_) => None,
};
if let Some(calls) = calls {
let key = row_key(&calls[0].key());
let (widget, tools) = crate::tool::build_tool_row(
rsc,
list,
selection,
key,
calls.to_vec(),
working,
);
return (key, widget, Some(TailRow::Tools(tools)));
}
let FoldedRow::Single(item) = row else {
unreachable!("every Tools row took the branch above");
};
let key = row_key(&item.key());
let (widget, blocks) = build_single(rsc, list, selection, key, item);
(key, widget, Some(TailRow::Blocks(blocks)))
}
+876
View File
@@ -0,0 +1,876 @@
//! Tool-call cards and the runs they are grouped into -- the port of
//! `ToolRows.kt`/`ToolInput.kt` (RUST.md's P1b).
//!
//! One card per call. Closed, it is a single line: the tool's name and
//! what the call is for ([`client_core::tool_summary::parse_tool_input`]'s
//! `title`). The command itself is not on it, because a wrapped command
//! turns one row into four and a run of them into a wall. Open, it shows
//! the description, the input and the output.
//!
//! **A collapsed card lays out its summary line and nothing else.** Not an
//! optimisation -- the discipline this crate is built to. The bench
//! fixture carries tool outputs of tens of kilobytes, and a collapsed card
//! that built a text widget for one would pay parley for text nobody can
//! see. `collapsed_cards_shape_only_their_summary_lines` in `lib.rs` holds
//! it, counting `UiRenderState`'s text-shape counter the same way
//! `a_delta_into_a_long_reply_...` counts it for a streamed delta.
//!
//! **Two or more adjacent calls are one group** -- decided in
//! `client_core::transcript_fold::group_tool_runs`/`adopt_run` and never
//! re-derived here. A group is a header, a column of cards on its own
//! surface, and a bar at its foot: it closes from either end, because a
//! long group's header scrolls off while its last call is still on screen,
//! and the reader who wants it shut is looking at the bottom.
//!
//! **A result arriving replaces one card.** [`ToolRow::apply_calls`] is
//! the group's half of `RowBlocks::apply_delta`'s discipline: a group is a
//! column of cards, and a `ToolEnd` changes exactly one of them.
//!
//! **Every tap here is a tap** -- `GestureOutcome::Tapped` out of the one
//! `DragArbiter` `Selection` already owns, never a second detector. A
//! finger that panned the list past a card must not also open it; that
//! rule is written once, in the gesture machine, and this file only reads
//! its answer.
use crate::markdown::{TEXT_COLOR, VERBATIM_BACKGROUND, highlight_into};
use crate::selection::Selection;
use client_core::tool_summary::{ToolInput, parse_tool_input};
use client_core::transcript_fold::{ToolState, TranscriptItem};
use iris::prelude::*;
use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc, time::Instant};
/// A card's own fill: Surface 0, what Material's filled `Card` resolves to
/// under `Theme.kt`'s scheme. One step *above* the page, so a card reads
/// as an object on it.
const CARD_FILL: UiColor = UiColor::new(0x31, 0x32, 0x44, 255);
/// The surface a group's cards sit on: Mantle, one step *below* the page.
/// That surface is the single cue saying these calls belong together, and
/// it goes below rather than above because the cards are already above --
/// two steps in the same direction render as one flat block.
const GROUP_FILL: UiColor = UiColor::new(0x18, 0x18, 0x25, 255);
/// A tool's name, and any of the call's own words.
const NAME_COLOR: UiColor = TEXT_COLOR;
/// The summary line and the leftover input fields: Subtext 0, the Compose
/// app's `onSurfaceVariant` -- structure about the call rather than the
/// call's own words.
const MUTED_COLOR: UiColor = UiColor::new(0xA6, 0xAD, 0xC8, 255);
/// Waiting on a person -- Peach, `Theme.kt`'s `awaitingColor`. The same
/// colour a question card takes, because it is the same fact.
const AWAITING_COLOR: UiColor = UiColor::new(0xFA, 0xB3, 0x87, 255);
/// The call itself failed -- Red, the scheme's `error`/`failedColor`.
const FAILED_COLOR: UiColor = UiColor::new(0xF3, 0x8B, 0xA8, 255);
/// **Nobody found out** -- Yellow, `Theme.kt`'s `warningColor`. Its own
/// colour *and* its own word: the expensive confusion is between this and
/// a call that finished having printed nothing, those two share an empty
/// output, and a difference in kind cannot be carried by colour alone.
const UNKNOWN_COLOR: UiColor = UiColor::new(0xF9, 0xE2, 0xAF, 255);
/// A tool's name (Material `titleSmall`).
const NAME_SIZE: f32 = 14.0;
/// The summary line, and the input and output text (`bodySmall`).
const BODY_SIZE: f32 = 12.0;
/// The state word, the "Output" heading and the group's own count
/// (`labelSmall`).
const LABEL_SIZE: f32 = 11.0;
/// The room inside a card, and so the height a bar of one line of text
/// comes to (`ToolRows.kt`'s `GROUP_INSET_LARGE`).
const CARD_PAD_DP: f32 = 12.0;
/// How far the stack of calls is held off the edge of the surface it sits
/// on -- the container's own padding, not an indent.
const GROUP_INSET_DP: f32 = 4.0;
/// Enough to read the join as a join rather than as one tall card.
const GROUP_GAP_DP: f32 = 2.0;
/// A card's corner: `shapes.medium`, the same as every other card in the
/// app.
const CARD_RADIUS_DP: f32 = 12.0;
/// The gap between the parts of a card's header line, and between the
/// stacked parts of an open card.
const GAP_DP: f32 = 8.0;
/// Smaller than a card's radius, and deliberately: a verbatim block sits
/// *inside* one, and a rounded rectangle drawn at the same radius as the
/// one behind it reads as a misprint (`RawBlock.kt`).
const RAW_RADIUS_DP: f32 = 4.0;
/// The room inside a verbatim block.
const RAW_PAD_DP: f32 = 8.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;
/// The mark that says a card opens, always drawn from the **monospace**
/// face.
///
/// Not a style choice: `NotoSans-Regular.ttf`, which every other string
/// here is set in, has no glyph at U+25B8/U+25BE/U+25B4 at all, while
/// `NotoSansMono-Regular.ttf` does -- read out of both bundled `cmap`s on
/// 2026-09-06. A missing glyph is the failure nobody who wrote the code
/// ever sees, so the face that has the glyph is named at the one place the
/// character is written. IRIS_TODO's "a drawn chevron" has the real fix,
/// which needs a line primitive iris does not have.
const CLOSED_MARK: &str = "\u{25b8}";
const OPEN_MARK: &str = "\u{25be}";
const UP_MARK: &str = "\u{25b4}";
/// Which cards the reader has opened, and which have had their whole
/// output asked for.
///
/// Outside the widget tree on purpose: a card is rebuilt when its result
/// arrives, and being open is the reader's state rather than the event's
/// -- held in the widget, it would silently close the moment the tool
/// answered. Keyed by the call's own id, which survives a regroup. Its
/// path out is [`ToolRow::apply_calls`], which drops the entry for any
/// call no longer in the row.
#[derive(Default)]
struct ToolRowState {
group_expanded: bool,
open: HashMap<String, bool>,
whole_output: HashMap<String, bool>,
}
/// Everything a handler needs to redraw part of this row, in one `Rc` so
/// that a handler registered once keeps working against calls that arrive
/// later. The rebuild functions read `calls` fresh rather than capturing a
/// call, which is what lets [`ToolRow::apply_calls`] replace a card's
/// content without re-registering its gesture.
struct Shared {
calls: RefCell<Vec<TranscriptItem>>,
state: RefCell<ToolRowState>,
/// One `WidgetPtr` per call, in order -- what makes a result cost one
/// card. Empty while the group is collapsed, because a collapsed group
/// draws no cards at all. Its path out is [`build_content`], which
/// clears it before building whatever replaces them.
cards: RefCell<Vec<WeakWidget<WidgetPtr>>>,
/// The column those cards sit in, so a call appended to a run can be
/// pushed into it. `None` unless the group is open.
column: RefCell<Option<WeakWidget<Span>>>,
/// The whole row's content, swapped when the group opens or closes.
/// Filled in immediately after construction -- the `WidgetPtr` cannot
/// exist before the `Rc` every handler inside it captures.
content: RefCell<Option<WeakWidget<WidgetPtr>>>,
list: WeakWidget<List>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
/// Whether a call in this row could still be running -- the caller's
/// `session_working`, and `false` for every row behind the newest,
/// whose turn has already ended. The one input to [`ToolState`] that
/// is not a property of the call itself, and what separates "still
/// going" from "nobody found out".
working: Cell<bool>,
}
/// One transcript row's worth of tool calls, kept by the caller for the
/// row a result can still land in -- the tool-call counterpart of
/// [`crate::row::RowBlocks`], and the reason a `ToolEnd` costs one card
/// rather than a row.
pub struct ToolRow {
shared: Rc<Shared>,
}
/// Register `f` as this widget's **tap**, panning the list instead when
/// the finger moves.
///
/// The one gesture entry point in this file. `Selection::drag` with no row
/// is the same call `row.rs` makes with one: it drives the shared
/// `DragArbiter`, so a drag starting on a card scrolls (and flings) the
/// transcript exactly as one starting on a paragraph does, and only a
/// press that committed to nothing comes back as `Tapped`. A bare
/// `CursorSense::click()` here would be a second, disagreeing detector --
/// it fires at the end of a pan too, so every scroll that began on a card
/// would also toggle it.
fn on_tap<Rsc: HasEvents>(
rsc: &mut Rsc,
ptr: WeakWidget<WidgetPtr>,
shared: &Rc<Shared>,
f: impl Fn(&mut Rsc) + 'static,
) where
Rsc::State: FocusHost + OpenUrl,
{
let (list, selection) = (shared.list, shared.selection.clone());
ptr.on(
CursorSense::click_or_drag() | CursorSense::unclick(),
move |ctx, rsc| {
let outcome = selection.borrow_mut().drag(
rsc,
list,
None,
ctx.data.cursor.pos,
ctx.data.sense,
Instant::now(),
ctx.data.render,
);
if outcome == GestureOutcome::Tapped {
f(rsc);
}
},
)
.add(rsc);
}
/// Hold the edge the reader is looking at while this row changes height.
///
/// `List::note_tap` wants a viewport-relative position and this row only
/// knows its own box, so `List::extent` (last frame's on-screen box for
/// this key) turns the two into the position `list.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) {
let (top, _bottom) = (shared.list)(rsc).extent(shared.key).unwrap_or((0.0, 0.0));
(shared.list)(rsc).note_tap(top);
}
fn text<Rsc>(content: impl Into<String>, size: f32, color: UiColor) -> TextBuilder<Rsc> {
wtext(content).size(size).color(color).text_align(Align::LEFT)
}
/// A verbatim block: monospace on the surface everything verbatim in this
/// app sits on, not wrapped, panning sideways on a finger.
///
/// Not wrapped for `ToolInput.kt`'s reason -- a wrapped command hides
/// where its arguments end, and the long one is the one being read
/// closely. The same `scrollable_on(Axis::X).masked()` a markdown fence
/// gets in `row.rs`, so a command and a fence behave the same way under a
/// finger.
fn raw_block<Rsc: HasEvents>(rsc: &mut Rsc, body: TextBuilder<Rsc>) -> StrongWidget
where
Rsc::State: FocusHost,
{
let field = body
.family(Family::Monospace)
.size(BODY_SIZE)
.wrap(false)
.add(rsc);
field
.scrollable_on(Axis::X)
.masked()
.pad(dp(RAW_PAD_DP))
.background(rect(VERBATIM_BACKGROUND).radius(dp(RAW_RADIUS_DP)))
.width(rest(1))
.add_strong(rsc)
.any()
}
/// The word a card shows for what became of the call, and the colour it is
/// in. `None` for a call that simply worked -- the ordinary outcome says
/// nothing, the way it says nothing in Compose.
///
/// Colour by consequence: the same red wherever something failed, the same
/// peach wherever the turn is stopped on a person, yellow where the answer
/// is that nobody knows.
fn state_mark(state: ToolState) -> Option<(&'static str, UiColor)> {
match state {
// A spinner would say the machine is working; while this call
// waits on an answer the machine is doing nothing at all, so the
// card says whose move it is instead (`ToolRows.kt`).
ToolState::Deciding => Some(("your turn", AWAITING_COLOR)),
ToolState::Running => Some(("running", MUTED_COLOR)),
ToolState::Failed => Some(("failed", FAILED_COLOR)),
ToolState::NoResult => Some(("no result", UNKNOWN_COLOR)),
ToolState::Succeeded => None,
}
}
/// What a screen reader is given for one card, and what a `ui-trace`
/// script taps by: the tool, what the call is for, and how it went when
/// that is anything but "fine" -- the same three things the Compose card's
/// own text says, in the order it says them.
fn card_label(tool: &str, parsed: &ToolInput, state: ToolState) -> String {
let mut name = tool.to_string();
if let Some(title) = parsed.title() {
name.push_str(": ");
name.push_str(title);
}
if let Some((word, _)) = state_mark(state) {
name.push_str(" (");
name.push_str(word);
name.push(')');
}
name
}
/// The heading a group carries, closed or open. Compose's exact wording,
/// because it is also the name every `ui-trace` script taps it by.
fn group_label(count: usize) -> String {
format!("Called {count} tools")
}
/// `output` cut to what an open card draws, with the line count it was cut
/// from; `None` when the whole of it fits.
///
/// Cut at the **head**, keeping the beginning: a tool's output is read
/// from the top, and the line saying what went wrong is nearly always the
/// first. (A path is identified by its other end; this is not a path.)
fn capped(output: &str) -> Option<(&str, usize)> {
debug_assert!(OUTPUT_LINES > 0 && OUTPUT_BYTES > 0, "an empty cap shows nothing at all");
let by_lines = output
.char_indices()
.filter(|(_, c)| *c == '\n')
.nth(OUTPUT_LINES - 1)
.map(|(i, _)| i);
let by_bytes = (output.len() > OUTPUT_BYTES).then(|| {
let mut end = OUTPUT_BYTES;
while !output.is_char_boundary(end) {
end -= 1;
}
end
});
let cut = match (by_lines, by_bytes) {
(Some(a), Some(b)) => a.min(b),
(a, b) => a.or(b)?,
};
Some((&output[..cut], output.lines().count()))
}
/// The tool's output, or the reason there is none to show.
///
/// The empty cases are drawn rather than left blank: "it printed nothing"
/// and "nothing ever came back" are the pair [`ToolState`] exists to keep
/// apart, and a card that drew neither would show the same thing for both.
fn output_block<Rsc: HasEvents>(
rsc: &mut Rsc,
shared: &Rc<Shared>,
index: usize,
id: &str,
output: &str,
call_state: ToolState,
) -> StrongWidget
where
Rsc::State: FocusHost + OpenUrl,
{
if output.is_empty() {
let (words, colour) = match call_state {
ToolState::Succeeded => ("No output", MUTED_COLOR),
ToolState::Failed => ("Failed, with no output", FAILED_COLOR),
ToolState::NoResult => ("No result ever arrived", UNKNOWN_COLOR),
ToolState::Running | ToolState::Deciding => ("No output yet", MUTED_COLOR),
};
return text(words, LABEL_SIZE, colour).add_strong(rsc).any();
}
let whole = shared
.state
.borrow()
.whole_output
.get(id)
.copied()
.unwrap_or(false);
let shown = if whole { None } else { capped(output) };
let mut column = Span::empty(Dir::DOWN).gap(dp(2));
column.push(text("Output", LABEL_SIZE, NAME_COLOR).add_strong(rsc).any());
// What the tool printed, in the face it was written for: this is
// column-aligned far more often than it is prose, and a proportional
// font destroys the alignment that carried the meaning.
let body = text(
shown.map_or(output, |(head, _)| head).to_string(),
BODY_SIZE,
NAME_COLOR,
);
column.push(raw_block(rsc, body));
if let Some((_, lines)) = shown {
let label = format!("Show all {lines} lines");
let more_strong = WidgetPtr::new().add_strong(rsc);
let more = more_strong.weak();
let words = text(label.clone(), LABEL_SIZE, MUTED_COLOR)
.label(label)
.add_strong(rsc);
more(rsc).set(words);
let shared_for_tap = shared.clone();
let id = id.to_string();
on_tap(rsc, more, shared, move |rsc| {
note_tap(rsc, &shared_for_tap);
shared_for_tap
.state
.borrow_mut()
.whole_output
.insert(id.clone(), true);
redraw_card(rsc, &shared_for_tap, index);
});
column.push(more_strong.any());
}
column.width(rest(1)).add_strong(rsc).any()
}
/// One tool call's card content.
///
/// Collapsed, this is one `Span` of at most four short strings -- no
/// input, no output, nothing whose size is the call's size.
fn build_card<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>, index: usize) -> StrongWidget
where
Rsc::State: FocusHost + OpenUrl,
{
let call = shared.calls.borrow()[index].clone();
let TranscriptItem::ToolRun {
id,
tool,
input,
output,
..
} = &call
else {
debug_assert!(false, "a tool row holds only tool calls, not {call:?}");
return Span::empty(Dir::DOWN).add_strong(rsc).any();
};
let parsed = parse_tool_input(tool, input);
let call_state = ToolState::of(&call, shared.working.get()).expect("matched ToolRun above");
// A call waiting on permission is shown open whatever the reader last
// chose: the command is the thing being decided, and a row saying only
// "Bash" cannot be decided on (`ToolRows.kt`).
let open =
shared.state.borrow().open.get(id).copied().unwrap_or(false) || call_state == ToolState::Deciding;
let mut header = Span::empty(Dir::RIGHT).gap(dp(GAP_DP));
header.push(
text(if open { OPEN_MARK } else { CLOSED_MARK }, BODY_SIZE, MUTED_COLOR)
.family(Family::Monospace)
.add_strong(rsc)
.any(),
);
header.push(text(tool.clone(), NAME_SIZE, NAME_COLOR).add_strong(rsc).any());
match (open, parsed.title()) {
// Open, the summary is redundant -- the input below is the same
// thing in full -- and the space goes to the timeout instead, at
// the far end, since it is a limit on the call rather than part of
// what the call does.
(true, _) | (false, None) => {
header.push(Span::empty(Dir::RIGHT).width(rest(1)).add_strong(rsc).any())
}
// One line, clipped rather than shrunk or wrapped: a wrapped
// command turns one row into four and a run of them into a wall.
(false, Some(title)) => header.push(
text(title.to_string(), BODY_SIZE, MUTED_COLOR)
.wrap(false)
.masked()
.width(rest(1))
.add_strong(rsc)
.any(),
),
}
if open && let Some(timeout) = &parsed.timeout {
header.push(
text(format!("timeout {timeout}"), LABEL_SIZE, MUTED_COLOR)
.add_strong(rsc)
.any(),
);
}
if let Some((word, colour)) = state_mark(call_state) {
header.push(text(word, LABEL_SIZE, colour).add_strong(rsc).any());
}
let mut column = Span::empty(Dir::DOWN).gap(dp(GAP_DP / 2.0));
column.push(header.width(rest(1)).add_strong(rsc).any());
if open {
if let Some(description) = &parsed.description {
// The tool's own prose about what it is doing, so it belongs
// with the reader's text rather than inside the machine's --
// above the input block rather than in it (`ToolInput.kt`).
column.push(
text(description.clone(), BODY_SIZE, MUTED_COLOR)
.width(rest(1))
.add_strong(rsc)
.any(),
);
}
if let Some(subject) = &parsed.subject {
let spans = match parsed.language {
Some(language) => {
let mut spans = Vec::new();
highlight_into(&mut spans, subject, 0..subject.len(), language);
spans
}
// An unknown language is drawn plain rather than coloured
// by the nearest one -- P1a's rule for a fence, and the
// same reason: a wrong highlight is read as a fact.
None => Vec::new(),
};
let body = text(subject.clone(), BODY_SIZE, NAME_COLOR).spans(spans);
column.push(raw_block(rsc, body));
}
if !parsed.rest.is_empty() {
// Never dropped: a field left out would be claiming the tool
// had no other input when it might (`ToolInput.kt`).
let body = text(parsed.rest.join("\n"), BODY_SIZE, MUTED_COLOR);
column.push(raw_block(rsc, body));
}
column.push(output_block(rsc, shared, index, id, output, call_state));
}
column
.width(rest(1))
.pad(dp(CARD_PAD_DP))
.background(rect(CARD_FILL).radius(dp(CARD_RADIUS_DP)))
.width(rest(1))
.label(card_label(tool, &parsed, call_state))
.add_strong(rsc)
.any()
}
/// Rebuild card `index` in place. The removal half is the returned
/// `StrongWidget` being dropped, which frees the content this replaced.
fn redraw_card<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>, index: usize)
where
Rsc::State: FocusHost + OpenUrl,
{
let Some(ptr) = shared.card_ptr(index) else {
// Reached only if a handler outlives the card it was registered
// on, which `apply_calls` is written to prevent.
debug_assert!(false, "card {index} has no widget to redraw");
return;
};
let content = build_card(rsc, shared, index);
let _old = ptr(rsc).replace(content);
}
/// A card and the tap that opens it. The gesture is registered **once**,
/// on a `WidgetPtr` whose content is replaced as often as needed -- which
/// is why every rebuild reads the call out of [`Shared`] rather than
/// capturing one.
fn build_card_ptr<Rsc: HasEvents>(
rsc: &mut Rsc,
shared: &Rc<Shared>,
index: usize,
) -> (StrongWidget, WeakWidget<WidgetPtr>)
where
Rsc::State: FocusHost + OpenUrl,
{
// The strong handle is the card's one real registration and goes to
// whatever container holds it; the weak one is what the gesture and
// every later redraw address it by.
let strong = WidgetPtr::new().add_strong(rsc);
let ptr = strong.weak();
shared.cards.borrow_mut().push(ptr);
debug_assert_eq!(
shared.cards.borrow().len(),
index + 1,
"a card's index is its position, and both are the call's"
);
let content = build_card(rsc, shared, index);
ptr(rsc).set(content);
let for_tap = shared.clone();
on_tap(rsc, ptr, shared, move |rsc| {
note_tap(rsc, &for_tap);
let Some(id) = for_tap.call_id(index) else {
debug_assert!(false, "tapped card {index} is no longer in the row");
return;
};
let was = for_tap.state.borrow().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)
}
/// A bar the height of one line of `LABEL_SIZE` text, carrying `mark`
/// centred -- the group's collapse control at its foot.
///
/// Given the same content as the heading above rather than a height that
/// looks close, so the surface the calls sit on is the same thickness at
/// both ends (`ToolRows.kt`'s `groupBarHeight`, which derives the number
/// from the type for the same reason).
fn collapse_bar<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>) -> StrongWidget
where
Rsc::State: FocusHost + OpenUrl,
{
let strong = WidgetPtr::new().add_strong(rsc);
let ptr = strong.weak();
let mark = text(UP_MARK, BODY_SIZE, MUTED_COLOR)
.family(Family::Monospace)
.center()
.width(rest(1))
.pad(dp(CARD_PAD_DP))
// Anything shown only as a mark still needs a name: this is what
// a screen reader reads and what a `ui-trace` script taps.
.label("Collapse these tool calls")
.add_strong(rsc);
ptr(rsc).set(mark);
let for_tap = shared.clone();
on_tap(rsc, ptr, shared, move |rsc| toggle_group(rsc, &for_tap));
strong.any()
}
/// The row's whole content: a lone card, a closed group's one line, or an
/// open group's header, cards and foot.
///
/// Rebuilt whole when the group opens or closes, because that is a change
/// of what the row *is* rather than of one card in it. Everything a single
/// card's tap does goes through [`redraw_card`] instead.
fn build_content<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>) -> StrongWidget
where
Rsc::State: FocusHost + OpenUrl,
{
shared.cards.borrow_mut().clear();
*shared.column.borrow_mut() = None;
let count = shared.calls.borrow().len();
debug_assert!(count > 0, "a tool row with no calls has nothing to draw");
// One call is left alone: "Called 1 tool" hides a card to say the same
// thing in more words, and the run this grouping exists for is the
// burst of five greps nobody wants to scroll past (`ToolRows.kt`).
if count == 1 {
return build_card_ptr(rsc, shared, 0).0;
}
if !shared.state.borrow().group_expanded {
let heading = group_label(count);
return text(heading.clone(), NAME_SIZE, NAME_COLOR)
.pad(dp(CARD_PAD_DP))
.width(rest(1))
.background(rect(CARD_FILL).radius(dp(CARD_RADIUS_DP)))
.width(rest(1))
.label(heading)
.add_strong(rsc)
.any();
}
let heading = group_label(count);
let mut group = Span::empty(Dir::DOWN);
group.push(
text(heading.clone(), NAME_SIZE, NAME_COLOR)
.pad(dp(CARD_PAD_DP))
.width(rest(1))
.label(heading)
.add_strong(rsc)
.any(),
);
let mut column = Span::empty(Dir::DOWN).gap(dp(GROUP_GAP_DP));
for index in 0..count {
column.push(build_card_ptr(rsc, shared, index).0);
}
let column = column.add(rsc);
*shared.column.borrow_mut() = Some(column);
group.push(
column
.width(rest(1))
.pad(Padding {
left: dp(GROUP_INSET_DP),
right: dp(GROUP_INSET_DP),
..Padding::ZERO
})
.add_strong(rsc)
.any(),
);
// Shutting it from here anchors the other end: the reader is at the
// bottom of a long group, and what they are looking at is what follows
// it (`ToolRows.kt`'s `CollapseBar`).
group.push(collapse_bar(rsc, shared));
group
.width(rest(1))
.background(rect(GROUP_FILL).radius(dp(CARD_RADIUS_DP)))
.width(rest(1))
.add_strong(rsc)
.any()
}
fn toggle_group<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>)
where
Rsc::State: FocusHost + OpenUrl,
{
note_tap(rsc, shared);
let was = shared.state.borrow().group_expanded;
shared.state.borrow_mut().group_expanded = !was;
let content = build_content(rsc, shared);
shared.set_content(rsc, content);
}
impl Shared {
/// Swap the row's whole content. The old `StrongWidget` is freed as it
/// drops here, which is the removal half of what replaced it.
fn set_content(&self, rsc: &mut impl UiRsc, content: StrongWidget) {
let Some(ptr) = *self.content.borrow() else {
debug_assert!(
false,
"the row's content pointer is set before anything can tap it"
);
return;
};
let _old = ptr(rsc).replace(content);
}
fn call_id(&self, index: usize) -> Option<String> {
match self.calls.borrow().get(index) {
Some(TranscriptItem::ToolRun { id, .. }) => Some(id.clone()),
_ => None,
}
}
fn card_ptr(&self, index: usize) -> Option<WeakWidget<WidgetPtr>> {
self.cards.borrow().get(index).copied()
}
}
/// Build a tool row: one card, or a run of them under one heading.
///
/// `working` is the caller's `session_working` **for this row** -- true
/// only for the newest row of a session that is still doing something.
/// Every row behind it belongs to a turn that has ended, so a call in one
/// with no result never came back rather than still running.
pub fn build_tool_row<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<List>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
calls: Vec<TranscriptItem>,
working: bool,
) -> (StrongWidget, ToolRow)
where
Rsc::State: FocusHost + OpenUrl,
{
let shared = Rc::new(Shared {
calls: RefCell::new(calls),
state: RefCell::new(ToolRowState::default()),
cards: RefCell::new(Vec::new()),
column: RefCell::new(None),
content: RefCell::new(None),
list,
selection,
key,
working: Cell::new(working),
});
// `.add_strong`, not `.add`: this row *is* the top of its own subtree,
// so nothing else holds it and it has to own itself (`row.rs`).
let content_strong = WidgetPtr::new().add_strong(rsc);
let content = content_strong.weak();
*shared.content.borrow_mut() = Some(content);
let inner = build_content(rsc, &shared);
content(rsc).set(inner);
(content_strong.any(), ToolRow { shared })
}
impl ToolRow {
/// The calls this row is currently drawing -- what a caller passes
/// back to [`Self::apply_calls`] when something other than the calls
/// themselves changed (the session's status).
pub fn calls(&self) -> Vec<TranscriptItem> {
self.shared.calls.borrow().clone()
}
/// How many cards this row currently draws -- zero for a closed
/// group, which is the whole reason its calls' outputs cost nothing.
/// Only the tests ask; nothing on screen is decided by it.
#[cfg(test)]
pub(crate) fn card_count(&self) -> usize {
self.shared.cards.borrow().len()
}
/// Open or close this row's group without a tap.
///
/// Exists because the expanded appearance is otherwise unreachable
/// from anything that cannot press the screen -- a headless
/// screenshot on this displayless machine, and a test. Same path a tap
/// takes, including `List::note_tap`, so what it produces is what a
/// reader would have got.
pub fn set_group_expanded<Rsc: HasEvents>(&self, rsc: &mut Rsc, expanded: bool)
where
Rsc::State: FocusHost + OpenUrl,
{
if self.shared.state.borrow().group_expanded != expanded {
toggle_group(rsc, &self.shared);
}
}
/// Bring this row up to date with `calls` **without** rebuilding the
/// cards that did not change, and say whether that was possible.
/// `false` means the caller must rebuild the row the ordinary way.
///
/// This is what the per-card `WidgetPtr` exists for: a `ToolEnd`
/// changes one call, so it costs one card, whatever else is in the
/// run. The same rule `RowBlocks::apply_delta` follows for the blocks
/// of a message.
///
/// Refused when a call *left* the row or the calls were reordered: a
/// card's index is its call's position, and every registered handler
/// closed over that index. A run only ever grows at its end while it
/// is the live row, so the refused cases are the ones a page join
/// produces -- and those go through `Rebuild` already.
pub fn apply_calls<Rsc: HasEvents>(
&mut self,
rsc: &mut Rsc,
calls: &[TranscriptItem],
working: bool,
) -> bool
where
Rsc::State: FocusHost + OpenUrl,
{
// A row that was tool calls and now holds something else is a
// different row, not a changed one -- and nothing here could draw
// a message anyway.
if calls.is_empty()
|| !calls
.iter()
.all(|c| matches!(c, TranscriptItem::ToolRun { .. }))
{
return false;
}
let old = self.shared.calls.borrow().clone();
if calls.len() < old.len() {
return false;
}
// Whether the group is drawn as one card or as a stack changes at
// exactly one call, and that is a different row, not a changed
// one.
if (old.len() == 1) != (calls.len() == 1) {
return false;
}
let changed: Vec<usize> = (0..old.len()).filter(|&i| old[i] != calls[i]).collect();
self.shared.working.set(working);
*self.shared.calls.borrow_mut() = calls.to_vec();
// The path out for the reader's own state: a call that is no
// longer in this row keeps no entry in `open`/`whole_output`.
let ids: std::collections::HashSet<String> = calls
.iter()
.filter_map(|c| match c {
TranscriptItem::ToolRun { id, .. } => Some(id.clone()),
_ => None,
})
.collect();
{
let mut state = self.shared.state.borrow_mut();
state.open.retain(|id, _| ids.contains(id));
state.whole_output.retain(|id, _| ids.contains(id));
}
// A collapsed group draws no cards, so a changed call is worth
// nothing on screen -- unless the *count* changed, which is the
// whole of what its one line says.
if self.shared.cards.borrow().is_empty() {
if calls.len() != old.len() {
let content = build_content(rsc, &self.shared);
self.shared.set_content(rsc, content);
}
return true;
}
debug_assert_eq!(
self.shared.cards.borrow().len(),
old.len(),
"an open row draws exactly one card per call"
);
for index in changed {
redraw_card(rsc, &self.shared, index);
}
for index in old.len()..calls.len() {
let Some(column) = *self.shared.column.borrow() else {
// Only a group has a column to append to, and a lone card
// that gained a neighbour is a different row.
return false;
};
let (widget, _card) = build_card_ptr(rsc, &self.shared, index);
// `get_mut` marks the column dirty, which is what gets the new
// card drawn; its removal half is the row's own, since the
// column owns the child strongly (`RowBlocks::apply_delta`).
if let Some(column) = rsc.ui_mut().widgets.get_mut(&column) {
column.push(widget);
}
}
true
}
}