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