diff --git a/docs/bench/p1b-2026-09-06/iris-tools-collapsed.png b/docs/bench/p1b-2026-09-06/iris-tools-collapsed.png new file mode 100644 index 0000000..d46ff6b Binary files /dev/null and b/docs/bench/p1b-2026-09-06/iris-tools-collapsed.png differ diff --git a/docs/bench/p1b-2026-09-06/iris-tools-expanded.png b/docs/bench/p1b-2026-09-06/iris-tools-expanded.png new file mode 100644 index 0000000..bd9ea85 Binary files /dev/null and b/docs/bench/p1b-2026-09-06/iris-tools-expanded.png differ diff --git a/iris/core/src/ui/render_state.rs b/iris/core/src/ui/render_state.rs index 14f3cb5..d237df2 100644 --- a/iris/core/src/ui/render_state.rs +++ b/iris/core/src/ui/render_state.rs @@ -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; diff --git a/iris/src/widget/list.rs b/iris/src/widget/list.rs index 62d4dec..2c8e725 100644 --- a/iris/src/widget/list.rs +++ b/iris/src/widget/list.rs @@ -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) } diff --git a/iris/transcript-ui/examples/transcript.rs b/iris/transcript-ui/examples/transcript.rs index c5c2e10..e1130b9 100644 --- a/iris/transcript-ui/examples/transcript.rs +++ b/iris/transcript-ui/examples/transcript.rs @@ -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::>() + .join("\n") +} + fn synthetic_rows() -> Vec { vec![ msg( @@ -55,41 +110,39 @@ fn synthetic_rows() -> Vec { 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 } } } diff --git a/iris/transcript-ui/src/lib.rs b/iris/transcript-ui/src/lib.rs index 7ca5fa9..df82adf 100644 --- a/iris/transcript-ui/src/lib.rs +++ b/iris/transcript-ui/src/lib.rs @@ -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, - /// 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>, + /// 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>, + /// 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, } 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(&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(&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(&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 { + (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); + } + } diff --git a/iris/transcript-ui/src/markdown.rs b/iris/transcript-ui/src/markdown.rs index 3cc9c94..e704345 100644 --- a/iris/transcript-ui/src/markdown.rs +++ b/iris/transcript-ui/src/markdown.rs @@ -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, text: &str, range: Range, language: Language) { +pub(crate) fn highlight_into(spans: &mut Vec, text: &str, range: Range, 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. diff --git a/iris/transcript-ui/src/row.rs b/iris/transcript-ui/src/row.rs index 9256a1b..39ee8c2 100644 --- a/iris/transcript-ui/src/row.rs +++ b/iris/transcript-ui/src/row.rs @@ -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: &mut Rsc, - list: WeakWidget, - selection: Rc>, - key: RowKey, - calls: Vec, -) -> 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::>() - .join("\n\n"); - - fn build_content( - rsc: &mut Rsc, - list: WeakWidget, - selection: Rc>, - 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( @@ -567,26 +483,38 @@ pub fn build_row( list: WeakWidget, selection: Rc>, row: &FoldedRow, -) -> (RowKey, StrongWidget, Option) + working: bool, +) -> (RowKey, StrongWidget, Option) 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))) } diff --git a/iris/transcript-ui/src/tool.rs b/iris/transcript-ui/src/tool.rs new file mode 100644 index 0000000..6840ce8 --- /dev/null +++ b/iris/transcript-ui/src/tool.rs @@ -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, + whole_output: HashMap, +} + +/// 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>, + state: RefCell, + /// 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>>, + /// 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>>, + /// 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>>, + list: WeakWidget, + selection: Rc>, + 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, +} + +/// 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, +} + +/// 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: &mut Rsc, + ptr: WeakWidget, + shared: &Rc, + 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(content: impl Into, size: f32, color: UiColor) -> TextBuilder { + 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: &mut Rsc, body: TextBuilder) -> 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: &mut Rsc, + shared: &Rc, + 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: &mut Rsc, shared: &Rc, 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: &mut Rsc, shared: &Rc, 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: &mut Rsc, + shared: &Rc, + index: usize, +) -> (StrongWidget, WeakWidget) +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: &mut Rsc, shared: &Rc) -> 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: &mut Rsc, shared: &Rc) -> 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: &mut Rsc, shared: &Rc) +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 { + match self.calls.borrow().get(index) { + Some(TranscriptItem::ToolRun { id, .. }) => Some(id.clone()), + _ => None, + } + } + + fn card_ptr(&self, index: usize) -> Option> { + 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: &mut Rsc, + list: WeakWidget, + selection: Rc>, + key: RowKey, + calls: Vec, + 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 { + 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(&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( + &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 = (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 = 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 + } +}