654 lines
21 KiB
Rust
654 lines
21 KiB
Rust
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<String, bool>,
|
|
whole: HashMap<(String, Part), bool>,
|
|
}
|
|
|
|
struct ToolRowData {
|
|
calls: Vec<TranscriptItem>,
|
|
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<ToolRowData>,
|
|
/// 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>>>,
|
|
content: Cell<Option<WeakWidget<WidgetPtr>>>,
|
|
list: WeakWidget<LazySpan>,
|
|
key: RowKey,
|
|
theme: Rc<Theme>,
|
|
}
|
|
|
|
/// 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<ToolRowShared>,
|
|
}
|
|
|
|
pub struct BuiltToolRow {
|
|
pub widget: StrongWidget,
|
|
pub row: ToolRow,
|
|
}
|
|
|
|
fn text<Rsc>(content: impl Into<String>, size: f32, color: PaintId) -> TextBuilder<Rsc> {
|
|
wtext(content)
|
|
.size(size)
|
|
.color(color)
|
|
.text_align(Align::LEFT)
|
|
}
|
|
|
|
fn disclosure<Rsc>(glyph: &'static str, theme: &Theme) -> TextBuilder<Rsc> {
|
|
text(glyph, MARK_DP, theme.muted.clone()).family(super::ICON_FAMILY)
|
|
}
|
|
|
|
fn raw_block<Rsc: HasEvents>(rsc: &mut Rsc, body: TextBuilder<Rsc>, 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: HasEvents>(
|
|
rsc: &mut Rsc,
|
|
shared: &Rc<ToolRowShared>,
|
|
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: HasEvents>(
|
|
rsc: &mut Rsc,
|
|
shared: &Rc<ToolRowShared>,
|
|
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: HasEvents>(
|
|
rsc: &mut Rsc,
|
|
shared: &Rc<ToolRowShared>,
|
|
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: HasEvents>(rsc: &mut Rsc, shared: &Rc<ToolRowShared>, 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: HasEvents>(
|
|
rsc: &mut Rsc,
|
|
shared: &Rc<ToolRowShared>,
|
|
index: usize,
|
|
) -> (StrongWidget, WeakWidget<WidgetPtr>)
|
|
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: HasEvents>(rsc: &mut Rsc, shared: &Rc<ToolRowShared>) -> 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: HasEvents>(rsc: &mut Rsc, shared: &Rc<ToolRowShared>) -> 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: HasEvents>(rsc: &mut Rsc, shared: &Rc<ToolRowShared>)
|
|
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<String> {
|
|
match self.data.borrow().calls.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()
|
|
}
|
|
}
|
|
|
|
/// `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<LazySpan>,
|
|
key: RowKey,
|
|
calls: Vec<TranscriptItem>,
|
|
working: bool,
|
|
theme: Rc<Theme>,
|
|
) -> 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<TranscriptItem> {
|
|
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<Rsc: HasEvents>(&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<Rsc: HasEvents>(
|
|
&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<usize> = (0..old.len()).filter(|&i| old[i] != calls[i]).collect();
|
|
let ids: HashSet<String> = 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
|
|
}
|
|
}
|