use crate::client::text_cap::{VERBATIM_BYTES, VERBATIM_LINES, cut, show_all_label}; use crate::client::tool_summary::{ToolInput, parse_tool_input}; use crate::client::transcript_fold::{ToolState, TranscriptItem}; use crate::ui::icon; use crate::ui::markdown::highlight_into; use crate::ui::tap::{hold_edge, on_tap}; use crate::ui::theme::Theme; use iris::prelude::*; use std::{ cell::{Cell, RefCell}, collections::{HashMap, HashSet}, rc::Rc, }; const NAME_SIZE: f32 = 14.0; const BODY_SIZE: f32 = 12.0; const LABEL_SIZE: f32 = 11.0; const CARD_PAD_DP: f32 = 12.0; const CARD_RADIUS_DP: f32 = 12.0; const GAP_DP: f32 = 8.0; const RAW_RADIUS_DP: f32 = 4.0; const RAW_PAD_DP: f32 = 8.0; const GROUP_INSET_DP: f32 = 4.0; const MARK_DP: f32 = 9.0; #[derive(Default)] struct ToolRowState { group_expanded: bool, open: HashMap, whole: HashMap<(String, Part), bool>, } struct ToolRowData { calls: Vec, view: ToolRowState, working: bool, } #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] enum Part { Input, Output, } struct ToolRowShared { // The row owner and `'static` tap callbacks share this model. Callbacks // are single-threaded but cannot borrow from `ToolRow`, hence Rc/RefCell. data: RefCell, /// One `WidgetPtr` per call, in order -- what makes a result cost one /// card. Empty while the group is collapsed, because a collapsed group /// draws no cards at all. Its path out is [`build_content`], which /// clears it before building whatever replaces them. cards: RefCell>>, content: Cell>>, list: WeakWidget, key: RowKey, theme: Rc, } /// 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::ui::row::RowBlocks`], and the reason a `ToolEnd` costs one card /// rather than a row. pub struct ToolRow { shared: Rc, } pub struct BuiltToolRow { pub widget: StrongWidget, pub row: ToolRow, } fn text(content: impl Into, size: f32, color: PaintId) -> TextBuilder { wtext(content) .size(size) .color(color) .text_align(Align::LEFT) } fn disclosure(glyph: &'static str, theme: &Theme) -> TextBuilder { text(glyph, MARK_DP, theme.muted.clone()).family(super::ICON_FAMILY) } fn raw_block(rsc: &mut Rsc, body: TextBuilder, theme: &Theme) -> StrongWidget where Rsc::State: FocusHost, { let field = body.family(MONOSPACE).size(BODY_SIZE).add(rsc); field .scrollable(Axis::X, Pin::Start) .pad(dp(RAW_PAD_DP)) .masked_by(rect(theme.verbatim_surface.clone()).radius(dp(RAW_RADIUS_DP))) .width(rest(1)) .add_strong(rsc) .any() } fn state_word(state: ToolState) -> Option<&'static str> { match state { ToolState::Deciding => Some("your turn"), ToolState::Running => Some("running"), ToolState::Failed => Some("failed"), ToolState::NoResult => Some("no result"), ToolState::Succeeded => None, } } fn state_mark(state: ToolState, theme: &Theme) -> Option<(&'static str, PaintId)> { let color = match state { ToolState::Deciding => theme.awaiting.clone(), ToolState::Running => theme.muted.clone(), ToolState::Failed => theme.failed.clone(), ToolState::NoResult => theme.unknown.clone(), ToolState::Succeeded => return None, }; Some(( state_word(state).expect("non-success state has a label"), color, )) } /// 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". 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_word(state) { name.push_str(" ("); name.push_str(word); name.push(')'); } name } /// The heading a group carries, also used as its accessibility label. fn group_label(count: usize) -> String { format!("Called {count} tools") } fn capped(body: &str, whole: bool) -> (&str, usize, bool) { match cut(body, VERBATIM_LINES, VERBATIM_BYTES) { Some((head, lines)) if !whole => (head, lines, true), Some((_, lines)) => (body, lines, false), None => (body, body.lines().count(), false), } } fn wants_whole(shared: &ToolRowShared, id: &str, part: Part) -> bool { shared .data .borrow() .view .whole .get(&(id.to_string(), part)) .copied() .unwrap_or(false) } /// A control rather than a note, and it says the count rather than "more", /// because the reader is deciding whether to ask for it: "Show all 4,000 /// lines" and "Show all 12 lines" are different decisions and the word /// "more" tells them apart not at all. fn show_all( rsc: &mut Rsc, shared: &Rc, index: usize, id: &str, part: Part, lines: usize, ) -> StrongWidget where Rsc::State: FocusHost + OpenUrl, { let label = show_all_label(lines); let more_strong = WidgetPtr::new().add_strong(rsc); let more = more_strong.weak(); let words = text(label.clone(), LABEL_SIZE, shared.theme.muted.clone()) .label(label) .add_strong(rsc); more(rsc).set(words); let shared_for_tap = shared.clone(); let key = (id.to_string(), part); on_tap(rsc, more, shared.list, move |rsc| { hold_edge(rsc, shared_for_tap.list, shared_for_tap.key); shared_for_tap .data .borrow_mut() .view .whole .insert(key.clone(), true); redraw_card(rsc, &shared_for_tap, index); }); more_strong.any() } fn output_block( rsc: &mut Rsc, shared: &Rc, index: usize, id: &str, output: &str, call_state: ToolState, ) -> StrongWidget where Rsc::State: FocusHost + OpenUrl, { if output.is_empty() { let (words, colour) = match call_state { ToolState::Succeeded => ("No output", shared.theme.muted.clone()), ToolState::Failed => ("Failed, with no output", shared.theme.failed.clone()), ToolState::NoResult => ("No result ever arrived", shared.theme.unknown.clone()), ToolState::Running | ToolState::Deciding => { ("No output yet", shared.theme.muted.clone()) } }; return text(words, LABEL_SIZE, colour).add_strong(rsc).any(); } let (shown, lines, was_cut) = capped(output, wants_whole(shared, id, Part::Output)); let mut column = Span::empty(Dir::DOWN).gap(dp(2)); column.push( text("Output", LABEL_SIZE, shared.theme.text.clone()) .add_strong(rsc) .any(), ); let body = text(shown.to_string(), BODY_SIZE, shared.theme.text.clone()); column.push(raw_block(rsc, body, &shared.theme)); if was_cut { column.push(show_all(rsc, shared, index, id, Part::Output, lines)); } column.width(rest(1)).add_strong(rsc).any() } fn build_card( rsc: &mut Rsc, shared: &Rc, index: usize, ) -> StrongWidget where Rsc::State: FocusHost + OpenUrl, { let call = shared.data.borrow().calls[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 data = shared.data.borrow(); let call_state = ToolState::of(&call, data.working).expect("matched ToolRun above"); let open = data.view.open.get(id).copied().unwrap_or(false) || call_state == ToolState::Deciding; drop(data); let mut header = Span::empty(Dir::RIGHT).gap(dp(GAP_DP)); header.push( disclosure(if open { icon::OPEN } else { icon::CLOSED }, &shared.theme) .add_strong(rsc) .any(), ); header.push( text(tool.clone(), NAME_SIZE, shared.theme.text.clone()) .add_strong(rsc) .any(), ); match (open, parsed.title()) { (true, _) | (false, None) => { header.push(Span::empty(Dir::RIGHT).width(rest(1)).add_strong(rsc).any()) } (false, Some(title)) => header.push( text(title.to_string(), BODY_SIZE, shared.theme.muted.clone()) .masked() .width(rest(1)) .add_strong(rsc) .any(), ), } if open && let Some(timeout) = &parsed.timeout { header.push( text( format!("timeout {timeout}"), LABEL_SIZE, shared.theme.muted.clone(), ) .add_strong(rsc) .any(), ); } if let Some((word, colour)) = state_mark(call_state, &shared.theme) { 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 { column.push( text(description.clone(), BODY_SIZE, shared.theme.muted.clone()) .width(rest(1)) .add_strong(rsc) .any(), ); } let whole = wants_whole(shared, id, Part::Input); let mut input_lines = 0usize; let mut input_cut = false; if let Some(subject) = &parsed.subject { let (shown, lines, was_cut) = capped(subject, whole); input_lines += lines; input_cut |= was_cut; let spans = match parsed.language { Some(language) => { let mut spans = Vec::new(); highlight_into(&mut spans, shown, 0..shown.len(), language, &shared.theme); spans } None => Vec::new(), }; let body = text(shown.to_string(), BODY_SIZE, shared.theme.text.clone()).spans(spans); column.push(raw_block(rsc, body, &shared.theme)); } 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`). Capped is // not dropped -- the field is still there, with its size said // out loud. let joined = parsed.rest.join("\n"); let (shown, lines, was_cut) = capped(&joined, whole); input_lines += lines; input_cut |= was_cut; let body = text(shown.to_string(), BODY_SIZE, shared.theme.muted.clone()); column.push(raw_block(rsc, body, &shared.theme)); } if input_cut { column.push(show_all(rsc, shared, index, id, Part::Input, input_lines)); } column.push(output_block(rsc, shared, index, id, output, call_state)); } column .width(rest(1)) .pad(dp(CARD_PAD_DP)) .background(rect(shared.theme.card_surface.clone()).radius(dp(CARD_RADIUS_DP))) .width(rest(1)) .label(card_label(tool, &parsed, call_state)) .add_strong(rsc) .any() } fn redraw_card(rsc: &mut Rsc, shared: &Rc, index: usize) where Rsc::State: FocusHost + OpenUrl, { let Some(ptr) = shared.card_ptr(index) else { debug_assert!(false, "card {index} has no widget to redraw"); return; }; let content = build_card(rsc, shared, index); let _old = ptr(rsc).replace(content); } fn build_card_ptr( rsc: &mut Rsc, shared: &Rc, index: usize, ) -> (StrongWidget, WeakWidget) where Rsc::State: FocusHost + OpenUrl, { 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.list, move |rsc| { hold_edge(rsc, for_tap.list, for_tap.key); 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 .data .borrow() .view .open .get(&id) .copied() .unwrap_or(false); for_tap.data.borrow_mut().view.open.insert(id, !was); redraw_card(rsc, &for_tap, index); }); (strong.any(), ptr) } fn collapse_bar(rsc: &mut Rsc, shared: &Rc) -> StrongWidget where Rsc::State: FocusHost + OpenUrl, { let strong = WidgetPtr::new().add_strong(rsc); let ptr = strong.weak(); let mark = disclosure(icon::COLLAPSE, &shared.theme) .center_text() .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.list, move |rsc| { toggle_group(rsc, &for_tap) }); strong.any() } /// Rebuilt whole when the group opens or closes, because that is a change /// of what the row *is* rather than of one card in it. Everything a single /// card's tap does goes through [`redraw_card`] instead. fn build_content(rsc: &mut Rsc, shared: &Rc) -> StrongWidget where Rsc::State: FocusHost + OpenUrl, { shared.cards.borrow_mut().clear(); let count = shared.data.borrow().calls.len(); debug_assert!(count > 0, "a tool row with no calls has nothing to draw"); if count == 1 { return build_card_ptr(rsc, shared, 0).0; } if !shared.data.borrow().view.group_expanded { let heading = group_label(count); return text(heading.clone(), NAME_SIZE, shared.theme.text.clone()) .pad(dp(CARD_PAD_DP)) .width(rest(1)) .background(rect(shared.theme.card_surface.clone()).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, shared.theme.text.clone()) .pad(dp(CARD_PAD_DP)) .width(rest(1)) .label(heading) .add_strong(rsc) .any(), ); { let mut cards = Span::empty(Dir::DOWN); for index in 0..count { cards.push(build_card_ptr(rsc, shared, index).0); } group.push(cards.pad(dp(GROUP_INSET_DP)).add_strong(rsc).any()); } group.push(collapse_bar(rsc, shared)); group .width(rest(1)) .background(rect(shared.theme.group_surface.clone()).radius(dp(CARD_RADIUS_DP))) .width(rest(1)) .add_strong(rsc) .any() } fn toggle_group(rsc: &mut Rsc, shared: &Rc) where Rsc::State: FocusHost + OpenUrl, { hold_edge(rsc, shared.list, shared.key); let mut data = shared.data.borrow_mut(); data.view.group_expanded = !data.view.group_expanded; drop(data); let content = build_content(rsc, shared); shared.set_content(rsc, content); } impl ToolRowShared { fn set_content(&self, rsc: &mut impl UiRsc, content: StrongWidget) { let Some(ptr) = self.content.get() else { debug_assert!( false, "the row's content pointer is set before anything can tap it" ); return; }; let _old = ptr(rsc).replace(content); } fn call_id(&self, index: usize) -> Option { match self.data.borrow().calls.get(index) { Some(TranscriptItem::ToolRun { id, .. }) => Some(id.clone()), _ => None, } } fn card_ptr(&self, index: usize) -> Option> { self.cards.borrow().get(index).copied() } } /// `working` is the caller's `session_working` **for this row** -- true /// only for the newest row of a session that is still doing something. /// Every row behind it belongs to a turn that has ended, so a call in one /// with no result never came back rather than still running. pub fn build_tool_row( rsc: &mut Rsc, list: WeakWidget, key: RowKey, calls: Vec, working: bool, theme: Rc, ) -> BuiltToolRow where Rsc::State: FocusHost + OpenUrl, { let shared = Rc::new(ToolRowShared { data: RefCell::new(ToolRowData { calls, view: ToolRowState::default(), working, }), cards: RefCell::new(Vec::new()), content: Cell::new(None), list, key, theme, }); let content_strong = WidgetPtr::new().add_strong(rsc); let content = content_strong.weak(); shared.content.set(Some(content)); let inner = build_content(rsc, &shared); content(rsc).set(inner); BuiltToolRow { widget: content_strong.any(), row: ToolRow { shared }, } } impl ToolRow { /// The calls this row is currently drawing -- what a caller passes /// back to [`Self::apply_calls`] when something other than the calls /// themselves changed (the session's status). pub fn calls(&self) -> Vec { self.shared.data.borrow().calls.clone() } #[cfg(test)] pub(crate) fn card_count(&self) -> usize { self.shared.cards.borrow().len() } /// 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 `LazySpan::note_tap`, so what it produces is what a /// reader would have got. pub fn set_group_expanded(&self, rsc: &mut Rsc, expanded: bool) where Rsc::State: FocusHost + OpenUrl, { if self.shared.data.borrow().view.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. pub fn apply_calls( &mut self, rsc: &mut Rsc, calls: &[TranscriptItem], working: bool, ) -> bool where Rsc::State: FocusHost + OpenUrl, { if calls.is_empty() || !calls .iter() .all(|c| matches!(c, TranscriptItem::ToolRun { .. })) { return false; } let old = self.shared.data.borrow().calls.clone(); if calls.len() < old.len() { return false; } if (old.len() == 1) != (calls.len() == 1) { return false; } let changed: Vec = (0..old.len()).filter(|&i| old[i] != calls[i]).collect(); let ids: HashSet = calls .iter() .filter_map(|c| match c { TranscriptItem::ToolRun { id, .. } => Some(id.clone()), _ => None, }) .collect(); { let mut data = self.shared.data.borrow_mut(); data.working = working; data.calls = calls.to_vec(); data.view.open.retain(|id, _| ids.contains(id)); data.view.whole.retain(|(id, _), _| ids.contains(id)); } 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); } if calls.len() > old.len() { let content = build_content(rsc, &self.shared); self.shared.set_content(rsc, content); } true } }