Make the Rust client the sole app
This commit is contained in:
1 parent
a8602c1626
commit
d8bb1699a8
230 files changed
+762
-27300
No files matched your search
@@ -0,0 +1,657 @@
|
||||
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::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(Family::Icons)
|
||||
}
|
||||
|
||||
fn raw_block<Rsc: HasEvents>(rsc: &mut Rsc, body: TextBuilder<Rsc>, theme: &Theme) -> StrongWidget
|
||||
where
|
||||
Rsc::State: FocusHost,
|
||||
{
|
||||
let field = body
|
||||
.family(Family::Monospace)
|
||||
.size(BODY_SIZE)
|
||||
.wrap(false)
|
||||
.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())
|
||||
.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,
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user