//! The transcript screen, in iris -- RUST.md's I5. Built the same way //! `tabs-ui` is: its own crate, generic over `Rsc: HasEvents` + //! `Rsc::State: FocusHost + OpenUrl`, so the winit example (`iris/examples/ //! transcript.rs`) and an eventual `iris-android-app`-style cdylib call the //! same [`build`]. See RUST.md's I5 box for the full account of what is //! and is not proved yet, and this doc for the shape. //! //! ```text //! +------------------------------------------+ //! | iris::widget::LazySpan (crate::ui::row) | <- .height(rest(1)) //! | row 1: sender label + one TextEdit | //! | row 2: sender label + one TextEdit | //! | row 3 (Tools): collapsed/expanded | //! | ... | //! +------------------------------------------+ //! | composer bar (crate::ui::composer) | <- natural height //! +------------------------------------------+ //! ``` //! //! **What this crate does not do itself**: fetch anything over the network //! or read the transcript cache. [`build`] takes an already-folded //! `Vec` and //! [`TranscriptScreen::push_row`] takes one more as it arrives -- the //! caller (an app's own `main`, or a future `iris-android-app`-shaped //! cdylib) owns `crate::client::ApiClient`/ //! `event_stream::follow_session_events` and the transcript cache, per the //! code rules' "ask for the least you need": a widget-tree builder that //! also knew how to make an HTTPS request would be untestable without a //! server and unable to be driven by `run-headless.sh` with synthetic rows. //! //! **Gap closed, 2026-09-05**: a touch-drag that starts on a row's //! rendered text used to always begin a cross-row *selection* (`row.rs`'s //! `CursorSense::click_or_drag()` on each row's `TextEdit`), never a //! *scroll* of the list, because both wanted the same gesture over the //! same screen region and `core/src/sense.rs`'s `run_sensors` gave the //! widget in the *inner* layer (a row's own `TextEdit`) first refusal //! every frame it was pressed. `row.rs` now routes every row's drag //! through one shared `iris::sense::DragArbiter` //! (`Selection::drag`, `selection.rs`), which decides pan vs. select the //! way Android itself does -- `DragArbiter`'s own doc has the exact //! rule. `LazySpan` scrolls correctly when //! driven programmatically (I3's benchmark), via the mouse wheel (wired //! below, `CursorSense::Scroll`), and now via a touch pan starting on a //! row's own text too. pub mod composer; // The checked-in bench fixture opened as a real screen -- 1.9 MB of // `include_str!`, so it is a feature rather than always present: a build // meant for a phone must not carry it. `bench` turns it on; so does the // default, which is what makes `cargo test` here run the harness tests. #[cfg(feature = "fixture")] pub mod fixture; pub mod markdown; pub mod row; pub mod selection; pub(crate) mod tap; pub mod tool; use crate::client::transcript_fold::TranscriptRow as FoldedRow; use iris::prelude::*; use selection::Selection; use std::{cell::RefCell, rc::Rc}; pub struct TranscriptScreen { /// The transcript's own `LazySpan` -- the layout *and* the scroll /// position, since a lazy span owns a `ScrollController` of its own /// rather than being wrapped in a `ScrollArea` (`docs/SCROLL.md`). /// Exposed so a caller can read `.extent()`, drive it through /// `Scrollable` (`.scroll()`, `.fling()`, `.amt()`) or call /// `.jump_to_end()` directly. pub list: WeakWidget, pub composer: composer::Composer, selection: Rc>, /// How many times [`Self::apply`] has fallen back to a full rebuild -- /// `Cell` rather than requiring `&mut self`, matching every other /// method here (the real state lives behind `list`/`selection`'s own /// interior mutability, per `push_row`'s existing `&self`). Drained by /// [`Self::take_rebuilds`]. rebuilds: std::cell::Cell, /// 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 { /// Append one more folded row at the live end of the transcript -- /// what a caller's SSE loop or a sent message calls as new events /// arrive. `LazySpan::push_back` is O(1) and keeps the view pinned to the /// newest content when it already was (I3). pub fn push_row(&self, rsc: &mut Rsc, row: &FoldedRow) where Rsc::State: FocusHost + OpenUrl, { // Capped like any other row (`row::build_row`'s `cap`). A reply // that goes on to *grow* past the cap is never capped, because it // grows through `RowBlocks::apply_delta`, which appends to what is // already drawn -- so the cap only ever catches a row that arrived // long, which is the one nobody is watching arrive. let (key, widget, tail) = row::build_row( rsc, self.list, self.selection.clone(), row, self.session_working.get(), true, ); (self.list)(rsc).push_back(LazyItem::new(key, widget)); *self.tail.borrow_mut() = tail.map(|t| (key, t)); } /// Whether the session this transcript belongs to is still doing /// something (`crate::client::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 mut tail = self.tail.borrow_mut(); let Some((tail_key, kept)) = tail.as_mut() else { return false; }; if *tail_key != key { return false; } 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, crate::client::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 /// whole screen -- RUST.md's "streaming still costs a full rebuild" /// fix. `old`/`new` are `crate::client::transcript_fold::fold_event`'s /// own before/after item lists (never grouped into rows -- that /// happens here, over both, so the common tail cases can be told /// apart; `group_tool_runs` is pure bookkeeping over already-folded /// items, no widget is built doing it). /// /// Three cases, cheapest first: /// - **nothing changed**: no-op. /// - **pure append** (a still-open reply's row now closed and stable, /// a new tool call, a new message): every new row is `push_back`ed, /// same cost as [`Self::push_row`]. /// - **only the last row's content changed** (the common case: a delta /// folded into a still-open assistant message): that one row is /// rebuilt (`row::build_row`, the same path a fresh row goes /// through) and swapped in with [`LazySpan::replace_back`] -- every /// other row is untouched, so nothing else redraws or moves. Any /// further new rows are appended after it, for the (also common) /// case of a delta that both finishes the open reply and starts the /// next row in the same event. /// /// Anything else -- a row *before* the tail changed, which only /// happens when `group_tool_runs` regroups already-seen items (a tool /// run's calls that used to be separate rows join once the run closes) /// -- falls back to a full rebuild: every row is dropped /// (`LazySpan::clear`) and rebuilt from `new`. Counted in /// [`Self::take_rebuilds`] so a caller (a report, a test) can see how /// often the fallback actually fires rather than assuming it never /// does. pub fn apply( &self, rsc: &mut Rsc, old: &[crate::client::transcript_fold::TranscriptItem], new: &[crate::client::transcript_fold::TranscriptItem], ) where Rsc::State: FocusHost + OpenUrl, { use crate::client::transcript_fold::group_tool_runs; let old_rows = group_tool_runs(old); let new_rows = group_tool_runs(new); match diff_rows(&old_rows, &new_rows) { RowDiff::Unchanged => {} RowDiff::Appended { common } => { // Pure append: every already-drawn row is byte-for-byte the // same `FoldedRow` it was last time. for row in &new_rows[common..] { self.push_row(rsc, row); } } RowDiff::ReplaceLast { common } => { // Only the tail row's content changed. First try the // delta path: the row is a column of one widget per // markdown block, so a delta that lands in the last block // is one `set_with_spans` and the earlier blocks keep // their layouts (`row::RowBlocks::apply_delta`, whose doc // says why the row is shaped that way). let old_key = row::row_key(&old_rows[common].key()); let new_key = row::row_key(&new_rows[common].key()); if new_key == old_key && self.apply_tail_delta(rsc, new_key, &new_rows[common]) { for row in &new_rows[common + 1..] { self.push_row(rsc, row); } return; } // Otherwise rebuild that one row and swap it in place, // keeping every row before it untouched. `unregister` // unconditionally, not only when the key changed: a // rebuild with *fewer* blocks under the same key would // otherwise leave the extra blocks in `Selection` // pointing at widgets the `drop` below frees (the shape // a review on 2026-09-06 called out). self.selection.borrow_mut().unregister(old_key); // Uncapped: this is the row a delta just failed to land // in, and the reason may be that it *is* capped // (`RowBlocks::capped`). Rebuilding it capped again would // refuse the next delta the same way, once per event. let (new_key, widget, kept) = row::build_row( rsc, self.list, self.selection.clone(), &new_rows[common], self.session_working.get(), false, ); let evicted = (self.list)(rsc).replace_back(LazyItem::new(new_key, widget)); drop(evicted); // frees the old row's widget, same as a pop would *self.tail.borrow_mut() = kept.map(|t| (new_key, t)); for row in &new_rows[common + 1..] { self.push_row(rsc, row); } } RowDiff::Rebuild => { // A row before the tail changed (a regroup) -- nothing // short of a full rebuild expresses that. `Selection` // gets cleared the same way `LazySpan` does, right before the // rows it was pointing at go with it -- `push_row` below // re-`register`s whatever survives as it rebuilds each // row (review, 2026-09-06 finding 1: a key that // `group_tool_runs` regrouped away used to stay in // `Selection` pointing at a widget this `clear()` had // just freed, panicking the next long-press anywhere). self.rebuilds.set(self.rebuilds.get() + 1); self.selection.borrow_mut().clear(); (self.list)(rsc).clear(); *self.tail.borrow_mut() = None; for row in &new_rows { self.push_row(rsc, row); } } } } /// How many times [`Self::apply`] has fallen back to a full rebuild /// since the last call, reset to 0 by reading it -- the same /// take-and-reset shape `AccessTree::take_rebuilds` already uses (I4). pub fn take_rebuilds(&self) -> usize { self.rebuilds.replace(0) } /// The concatenated text of whatever is currently selected across one /// or more rows, `None` if nothing is -- what a copy command reads. pub fn selected_text(&self, rsc: &mut impl UiRsc) -> Option { self.selection.borrow().selected_text(rsc) } } pub fn build( rsc: &mut Rsc, ui_state: &mut impl HasRoot, rows: Vec, ) -> TranscriptScreen where Rsc::State: FocusHost + OpenUrl, { let (screen, tree) = build_tree(rsc, rows); ui_state.set_root(tree); screen } /// The same widget tree [`build`] makes, without claiming the window's /// whole root -- what a caller embedding this screen alongside something /// else of its own needs (RUST.md's E4: a session list beside the /// transcript on the desktop). `build` is `build_tree` plus /// `ui_state.set_root(tree)`; kept as its own function since most callers /// (the winit example, an eventual Android cdylib) want the screen to *be* /// the window and don't need the strong handle back. pub fn build_tree( rsc: &mut Rsc, rows: Vec, ) -> (TranscriptScreen, StrongWidget) where Rsc::State: FocusHost + OpenUrl, { let selection = Rc::new(RefCell::new(Selection::new())); let list = LazySpan::new(Dir::DOWN, Pin::End).add(rsc); // The last row's block widgets are kept for the same reason // `push_row` keeps them: a reply that is *already* streaming when the // screen is built takes its next delta through `apply`, and a `None` // here would send that delta down the rebuild path instead -- the // whole message re-shaped, which is exactly what the per-block column // exists to avoid, and nothing on screen or in `take_rebuilds` would // say so. let mut tail = None; for (i, row) in rows.iter().enumerate() { // `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. // `cap`: every row but the last. The last is the tail, which may // be a reply already streaming when this screen opened, and a // capped row cannot take a delta (`RowBlocks::capped`). let cap = i + 1 < rows.len(); let (key, widget, kept) = row::build_row(rsc, list, selection.clone(), row, false, cap); list(rsc).push_back(LazyItem::new(key, widget)); tail = kept.map(|t| (key, t)); } // The continuation of a row-started drag once it has committed and // taken pointer capture on `list`'s own id (`row.rs`'s registration is // only ever the gesture's first frame) -- registered once here, not // once per row, since `DragGesture`'s single shared instance must see // each frame of one gesture exactly once. `ctx.data.pos`/`size` are // already relative to `list`'s own on-screen box (this is what it was // registered against), which is exactly the viewport-pixel space // `LazySpan::key_at`/`extent` work in, so the row-under-the-pointer is // resolved from those instead of a per-row hit test. { let selection = selection.clone(); list.on( CursorSense::Pressing(CursorButton::Left) | CursorSense::Drop | CursorSense::Cancel, move |ctx, rsc| { // Which *block* the finger is over, resolved from its // drawn box rather than from the row's extent -- a row is // a column of one widget per markdown block now, and the // block is what `Selection` selects (`SelKey`). let row = selection .borrow() .locate(&*rsc, ctx.data.render, ctx.data.cursor.pos); selection.borrow_mut().drag( rsc, list, row, ctx.data.cursor.pos, ctx.data.sense, ctx.data.cursor.time, ctx.data.pointer, ); }, ) .add(rsc); } // The wheel, registered by hand rather than through // `LazySpan::scrollable()`, and this is the reason: that helper also // registers a finger drag driving the span's own `DragGesture`, and // the transcript already has an arbiter -- `Selection`, which has to // decide between panning and selecting text and so cannot let a second // `DragGesture` see the same frames. `DragGesture`'s doc states the // rule: one gesture, one arbiter, each frame delivered exactly once. // The wheel handler here is identical to the helper's; only the drag // differs, and it arrives through `Selection::drag`, which hands // committed pans and releases to this same span. list.on(CursorSense::Scroll, |ctx, rsc| { let delta = ctx.data.scroll_delta.y * 50.0; ctx.widget(rsc).scroll(delta); }) .add(rsc); selection.borrow_mut().set_scroll_area(list); let (composer, composer_bar) = composer::build_composer(rsc); // `.masked()`, opted into here rather than done by the list: a // `LazySpan` culls the rows outside its box but draws a *straddling* // one in full, so without a clip the top of that row is drawn above // the list -- through whatever the app put there, which on the phone // is the header bar (docs/IRIS_TODO.md, 2026-09-07: "code and a // paragraph visible behind Run benchmark"). This screen is a list // under a header, so this screen wants the clip; a full-screen list // does not, and the widget is right not to assume either // (`lazy_span.rs`'s module doc). let tree = (list.width(rest(1)).height(rest(1)).masked(), composer_bar) .span(Dir::DOWN) .add_strong(rsc) .any(); ( TranscriptScreen { tail: RefCell::new(tail), session_working: std::cell::Cell::new(false), list, composer, selection, rebuilds: std::cell::Cell::new(0), }, tree, ) } /// What changed at the tail between two folded row lists -- the decision /// [`TranscriptScreen::apply`] acts on. Kept as its own pure function, no /// widget and no `Rsc`, so the three cases can be tested directly against /// synthetic `Vec`s (below) rather than needing a full widget /// harness to exercise logic that never touches one. #[derive(Debug, PartialEq, Eq)] enum RowDiff { /// `old` and `new` are the same length and every row is identical. Unchanged, /// Rows `[common..]` of `new` are new; everything before `common` is /// byte-for-byte the same `FoldedRow` `old` already had. Appended { common: usize }, /// Row `common` is the only one whose content differs; anything past /// it in `new` is a pure append after the replacement. ReplaceLast { common: usize }, /// A row *before* the tail differs -- only `group_tool_runs` regrouping /// an earlier run does this, and nothing short of a full rebuild /// expresses it. Rebuild, } fn diff_rows(old: &[FoldedRow], new: &[FoldedRow]) -> RowDiff { let common = old .iter() .zip(new.iter()) .take_while(|(a, b)| a == b) .count(); if common == old.len() && common == new.len() { RowDiff::Unchanged } else if common == old.len() { RowDiff::Appended { common } } else if !old.is_empty() && common == old.len() - 1 && common < new.len() { // The `common < new.len()` guard is what tells "the tail row's // content changed" apart from "the tail row was removed and // nothing replaced it" (a shrinking list) -- the latter has // nothing at `new[common]` to rebuild into place. RowDiff::ReplaceLast { common } } else { RowDiff::Rebuild } } #[cfg(test)] mod diff_tests { use super::*; use crate::client::transcript_fold::TranscriptItem; fn user(seq: u64, text: &str) -> FoldedRow { FoldedRow::Single(TranscriptItem::UserMsg { seq, text: text.to_string(), attachments: Vec::new(), }) } fn assistant(seq: u64, text: &str, settled: bool) -> FoldedRow { FoldedRow::Single(TranscriptItem::AssistantMsg { seq, text: text.to_string(), settled, }) } fn tool(seq: u64, run_id: &str) -> TranscriptItem { TranscriptItem::ToolRun { seq, id: format!("id{seq}"), run_id: run_id.to_string(), tool: "grep".to_string(), input: "x".to_string(), output: String::new(), done: false, failed: false, asks: Vec::new(), images: Vec::new(), } } #[test] fn identical_lists_are_unchanged() { let rows = vec![user(1, "hi"), assistant(2, "hello", true)]; assert_eq!(diff_rows(&rows, &rows.clone()), RowDiff::Unchanged); } #[test] fn an_empty_list_growing_by_one_is_an_append_from_zero() { let old: Vec = Vec::new(); let new = vec![user(1, "hi")]; assert_eq!(diff_rows(&old, &new), RowDiff::Appended { common: 0 }); } #[test] fn a_new_message_after_a_settled_reply_is_a_pure_append() { // The row that used to be the tail (a now-closed assistant // message) is unchanged; a new user message is appended after it // -- the transition every reply's *last* delta makes once the // next turn starts. let old = vec![user(1, "hi"), assistant(2, "hello", true)]; let new = vec![user(1, "hi"), assistant(2, "hello", true), user(3, "and?")]; assert_eq!(diff_rows(&old, &new), RowDiff::Appended { common: 2 }); } #[test] fn a_delta_into_the_open_reply_is_a_last_row_replace() { // The common streaming case: the assistant message's key (its // first delta's seq) never changes, only its text grows. let old = vec![user(1, "hi"), assistant(2, "hel", false)]; let new = vec![user(1, "hi"), assistant(2, "hello", false)]; assert_eq!(diff_rows(&old, &new), RowDiff::ReplaceLast { common: 1 }); } #[test] fn a_delta_that_both_settles_the_reply_and_starts_the_next_row_is_still_a_replace() { // `ReplaceLast` only claims the row it names; `apply` appends // whatever comes after it separately -- this just confirms the // diff still recognises the replace even with a trailing append. let old = vec![user(1, "hi"), assistant(2, "hel", false)]; let new = vec![user(1, "hi"), assistant(2, "hello", true), user(3, "and?")]; assert_eq!(diff_rows(&old, &new), RowDiff::ReplaceLast { common: 1 }); } #[test] fn a_tool_run_closing_and_joining_an_earlier_call_is_a_regroup_fallback() { // Two separate `Single` rows for the same run id become one // `Tools` row once `group_tool_runs` sees them adjacent -- that // changes row 0, not just the tail, so nothing short of a full // rebuild expresses it. let old = vec![FoldedRow::Single(tool(1, "run-a")), user(2, "meanwhile")]; let new = vec![FoldedRow::Tools(vec![tool(1, "run-a"), tool(3, "run-a")])]; assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild); } #[test] fn shrinking_the_list_is_a_rebuild() { let old = vec![user(1, "hi"), assistant(2, "hello", true)]; let new = vec![user(1, "hi")]; assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild); } } /// Exercises `TranscriptScreen::apply`'s `Rebuild` arm through a real /// `Selection`, the gap the 2026-09-06 review named: the /// pure `diff_rows` decision above and `selection.rs`'s own registration /// tests each pass in isolation, and neither alone catches finding 1 (a /// regrouped-away row's key surviving in `Selection` after `LazySpan::clear()` /// has already freed its widget). This fails before `Selection::clear()` /// existed and the `Rebuild` arm called it, with a panic from /// `TextEditable::edit` resolving the freed slot. #[cfg(test)] mod apply_tests { use super::*; use crate::client::transcript_fold::TranscriptItem; struct TestFocus { focus: Option>, } /// The headless stand-in for `iris::platform::OpenUrl`'s real /// backends. Nothing in these tests taps a link -- the tap-vs-drag /// rule that decides whether one is followed is `iris`'s own /// (`sense_tests.rs`'s `a_press_released_without_moving_is_a_tap`), /// and which link is under a byte offset is `markdown.rs`'s -- so /// this only exists to satisfy the bound. impl OpenUrl for TestFocus { fn open_url(&mut self, _url: &str) {} } impl FocusHost for TestFocus { fn recent_click(&mut self) -> bool { false } fn set_focus(&mut self, id: Option>) { self.focus = id; } fn focus_gained(&mut self, _region: Option) {} fn is_focused(&self, id: WeakWidget) -> bool { self.focus == Some(id) } } struct TestRsc { ui: UiData, events: EventManager, } impl UiRsc for TestRsc { fn ui(&self) -> &UiData { &self.ui } fn ui_mut(&mut self) -> &mut UiData { &mut self.ui } fn on_draw(&mut self, active: &ActiveData) { self.events.draw(active); } fn on_undraw(&mut self, active: &ActiveData) { self.events.undraw(active); } fn on_remove(&mut self, id: WidgetId) { self.events.remove(id); } } impl HasState for TestRsc { type State = TestFocus; } impl HasEvents for TestRsc { fn events(&self) -> &EventManager { &self.events } fn events_mut(&mut self) -> &mut EventManager { &mut self.events } } fn user(seq: u64, text: &str) -> TranscriptItem { TranscriptItem::UserMsg { seq, text: text.to_string(), attachments: Vec::new(), } } fn tool(seq: u64, run_id: &str) -> TranscriptItem { TranscriptItem::ToolRun { seq, id: format!("id{seq}"), run_id: run_id.to_string(), tool: "grep".to_string(), input: "x".to_string(), output: String::new(), done: false, failed: false, asks: Vec::new(), images: Vec::new(), } } fn assistant(seq: u64, text: &str) -> TranscriptItem { TranscriptItem::AssistantMsg { seq, text: text.to_string(), settled: false, } } /// A reply of `paragraphs` paragraphs, the last one still growing. fn reply(paragraphs: usize, tail: &str) -> String { let mut out = String::new(); for i in 0..paragraphs { out.push_str(&format!("Paragraph number {i} of a streamed reply.\n\n")); } out.push_str(tail); out } /// `(Widget::draw` calls, text layouts) caused by one streamed delta /// landing in the last paragraph of a reply that already has /// `paragraphs` of them. fn cost_of_one_delta(paragraphs: usize) -> (u64, u64) { let mut rsc = TestRsc { ui: UiData::default(), events: EventManager::default(), }; let old_items = vec![assistant(1, &reply(paragraphs, "and the last one is st"))]; let new_items = vec![assistant( 1, &reply(paragraphs, "and the last one is still going."), )]; let (screen, tree) = build_tree( &mut rsc, crate::client::transcript_fold::group_tool_runs(&old_items), ); let mut render = UiRenderState::new(); render.resize((1080.0, 20000.0)); render.update(&tree, &mut rsc); render.take_counters(); screen.apply(&mut rsc, &old_items, &new_items); render.update(&tree, &mut rsc); assert_eq!(screen.take_rebuilds(), 0, "the delta path must be taken"); let (draws, _, _, shapes) = render.take_counters(); (draws, shapes) } /// The pass condition for the per-block row: a delta /// costs the **last block**, not the message. A 3,000-character reply /// has a hundred paragraphs already laid out; redrawing one delta into it /// must cost exactly what the same delta costs in a one-paragraph /// reply, or the earlier blocks are being re-shaped. /// /// Before the split this was one `TextEdit` for the whole message, so /// the count was the same *number* of widgets but each redraw /// re-shaped every paragraph through parley -- which a draw counter /// cannot see. What it can see is that the count does not *grow* with /// the message, which it now does not and could not before, since the /// one widget's own layout was O(message). #[test] fn a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one() { assert!( reply(100, "").len() > 3_000, "the long case must actually be a long message" ); let (short_draws, short_shapes) = cost_of_one_delta(1); let (long_draws, long_shapes) = cost_of_one_delta(100); assert_eq!( short_draws, long_draws, "a delta into a 100-paragraph reply redrew {long_draws} widgets against \ {short_draws} for a one-paragraph reply -- the earlier blocks are not being kept" ); // The half a draw counter cannot see, and the one the per-block // row actually exists for: a redraw is free if the text engine // hits its memo, and a re-shape is the expensive thing. One // shape, whatever the message is worth -- the block the delta // landed in. Before the split this was necessarily O(message), // since the whole reply was one buffer. assert_eq!( (short_shapes, long_shapes), (1, 1), "a delta shaped {long_shapes} text layouts in a 100-paragraph reply and \ {short_shapes} in a one-paragraph one; it must be the last block and nothing else" ); } #[test] fn a_row_dropped_by_a_regroup_does_not_outlive_itself_in_selection() { use crate::client::transcript_fold::group_tool_runs; let mut rsc = TestRsc { ui: UiData::default(), events: EventManager::default(), }; // Same regroup shape as diff_tests' regroup case, plus a trailing // row (seq 4) that survives unchanged -- what a reader would tap // on right after the regroup lands. let old_items = vec![tool(1, "run-a"), user(2, "meanwhile"), user(4, "stable")]; let new_items = vec![tool(1, "run-a"), tool(3, "run-a"), user(4, "stable")]; assert_eq!( diff_rows(&group_tool_runs(&old_items), &group_tool_runs(&new_items)), RowDiff::Rebuild, "test setup must actually exercise the Rebuild arm" ); let (screen, _tree) = build_tree(&mut rsc, group_tool_runs(&old_items)); screen.apply(&mut rsc, &old_items, &new_items); // The surviving row (seq 4) is what a reader's long-press would // land on; `begin` deselects every *other* registered row first, // which is exactly what used to resolve a stale `WeakWidget` left // by the regrouped-away rows and panic. let surviving_key = row::row_key(&crate::client::transcript_fold::ItemKey::Seq(4)); screen.selection.borrow_mut().begin( &mut rsc, (surviving_key, 0), Vec2::ZERO, Vec2::new(10.0, 10.0), ); } /// The failure half of the per-block row, and the one /// `a_row_dropped_by_a_regroup_...` cannot reach: the tail row is /// rebuilt under the **same key** with *fewer* blocks than it had. /// `Selection` is keyed by `(row, block)`, so the blocks that no /// longer exist are left pointing at widgets `replace_back`'s drop /// frees -- and `begin` resolves every registered handle on an /// ordinary press, so the next tap anywhere in the transcript /// panics. Nothing about the key changed, which is why the /// `if new_key != old_key` guard this replaced could not see it. #[test] fn a_tail_rebuilt_with_fewer_blocks_leaves_none_of_them_in_selection() { use crate::client::transcript_fold::group_tool_runs; let mut rsc = TestRsc { ui: UiData::default(), events: EventManager::default(), }; // Three blocks, then one. The rewrite is of an *earlier* block // (the heading), so `RowBlocks::apply_delta` refuses it and the // rebuild path is the one taken -- assert that below. let old_items = vec![user(1, "stable"), assistant(2, "# Head\n\npara\n\n- item")]; let new_items = vec![user(1, "stable"), assistant(2, "short")]; assert_eq!( diff_rows(&group_tool_runs(&old_items), &group_tool_runs(&new_items)), RowDiff::ReplaceLast { common: 1 }, "test setup must actually exercise the ReplaceLast arm" ); let (screen, _tree) = build_tree(&mut rsc, group_tool_runs(&old_items)); let tail_key = row::row_key(&crate::client::transcript_fold::ItemKey::Seq(2)); assert_eq!( screen .selection .borrow() .registered_blocks(tail_key) .count(), 3, "the fixture must start with more blocks than it ends with" ); screen.apply(&mut rsc, &old_items, &new_items); assert_eq!( screen .selection .borrow() .registered_blocks(tail_key) .count(), 1, "the blocks the rebuild dropped are still registered" ); // What a reader does next: press the row that survived. `begin` // resolves every registered handle, so a stale one panics here. let surviving_key = row::row_key(&crate::client::transcript_fold::ItemKey::Seq(1)); screen.selection.borrow_mut().begin( &mut rsc, (surviving_key, 0), Vec2::ZERO, 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, crate::client::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, crate::client::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" ); } /// The text shapes a screen holding `text` as its first message costs /// to draw. A second, tiny row follows it, so the message under test /// is **not** the tail -- the tail is deliberately uncapped /// (`row::build_row`'s `cap`), and measuring it would measure the one /// row the cap does not apply to. fn shapes_for_message(text: &str) -> u64 { let mut rsc = TestRsc { ui: UiData::default(), events: EventManager::default(), }; let items = vec![ TranscriptItem::AssistantMsg { seq: 1, text: text.to_string(), settled: true, }, TranscriptItem::AssistantMsg { seq: 2, text: "ok".to_string(), settled: true, }, ]; let (_screen, tree) = build_tree( &mut rsc, crate::client::transcript_fold::group_tool_runs(&items), ); let mut render = UiRenderState::new(); render.resize((1080.0, 20000.0)); render.update(&tree, &mut rsc); let (_, _, _, shapes) = render.take_counters(); shapes } /// **A long message is drawn as far as the cap and no further** /// (Iris, 2026-09-08). Counted in text shapes rather than draws, /// because that is the cost that grows with the message: every block /// past the cap is a parley layout of text nobody asked for. /// /// The bound is the cap's own, not the short message's -- the capped /// row genuinely draws more than a two-word one -- so this asserts /// that the cost stops growing with the message rather than that it /// is zero. #[test] fn a_long_message_is_drawn_only_as_far_as_the_cap() { let paragraphs = |n: usize| "a paragraph of a reply\n\n".repeat(n); let capped = shapes_for_message(¶graphs(crate::client::text_cap::MESSAGE_LINES * 4)); let bigger = shapes_for_message(¶graphs(crate::client::text_cap::MESSAGE_LINES * 40)); assert!( capped > 0, "the screen shaped nothing, so this compares zeroes" ); assert_eq!( capped, bigger, "a message ten times longer cost {bigger} text layouts against {capped} -- the cap \ is not bounding what gets laid out", ); } /// 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, crate::client::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, crate::client::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); } }