Merge branch 'worktree-agent-a673ba12761c025d9' into rustify
This commit is contained in:
commit
10267dec27
20 files changed
+2369
-194
No files matched your search
@@ -0,0 +1,100 @@
|
|||||||
|
//! A span of milliseconds, written the way somebody reads it -- the port
|
||||||
|
//! of `Durations.kt`'s `formatMillis`/`formatMillisText`, with its tests.
|
||||||
|
//!
|
||||||
|
//! Only the tool-timeout half is here. `formatSpan` (the usage
|
||||||
|
//! countdown's rounding-up rule) belongs with whatever draws the usage
|
||||||
|
//! bar, and nothing in this crate needs it yet.
|
||||||
|
|
||||||
|
/// A span of milliseconds, written the way somebody reads it.
|
||||||
|
///
|
||||||
|
/// A tool's timeout arrives as `480000`, which nobody reads as eight
|
||||||
|
/// minutes. The rule has two halves, because a short span and a long one
|
||||||
|
/// are read for different things. Under a minute the question is "roughly
|
||||||
|
/// how long", so only the largest unit is shown and a fraction carries the
|
||||||
|
/// rest -- `2.5s`. At a minute or more the question is "how long exactly",
|
||||||
|
/// so every unit with something in it is written out -- `5d 12h 4m`. Empty
|
||||||
|
/// units are left out rather than written as zero.
|
||||||
|
///
|
||||||
|
/// Sub-second precision is dropped past a minute: nothing that takes days
|
||||||
|
/// is measured in milliseconds.
|
||||||
|
pub fn format_millis(ms: i64) -> String {
|
||||||
|
if ms < 0 {
|
||||||
|
return format!("-{}", format_millis(-ms));
|
||||||
|
}
|
||||||
|
if ms < 1000 {
|
||||||
|
return format!("{ms}ms");
|
||||||
|
}
|
||||||
|
if ms < 60_000 {
|
||||||
|
let tenths = (ms + 50) / 100;
|
||||||
|
let (whole, rest) = (tenths / 10, tenths % 10);
|
||||||
|
return if rest == 0 {
|
||||||
|
format!("{whole}s")
|
||||||
|
} else {
|
||||||
|
format!("{whole}.{rest}s")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let seconds = ms / 1000;
|
||||||
|
[
|
||||||
|
("d", seconds / 86_400),
|
||||||
|
("h", seconds / 3600 % 24),
|
||||||
|
("m", seconds / 60 % 60),
|
||||||
|
("s", seconds % 60),
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, n)| *n > 0)
|
||||||
|
.map(|(unit, n)| format!("{n}{unit}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `text` as a span when it is a whole number of milliseconds, and
|
||||||
|
/// unchanged when it is not.
|
||||||
|
pub fn format_millis_text(text: &str) -> String {
|
||||||
|
match text.trim().parse::<i64>() {
|
||||||
|
Ok(ms) => format_millis(ms),
|
||||||
|
Err(_) => text.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The two ways a span of time is written here, and the rule each of
|
||||||
|
/// them follows -- ported from `DurationsTest.kt`, whose doc says why:
|
||||||
|
/// both are read off a screen to make a decision, so what matters is
|
||||||
|
/// that the shortest form that answers the question is what appears.
|
||||||
|
#[test]
|
||||||
|
fn under_a_minute_is_the_largest_unit_alone() {
|
||||||
|
assert_eq!(format_millis(30), "30ms");
|
||||||
|
assert_eq!(format_millis(999), "999ms");
|
||||||
|
assert_eq!(format_millis(1000), "1s");
|
||||||
|
assert_eq!(format_millis(2500), "2.5s");
|
||||||
|
// One decimal, rounded rather than cut: 2.46s is nearer two and a
|
||||||
|
// half than two and four.
|
||||||
|
assert_eq!(format_millis(2460), "2.5s");
|
||||||
|
assert_eq!(format_millis(59_900), "59.9s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_minute_or_more_is_every_unit_that_has_something_in_it() {
|
||||||
|
// The figure this rule was written for: a tool timeout, which
|
||||||
|
// arrives as milliseconds and is unreadable as 480000.
|
||||||
|
assert_eq!(format_millis(480_000), "8m");
|
||||||
|
assert_eq!(format_millis(60_000), "1m");
|
||||||
|
assert_eq!(format_millis(90_000), "1m 30s");
|
||||||
|
assert_eq!(format_millis(475_440_000), "5d 12h 4m");
|
||||||
|
// Empty units are left out rather than written as zero: the labels
|
||||||
|
// say which is which, and "5d 0h 4m" is only longer.
|
||||||
|
assert_eq!(format_millis(432_240_000), "5d 4m");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_a_whole_number_of_milliseconds_is_rewritten() {
|
||||||
|
assert_eq!(format_millis_text(" 480000 "), "8m");
|
||||||
|
// A timeout a tool expressed some other way is its own words,
|
||||||
|
// passed through rather than guessed at.
|
||||||
|
assert_eq!(format_millis_text("2 minutes"), "2 minutes");
|
||||||
|
assert_eq!(format_millis_text(""), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,13 @@
|
|||||||
pub mod ansi;
|
pub mod ansi;
|
||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod durations;
|
||||||
pub mod event_stream;
|
pub mod event_stream;
|
||||||
pub mod highlight;
|
pub mod highlight;
|
||||||
pub mod markdown_blocks;
|
pub mod markdown_blocks;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod sse;
|
pub mod sse;
|
||||||
|
pub mod tool_summary;
|
||||||
pub mod transcript_cache;
|
pub mod transcript_cache;
|
||||||
pub mod transcript_fold;
|
pub mod transcript_fold;
|
||||||
pub mod transcript_source;
|
pub mod transcript_source;
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
//! A tool call's input, read rather than dumped -- the port of
|
||||||
|
//! `ToolInput.kt`'s `parseToolInput`, which is what both the collapsed
|
||||||
|
//! card's one-line summary and the expanded card's key/value list are
|
||||||
|
//! derived from.
|
||||||
|
//!
|
||||||
|
//! Every tool's input arrives as JSON, and showing it raw makes the reader
|
||||||
|
//! parse `{"command":"…","timeout":120000}` themselves to find the one
|
||||||
|
//! line they care about. So the fields that carry the meaning are pulled
|
||||||
|
//! out, and anything left over is still shown, because dropping a field
|
||||||
|
//! would be claiming the tool has no other input when it might.
|
||||||
|
//!
|
||||||
|
//! Pure, and here rather than in the widget crate, for the reason the rest
|
||||||
|
//! of this crate exists: the derivation is the same on a phone and on a
|
||||||
|
//! desktop, and it is testable without a renderer.
|
||||||
|
|
||||||
|
use crate::durations::format_millis_text;
|
||||||
|
use crate::highlight::Language;
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
/// A tool call's input, split into the parts a card draws separately.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct ToolInput {
|
||||||
|
/// The thing that will actually be run or read, if this tool has one.
|
||||||
|
pub subject: Option<String>,
|
||||||
|
/// The language [`ToolInput::subject`] is written in, for
|
||||||
|
/// highlighting.
|
||||||
|
pub language: Option<Language>,
|
||||||
|
/// The tool's own one-line summary, when it wrote one.
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// How long the call may take, in the largest units it fits. Shown
|
||||||
|
/// apart because it is a limit on the call rather than part of what
|
||||||
|
/// the call does.
|
||||||
|
pub timeout: Option<String>,
|
||||||
|
/// Everything else, as `name: value` lines. Never dropped.
|
||||||
|
pub rest: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolInput {
|
||||||
|
/// The one line to show when there is only room for one: what this
|
||||||
|
/// call is for.
|
||||||
|
pub fn title(&self) -> Option<&str> {
|
||||||
|
self.description
|
||||||
|
.as_deref()
|
||||||
|
.or(self.subject.as_deref())
|
||||||
|
// A subject that is only whitespace would draw as an empty
|
||||||
|
// summary line, which reads as a tool with nothing to say
|
||||||
|
// rather than as one whose subject was blank.
|
||||||
|
.filter(|t| !t.trim().is_empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which field of which tool is the subject.
|
||||||
|
///
|
||||||
|
/// A table rather than a chain of `if`s: adding a tool is a row, and the
|
||||||
|
/// shape stops any of them from being the special case that gets its own
|
||||||
|
/// code path. Unknown tools fall through to "no subject, everything is
|
||||||
|
/// rest".
|
||||||
|
const SUBJECTS: &[(&str, &str, Option<Language>)] = &[
|
||||||
|
("Bash", "command", Some(Language::Shell)),
|
||||||
|
("Read", "file_path", None),
|
||||||
|
("Write", "file_path", None),
|
||||||
|
("Edit", "file_path", None),
|
||||||
|
("Glob", "pattern", None),
|
||||||
|
("Grep", "pattern", None),
|
||||||
|
("WebFetch", "url", None),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Fields that are the tool's own prose about itself rather than input to
|
||||||
|
/// it.
|
||||||
|
const DESCRIPTIONS: &[&str] = &["description", "prompt"];
|
||||||
|
|
||||||
|
/// One JSON value as the Kotlin's `JSONObject.optString`/`get` wrote it: a
|
||||||
|
/// string is its own characters, anything else is its JSON form.
|
||||||
|
///
|
||||||
|
/// One function rather than two, because the same coercion decides both
|
||||||
|
/// what a subject reads as and what a leftover field's value reads as, and
|
||||||
|
/// two copies would eventually disagree about a number.
|
||||||
|
fn as_text(value: &Value) -> String {
|
||||||
|
match value {
|
||||||
|
Value::String(s) => s.clone(),
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_blank(value: Option<&Value>) -> Option<String> {
|
||||||
|
let text = as_text(value?);
|
||||||
|
(!text.trim().is_empty()).then_some(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split `input` (a tool call's JSON) into the parts a card draws.
|
||||||
|
///
|
||||||
|
/// Input that is not a JSON object -- older transcripts and some tools
|
||||||
|
/// send a bare string -- is still the input, so it is still shown, as the
|
||||||
|
/// whole of `rest`.
|
||||||
|
pub fn parse_tool_input(tool: &str, input: &str) -> ToolInput {
|
||||||
|
let Ok(Value::Object(json)) = serde_json::from_str::<Value>(input) else {
|
||||||
|
return ToolInput {
|
||||||
|
rest: match input.trim().is_empty() {
|
||||||
|
true => Vec::new(),
|
||||||
|
false => vec![input.to_string()],
|
||||||
|
},
|
||||||
|
..ToolInput::default()
|
||||||
|
};
|
||||||
|
};
|
||||||
|
parse_object(tool, &json)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_object(tool: &str, json: &Map<String, Value>) -> ToolInput {
|
||||||
|
let (subject_key, language) = SUBJECTS
|
||||||
|
.iter()
|
||||||
|
.find(|(name, ..)| *name == tool)
|
||||||
|
.map(|(_, key, language)| (Some(*key), *language))
|
||||||
|
.unwrap_or((None, None));
|
||||||
|
let subject = subject_key.and_then(|key| non_blank(json.get(key)));
|
||||||
|
let description = DESCRIPTIONS
|
||||||
|
.iter()
|
||||||
|
.find_map(|key| non_blank(json.get(*key)));
|
||||||
|
let timeout = non_blank(json.get("timeout")).map(|t| format_millis_text(&t));
|
||||||
|
|
||||||
|
// Sorted, so the leftovers are in the same order every time this call
|
||||||
|
// is drawn rather than in whatever order the JSON happened to arrive
|
||||||
|
// in. A field is left out only when it is already drawn somewhere
|
||||||
|
// else on the card.
|
||||||
|
let mut keys: Vec<&String> = json
|
||||||
|
.keys()
|
||||||
|
.filter(|k| Some(k.as_str()) != subject_key || subject.is_none())
|
||||||
|
.filter(|k| !DESCRIPTIONS.contains(&k.as_str()) || description.is_none())
|
||||||
|
.filter(|k| k.as_str() != "timeout" || timeout.is_none())
|
||||||
|
.collect();
|
||||||
|
keys.sort();
|
||||||
|
let rest = keys
|
||||||
|
.into_iter()
|
||||||
|
.map(|key| format!("{key}: {}", as_text(&json[key])))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
ToolInput {
|
||||||
|
subject,
|
||||||
|
language,
|
||||||
|
description,
|
||||||
|
timeout,
|
||||||
|
rest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_tool_in_the_table_has_its_own_subject() {
|
||||||
|
// One assertion per row of `SUBJECTS`, because the table is the
|
||||||
|
// whole of the rule and a row lost in an edit would otherwise
|
||||||
|
// only show up as a card with no summary line.
|
||||||
|
let cases = [
|
||||||
|
("Bash", r#"{"command":"ls -la"}"#, "ls -la"),
|
||||||
|
("Read", r#"{"file_path":"/tmp/x.rs"}"#, "/tmp/x.rs"),
|
||||||
|
("Write", r#"{"file_path":"/tmp/y.rs"}"#, "/tmp/y.rs"),
|
||||||
|
("Edit", r#"{"file_path":"/tmp/z.rs"}"#, "/tmp/z.rs"),
|
||||||
|
("Glob", r#"{"pattern":"**/*.rs"}"#, "**/*.rs"),
|
||||||
|
("Grep", r#"{"pattern":"fn main"}"#, "fn main"),
|
||||||
|
("WebFetch", r#"{"url":"https://x/y"}"#, "https://x/y"),
|
||||||
|
];
|
||||||
|
for (tool, input, expected) in cases {
|
||||||
|
let parsed = parse_tool_input(tool, input);
|
||||||
|
assert_eq!(parsed.subject.as_deref(), Some(expected), "{tool}");
|
||||||
|
assert_eq!(parsed.title(), Some(expected), "{tool}");
|
||||||
|
assert!(parsed.rest.is_empty(), "{tool}: {:?}", parsed.rest);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
parse_tool_input("Bash", r#"{"command":"ls"}"#).language,
|
||||||
|
Some(Language::Shell),
|
||||||
|
"a Bash command is shell, and is the one row that names a language"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tools_own_description_is_what_the_one_line_says() {
|
||||||
|
// The description wins over the subject: it is the tool's own
|
||||||
|
// prose about what this call is for, which is what a reader
|
||||||
|
// scanning a collapsed run is looking for.
|
||||||
|
let parsed = parse_tool_input(
|
||||||
|
"Bash",
|
||||||
|
r#"{"command":"cargo test -p iris","description":"Run the iris tests"}"#,
|
||||||
|
);
|
||||||
|
assert_eq!(parsed.title(), Some("Run the iris tests"));
|
||||||
|
assert_eq!(parsed.subject.as_deref(), Some("cargo test -p iris"));
|
||||||
|
assert!(parsed.rest.is_empty(), "{:?}", parsed.rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_timeout_is_read_as_a_span_and_kept_apart_from_the_rest() {
|
||||||
|
let parsed = parse_tool_input("Bash", r#"{"command":"sleep 500","timeout":480000}"#);
|
||||||
|
assert_eq!(parsed.timeout.as_deref(), Some("8m"));
|
||||||
|
assert!(parsed.rest.is_empty(), "{:?}", parsed.rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_field_not_drawn_elsewhere_is_still_shown() {
|
||||||
|
// The half the "never dropped" promise is about: a tool this
|
||||||
|
// build has never heard of has no subject, so *everything* is
|
||||||
|
// rest -- and a known tool's extra fields are too.
|
||||||
|
let parsed = parse_tool_input(
|
||||||
|
"Edit",
|
||||||
|
r#"{"file_path":"/a.rs","old_string":"x","new_string":"y","replace_all":true}"#,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.rest,
|
||||||
|
vec![
|
||||||
|
"new_string: y".to_string(),
|
||||||
|
"old_string: x".to_string(),
|
||||||
|
"replace_all: true".to_string(),
|
||||||
|
],
|
||||||
|
"sorted, and a non-string value written as JSON"
|
||||||
|
);
|
||||||
|
let unknown = parse_tool_input("SomeNewTool", r#"{"b":2,"a":"one"}"#);
|
||||||
|
assert_eq!(unknown.subject, None);
|
||||||
|
assert_eq!(unknown.rest, vec!["a: one".to_string(), "b: 2".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_that_is_not_an_object_is_still_the_input() {
|
||||||
|
// Older transcripts and some tools send a bare string; a card
|
||||||
|
// that dropped it would claim the call had no input at all.
|
||||||
|
assert_eq!(
|
||||||
|
parse_tool_input("Bash", "just a string").rest,
|
||||||
|
vec!["just a string".to_string()]
|
||||||
|
);
|
||||||
|
assert_eq!(parse_tool_input("Bash", " ").rest, Vec::<String>::new());
|
||||||
|
assert_eq!(parse_tool_input("Bash", "").title(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_blank_subject_is_no_subject_rather_than_an_empty_summary_line() {
|
||||||
|
let parsed = parse_tool_input("Bash", r#"{"command":" ","other":1}"#);
|
||||||
|
assert_eq!(parsed.subject, None);
|
||||||
|
assert_eq!(parsed.title(), None);
|
||||||
|
// Not dropped just because it was blank -- it is still a field
|
||||||
|
// the call carried.
|
||||||
|
assert_eq!(
|
||||||
|
parsed.rest,
|
||||||
|
vec!["command: ".to_string(), "other: 1".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,11 @@ pub enum TranscriptItem {
|
|||||||
input: String,
|
input: String,
|
||||||
output: String,
|
output: String,
|
||||||
done: bool,
|
done: bool,
|
||||||
|
/// Whether the result that arrived said the call failed
|
||||||
|
/// ([`Event::ToolEnd`]'s `is_error`). Meaningless while `done` is
|
||||||
|
/// false, and [`ToolState::of`] is the only thing that reads the
|
||||||
|
/// pair, so the two cannot be combined wrongly at a call site.
|
||||||
|
failed: bool,
|
||||||
asks: Vec<QuestionCard>,
|
asks: Vec<QuestionCard>,
|
||||||
images: Vec<String>,
|
images: Vec<String>,
|
||||||
},
|
},
|
||||||
@@ -352,6 +357,7 @@ pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<T
|
|||||||
let &TranscriptItem::ToolRun {
|
let &TranscriptItem::ToolRun {
|
||||||
ref output,
|
ref output,
|
||||||
done,
|
done,
|
||||||
|
failed,
|
||||||
asks: ref half_asks,
|
asks: ref half_asks,
|
||||||
images: ref half_images,
|
images: ref half_images,
|
||||||
..
|
..
|
||||||
@@ -367,6 +373,7 @@ pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<T
|
|||||||
input,
|
input,
|
||||||
output: output.clone(),
|
output: output.clone(),
|
||||||
done,
|
done,
|
||||||
|
failed,
|
||||||
// Kept from both halves: a question or an image can be
|
// Kept from both halves: a question or an image can be
|
||||||
// attached to either, depending on which side of the
|
// attached to either, depending on which side of the
|
||||||
// boundary its event fell.
|
// boundary its event fell.
|
||||||
@@ -562,6 +569,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
input: input.to_string(),
|
input: input.to_string(),
|
||||||
output: String::new(),
|
output: String::new(),
|
||||||
done: false,
|
done: false,
|
||||||
|
failed: false,
|
||||||
asks: Vec::new(),
|
asks: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
});
|
});
|
||||||
@@ -572,15 +580,23 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
*out = output.clone();
|
*out = output.clone();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
Event::ToolEnd { id, output } => {
|
Event::ToolEnd {
|
||||||
|
id,
|
||||||
|
output,
|
||||||
|
is_error,
|
||||||
|
} => {
|
||||||
if items.iter().any(|i| i.as_tool_run() == Some(id.as_str())) {
|
if items.iter().any(|i| i.as_tool_run() == Some(id.as_str())) {
|
||||||
update_tool(items, id, |item| {
|
update_tool(items, id, |item| {
|
||||||
if let TranscriptItem::ToolRun {
|
if let TranscriptItem::ToolRun {
|
||||||
output: out, done, ..
|
output: out,
|
||||||
|
done,
|
||||||
|
failed,
|
||||||
|
..
|
||||||
} = item
|
} = item
|
||||||
{
|
{
|
||||||
*out = output.clone();
|
*out = output.clone();
|
||||||
*done = true;
|
*done = true;
|
||||||
|
*failed = *is_error;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -594,6 +610,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
input: String::new(),
|
input: String::new(),
|
||||||
output: output.clone(),
|
output: output.clone(),
|
||||||
done: true,
|
done: true,
|
||||||
|
failed: *is_error,
|
||||||
asks: Vec::new(),
|
asks: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
});
|
});
|
||||||
@@ -650,6 +667,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
done,
|
done,
|
||||||
|
failed,
|
||||||
images,
|
images,
|
||||||
} if asks.iter().any(|a| &a.id == id) => {
|
} if asks.iter().any(|a| &a.id == id) => {
|
||||||
for ask in asks.iter_mut() {
|
for ask in asks.iter_mut() {
|
||||||
@@ -665,6 +683,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
done,
|
done,
|
||||||
|
failed,
|
||||||
asks,
|
asks,
|
||||||
images,
|
images,
|
||||||
}
|
}
|
||||||
@@ -750,6 +769,74 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What became of one tool call -- every state a card has to be able to
|
||||||
|
/// draw, including the two that are not answers.
|
||||||
|
///
|
||||||
|
/// The pair this enum exists for is [`ToolState::Succeeded`] against
|
||||||
|
/// [`ToolState::NoResult`]. A call that finished having printed nothing
|
||||||
|
/// and a call whose result never arrived both leave an empty `output`,
|
||||||
|
/// and drawing them the same way states a verdict nobody reached: "it
|
||||||
|
/// worked and said nothing" reads as a fact, where the truth is that the
|
||||||
|
/// turn ended before anything came back.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ToolState {
|
||||||
|
/// Started, no result yet, and the session is still working -- the
|
||||||
|
/// ordinary state of a call in flight.
|
||||||
|
Running,
|
||||||
|
/// Stopped on the reader: a permission or question this call carries
|
||||||
|
/// has not been answered, so nothing is happening until somebody
|
||||||
|
/// answers it. Distinct from [`Self::Running`] because whose move it
|
||||||
|
/// is differs, which is the Compose card's "your turn".
|
||||||
|
Deciding,
|
||||||
|
/// A result arrived and the tool did not report a failure.
|
||||||
|
Succeeded,
|
||||||
|
/// A result arrived and the tool reported that the call failed
|
||||||
|
/// (`is_error`).
|
||||||
|
Failed,
|
||||||
|
/// No result ever arrived and the session is not working any more --
|
||||||
|
/// the turn was interrupted, or the process went away. Not a verdict
|
||||||
|
/// on the call: it says only that nobody found out.
|
||||||
|
NoResult,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolState {
|
||||||
|
/// The state of one call. `session_working` is
|
||||||
|
/// [`session_working`]'s answer for the session this call is in --
|
||||||
|
/// the only thing here that is not a property of the call itself, and
|
||||||
|
/// what separates "still running" from "never came back".
|
||||||
|
///
|
||||||
|
/// Written once, over the fields rather than per call site, because
|
||||||
|
/// the five states are decided by four conditions and every place
|
||||||
|
/// that re-derived a subset of them got a different subset.
|
||||||
|
pub fn of(item: &TranscriptItem, session_working: bool) -> Option<Self> {
|
||||||
|
let TranscriptItem::ToolRun {
|
||||||
|
done, failed, asks, ..
|
||||||
|
} = item
|
||||||
|
else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
debug_assert!(
|
||||||
|
!failed || *done,
|
||||||
|
"a call cannot have failed before its result arrived"
|
||||||
|
);
|
||||||
|
Some(if asks.iter().any(|ask| ask.answers.is_empty()) {
|
||||||
|
// Ahead of `done`: a call waiting on permission has not
|
||||||
|
// finished either, and which of the two the reader is being
|
||||||
|
// told about is the one they can act on.
|
||||||
|
Self::Deciding
|
||||||
|
} else if !*done {
|
||||||
|
match session_working {
|
||||||
|
true => Self::Running,
|
||||||
|
false => Self::NoResult,
|
||||||
|
}
|
||||||
|
} else if *failed {
|
||||||
|
Self::Failed
|
||||||
|
} else {
|
||||||
|
Self::Succeeded
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One row as the transcript draws it: a run of consecutive tool calls, or
|
/// One row as the transcript draws it: a run of consecutive tool calls, or
|
||||||
/// anything else. Ported from `ToolRows.kt`'s `TranscriptRow` and
|
/// anything else. Ported from `ToolRows.kt`'s `TranscriptRow` and
|
||||||
/// `groupToolRuns` -- the Compose card rendering in that file is not part
|
/// `groupToolRuns` -- the Compose card rendering in that file is not part
|
||||||
@@ -989,6 +1076,7 @@ mod tests {
|
|||||||
Event::ToolEnd {
|
Event::ToolEnd {
|
||||||
id: "x".to_string(),
|
id: "x".to_string(),
|
||||||
output: "done".to_string(),
|
output: "done".to_string(),
|
||||||
|
is_error: false,
|
||||||
},
|
},
|
||||||
)]);
|
)]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1001,6 +1089,7 @@ mod tests {
|
|||||||
input: String::new(),
|
input: String::new(),
|
||||||
output: "done".to_string(),
|
output: "done".to_string(),
|
||||||
done: true,
|
done: true,
|
||||||
|
failed: false,
|
||||||
asks: Vec::new(),
|
asks: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
}]
|
}]
|
||||||
@@ -1166,6 +1255,7 @@ mod tests {
|
|||||||
Event::ToolEnd {
|
Event::ToolEnd {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
output: output.to_string(),
|
output: output.to_string(),
|
||||||
|
is_error: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1211,6 +1301,7 @@ mod tests {
|
|||||||
input: "{}".to_string(),
|
input: "{}".to_string(),
|
||||||
output: "the result".to_string(),
|
output: "the result".to_string(),
|
||||||
done: true,
|
done: true,
|
||||||
|
failed: false,
|
||||||
asks: Vec::new(),
|
asks: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
}],
|
}],
|
||||||
@@ -1269,6 +1360,7 @@ mod tests {
|
|||||||
input: "{}".to_string(),
|
input: "{}".to_string(),
|
||||||
output: String::new(),
|
output: String::new(),
|
||||||
done: false,
|
done: false,
|
||||||
|
failed: false,
|
||||||
asks: Vec::new(),
|
asks: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
}];
|
}];
|
||||||
@@ -1279,3 +1371,150 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`ToolState`] is what a card colours itself by, so each of its five
|
||||||
|
/// states is asserted from the events that actually produce it rather than
|
||||||
|
/// from a hand-built item -- a mapping that agreed with a fixture and
|
||||||
|
/// disagreed with the fold would be invisible until it was on screen.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tool_state_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn event(seq: u64, e: Event) -> SeqEvent {
|
||||||
|
SeqEvent {
|
||||||
|
seq,
|
||||||
|
ts: 0.0,
|
||||||
|
event: e,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fold_all(events: &[SeqEvent]) -> Vec<TranscriptItem> {
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.fold(Vec::new(), |items, e| fold_event(&items, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(id: &str) -> SeqEvent {
|
||||||
|
event(
|
||||||
|
1,
|
||||||
|
Event::ToolStart {
|
||||||
|
id: id.to_string(),
|
||||||
|
tool: "Bash".to_string(),
|
||||||
|
input: serde_json::json!({"command": "ls"}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn end(id: &str, output: &str, is_error: bool) -> SeqEvent {
|
||||||
|
event(
|
||||||
|
2,
|
||||||
|
Event::ToolEnd {
|
||||||
|
id: id.to_string(),
|
||||||
|
output: output.to_string(),
|
||||||
|
is_error,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_of(events: &[SeqEvent], session_working: bool) -> ToolState {
|
||||||
|
let items = fold_all(events);
|
||||||
|
ToolState::of(&items[0], session_working).expect("the fixture's first item is a tool call")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_result_that_arrived_is_read_from_is_error() {
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a"), end("a", "ok", false)], false),
|
||||||
|
ToolState::Succeeded
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a"), end("a", "No such file", true)], false),
|
||||||
|
ToolState::Failed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pair this enum exists for. Both calls have an empty `output`
|
||||||
|
/// and nothing else distinguishes them, so a card that only looked at
|
||||||
|
/// the text would draw the interrupted one as a call that ran fine and
|
||||||
|
/// printed nothing.
|
||||||
|
#[test]
|
||||||
|
fn a_call_that_printed_nothing_is_not_a_call_that_never_answered() {
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a"), end("a", "", false)], false),
|
||||||
|
ToolState::Succeeded,
|
||||||
|
"a result arrived; it was empty"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a")], false),
|
||||||
|
ToolState::NoResult,
|
||||||
|
"no result, and the session is not working any more"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same call, mid-turn: still running rather than abandoned. The
|
||||||
|
/// only thing separating the two is the session's own status, which is
|
||||||
|
/// why `of` takes it.
|
||||||
|
#[test]
|
||||||
|
fn no_result_while_the_session_works_is_still_running() {
|
||||||
|
assert_eq!(state_of(&[start("a")], true), ToolState::Running);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unanswered_ask_is_the_readers_move_whatever_else_is_true() {
|
||||||
|
let asking = event(
|
||||||
|
3,
|
||||||
|
Event::Question {
|
||||||
|
id: "q1".to_string(),
|
||||||
|
prompt: "Allow?".to_string(),
|
||||||
|
header: None,
|
||||||
|
options: vec![QuestionOption {
|
||||||
|
label: "Allow".to_string(),
|
||||||
|
description: None,
|
||||||
|
preview: None,
|
||||||
|
}],
|
||||||
|
multi_select: false,
|
||||||
|
about: Some("a".to_string()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let answered = event(
|
||||||
|
4,
|
||||||
|
Event::Answered {
|
||||||
|
id: "q1".to_string(),
|
||||||
|
answers: vec!["Allow".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Ahead of both "still running" and "no result": the reader can
|
||||||
|
// act on this one, and cannot act on either of those.
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a"), asking.clone()], true),
|
||||||
|
ToolState::Deciding
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a"), asking.clone()], false),
|
||||||
|
ToolState::Deciding
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state_of(
|
||||||
|
&[start("a"), asking, answered, end("a", "ok", false)],
|
||||||
|
false
|
||||||
|
),
|
||||||
|
ToolState::Succeeded,
|
||||||
|
"once it is answered the call is an ordinary one again"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_but_a_tool_call_has_a_tool_state() {
|
||||||
|
assert_eq!(
|
||||||
|
ToolState::of(
|
||||||
|
&TranscriptItem::UserMsg {
|
||||||
|
seq: 1,
|
||||||
|
text: "hi".to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
},
|
||||||
|
true
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,45 @@ they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md
|
|||||||
for iris API changes); this file is only the summary. Newest first. Items
|
for iris API changes); this file is only the summary. Newest first. Items
|
||||||
marked **DEFERRED** are ones the agent chose not to decide alone.
|
marked **DEFERRED** are ones the agent chose not to decide alone.
|
||||||
|
|
||||||
|
## 2026-09-06 (how a tool call looks, P1b)
|
||||||
|
|
||||||
|
- **A card that never got a result says "no result", in yellow, and it is
|
||||||
|
a state Compose cannot say.** A call that finished having printed
|
||||||
|
nothing and a call whose turn was interrupted before anything came back
|
||||||
|
both leave an empty output. Compose draws both as an ordinary finished
|
||||||
|
call, which reads as a fact somebody established. There are five states
|
||||||
|
now, each with a word and a colour: nothing at all for a call that
|
||||||
|
worked, "running" (grey), "your turn" (peach, Compose's own wording and
|
||||||
|
colour), "failed" (red), "no result" (yellow).
|
||||||
|
|
||||||
|
- **A failed call is drawn as failed, which needed a field on the wire.**
|
||||||
|
`is_error` is on the CLI's `tool_result` and was being dropped; the
|
||||||
|
server now carries it to the phone. Reversible, but the alternative is a
|
||||||
|
card that says a call succeeded because it cannot tell.
|
||||||
|
|
||||||
|
- **A group's cards do not each carry their own surface.** Compose gives
|
||||||
|
each card a fill and squares the corners where it faces a neighbour, so
|
||||||
|
a run reads as one object broken into parts. iris has no per-corner
|
||||||
|
radius, and -- more to the point -- a group built the way Compose builds
|
||||||
|
it hit a framework layout defect that drew every card's text a card
|
||||||
|
below its own box. So a group is one surface with its cards on it,
|
||||||
|
separated by a small gap, and the 4dp inset Compose holds them off the
|
||||||
|
edge by is gone. Worth revisiting once the layout defect is fixed
|
||||||
|
(docs/IRIS_TODO.md).
|
||||||
|
|
||||||
|
- **A long tool output is capped at 80 lines or 4 kB with a "Show all N
|
||||||
|
lines".** Compose draws the whole thing, and gets away with it because
|
||||||
|
its `Text` inside a `LazyColumn` lays out lazily; here the output is one
|
||||||
|
text widget and shaping a hundred kilobytes of it costs what the file
|
||||||
|
editor's 32 kB limit was measured against. If iris's text gets cheaper,
|
||||||
|
this is the number to move.
|
||||||
|
|
||||||
|
- **A card's command is clipped, not pannable, and its summary line is
|
||||||
|
clipped rather than ellipsised.** Both are framework gaps rather than
|
||||||
|
choices (`scrollable_on` on a non-editable text draws nothing; there is
|
||||||
|
no overflow ellipsis), and both are worse than Compose today. Named here
|
||||||
|
because they are visible.
|
||||||
|
|
||||||
## 2026-09-06 (how a markdown block looks, P1a)
|
## 2026-09-06 (how a markdown block looks, P1a)
|
||||||
|
|
||||||
- **A table is drawn as padded monospace columns, not as a grid.** Your
|
- **A table is drawn as padded monospace columns, not as a grid.** Your
|
||||||
|
|||||||
@@ -8,6 +8,51 @@ capability that moved. Small and trivial changes do not go here.
|
|||||||
An entry gives the date, what changed, why, and a short before/after where
|
An entry gives the date, what changed, why, and a short before/after where
|
||||||
it helps judge the change without the session that made it. Newest first.
|
it helps judge the change without the session that made it. Newest first.
|
||||||
|
|
||||||
|
## 2026-09-06: tool cards, `ToolState`, and a screen that knows whether its session is working
|
||||||
|
|
||||||
|
`transcript_ui::tool` is new: a card per tool call, a group per run
|
||||||
|
(P1b). Three things in the public surface follow from it.
|
||||||
|
|
||||||
|
**`client_core::transcript_fold::ToolState`** is what a card colours
|
||||||
|
itself by -- `Running`, `Deciding`, `Succeeded`, `Failed`, `NoResult` --
|
||||||
|
built by `ToolState::of(&item, session_working)`. The pair it exists for
|
||||||
|
is `Succeeded` against `NoResult`: a call that finished having printed
|
||||||
|
nothing and a call whose result never arrived both leave an empty
|
||||||
|
`output`, and drawing them the same way states a verdict nobody reached.
|
||||||
|
Only the session's own status separates them, which is why `of` takes it.
|
||||||
|
|
||||||
|
**`event_model::Event::ToolEnd` gained `is_error`** (`#[serde(default)]`,
|
||||||
|
so an older transcript still parses), and
|
||||||
|
`client_core::transcript_fold::TranscriptItem::ToolRun` gained `failed`.
|
||||||
|
Without them a result was everything a card knew and a broken call drew
|
||||||
|
exactly as confidently as one that worked -- the missing state, not a
|
||||||
|
wrong one. Every construction site of both had to gain a field; the value
|
||||||
|
comes from the CLI's own `tool_result`, read in one place
|
||||||
|
(`import::tool_result_is_error`) by both the live translator and the
|
||||||
|
import replay.
|
||||||
|
|
||||||
|
**`TranscriptScreen::set_session_working(rsc, bool)`** is new, and is the
|
||||||
|
only thing that writes it. Before: a card with no result was drawn the
|
||||||
|
same whether its turn was still going or had been interrupted. After:
|
||||||
|
only the *newest* row can say "running", because every row behind it
|
||||||
|
belongs to a turn that has ended, and changing the flag redraws that one
|
||||||
|
row rather than the screen. `TranscriptScreen::expand_tail_tools(rsc,
|
||||||
|
bool)` joins it, answering whether there was a tool run to act on -- a
|
||||||
|
group's expanded appearance is otherwise unreachable from anything that
|
||||||
|
cannot press the screen.
|
||||||
|
|
||||||
|
**`transcript_ui::row::build_row` now returns a `TailRow`** rather than an
|
||||||
|
`Option<RowBlocks>`: `Blocks` for a message (a delta costs the last
|
||||||
|
markdown block) or `Tools` for a run (an arriving result costs one card).
|
||||||
|
One mechanism for "what can this row change cheaply", asked of the row
|
||||||
|
rather than decided again at each call site. It also takes the row's own
|
||||||
|
`working` flag.
|
||||||
|
|
||||||
|
Two smaller ones. `client_core::tool_summary::parse_tool_input` is
|
||||||
|
`ToolInput.kt`'s subject/description/timeout/rest split, and
|
||||||
|
`client_core::durations::format_millis` is `Durations.kt`'s -- both pure,
|
||||||
|
both with the Kotlin's own tests ported.
|
||||||
|
|
||||||
## 2026-09-06: a tap is its own gesture outcome, and opening a URL is a backend capability
|
## 2026-09-06: a tap is its own gesture outcome, and opening a URL is a backend capability
|
||||||
|
|
||||||
Three related additions, all for following a markdown link.
|
Three related additions, all for following a markdown link.
|
||||||
|
|||||||
@@ -695,6 +695,49 @@ Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
|
|||||||
it is its own item, most likely the report pane not masking or not
|
it is its own item, most likely the report pane not masking or not
|
||||||
claiming its region.
|
claiming its region.
|
||||||
|
|
||||||
|
## Found by P1b (2026-09-06), all with a headless repro
|
||||||
|
|
||||||
|
Each was found by looking at `iris/run-headless.sh transcript -- -p
|
||||||
|
transcript-ui` rather than at a diff, and each is worked around in
|
||||||
|
`transcript-ui/src/tool.rs` rather than fixed here. docs/RUST.md's P1b box
|
||||||
|
has the fuller account.
|
||||||
|
|
||||||
|
- [ ] **A `Span` of `Pad`ded children inside another `Span` places those
|
||||||
|
children a slot out of step.** Each child drew its content one sibling's
|
||||||
|
height below its own box. Repro: `IRIS_TOOLS_EXPANDED=1
|
||||||
|
iris/run-headless.sh transcript --shot /tmp/x.png -- -p transcript-ui`
|
||||||
|
with `tool.rs`'s group built as `Span(DOWN)[header, Pad(Span(DOWN)
|
||||||
|
[cards]), bar]` instead of the single `Span` it uses now. Bisected:
|
||||||
|
removing the inner `Span` fixes it, and so does removing the children's
|
||||||
|
own `Pad`; the background `Stack`, the `Sized` wrappers and the
|
||||||
|
`WidgetPtr` per child make no difference. **Not** the `mov`-vs-
|
||||||
|
`reposition` fault f5b8893 fixed -- it survives that commit. The
|
||||||
|
workaround costs the group the 4dp inset its Compose counterpart holds
|
||||||
|
its cards off the edge by, so this is worth fixing.
|
||||||
|
- [ ] **`scrollable_on(Axis::X)` on a non-editable `Text` draws nothing.**
|
||||||
|
The panel is drawn and the text inside it is not. A markdown fence does
|
||||||
|
the same to a `TextEdit` and is fine, so it is the widget kind rather
|
||||||
|
than the chain. `tool.rs`'s `raw_block` is `masked()` only until this is
|
||||||
|
fixed, which means a long command is clipped rather than pannable.
|
||||||
|
- [ ] **No overflow ellipsis.** `TextAttrs` can wrap or not wrap; there is
|
||||||
|
no "one line, ellipsised" the way `maxLines = 1` + `TextOverflow.
|
||||||
|
Ellipsis` gives Compose. A tool card's summary is clipped instead, so
|
||||||
|
nothing on screen says it was cut. Whichever end is cut has to be a
|
||||||
|
choice when this lands: a path is identified by its tail, a command by
|
||||||
|
its head.
|
||||||
|
- [ ] **A drawn chevron.** `Chevron.kt` draws its own strokes precisely
|
||||||
|
because a chevron from a font is a glyph a system font may not have --
|
||||||
|
and the bundled `NotoSans-Regular.ttf` indeed has no U+25B8/25BE/25B4,
|
||||||
|
while `NotoSansMono-Regular.ttf` does. `tool.rs` sets the mark in the
|
||||||
|
monospace face as a result. A real fix needs a line/path primitive;
|
||||||
|
iris has rects, text and textures only.
|
||||||
|
- [ ] **A tool card's text is not selectable.** `Selection` is keyed
|
||||||
|
`(RowKey, block index)` and a card has no markdown blocks, so nothing in
|
||||||
|
a card registers. Compose's `SelectionContainer` covers tool output,
|
||||||
|
which is the text people most want to copy. Needs a key for "the nth
|
||||||
|
text of this row" that a card can mint without colliding with a
|
||||||
|
message's blocks.
|
||||||
|
|
||||||
## Build (for the port)
|
## Build (for the port)
|
||||||
|
|
||||||
Widgets `RUST.md`'s "The port, in order (decided 2026-09-05)" needs and
|
Widgets `RUST.md`'s "The port, in order (decided 2026-09-05)" needs and
|
||||||
|
|||||||
+107
-6
@@ -5687,12 +5687,113 @@ device.
|
|||||||
correctly, and an emulator bench run with assertions live and no
|
correctly, and an emulator bench run with assertions live and no
|
||||||
abort (`2438 frames over 147.7s, p50 27.2ms`).
|
abort (`2438 frames over 147.7s, p50 27.2ms`).
|
||||||
|
|
||||||
- [ ] **P1b — tool-call cards and grouping.** `ToolRows.kt`/
|
- [x] **P1b — tool-call cards and grouping.** Done 2026-09-06.
|
||||||
`ToolInput.kt`'s cards: a collapsed row per call with name
|
`ToolRows.kt`/`ToolInput.kt` ported to
|
||||||
and a one-line summary, expand to input and output, runs of
|
`iris/transcript-ui/src/tool.rs` plus two new pure modules in
|
||||||
calls grouped (`adopt_run` already groups in `client-core`),
|
`client-core`. **Screenshots:
|
||||||
the busy/failed states, and the kilobyte outputs the fixture
|
`docs/bench/p1b-2026-09-06/iris-tools-collapsed.png` and
|
||||||
carries without laying them out while collapsed.
|
`iris-tools-expanded.png`**, both from
|
||||||
|
`iris/run-headless.sh transcript -- -p transcript-ui` on the
|
||||||
|
desktop/winit backend (the emulator was not touched this pass
|
||||||
|
-- another agent held this checkout's AVD). The expanded one
|
||||||
|
is taken with `IRIS_TOOLS_EXPANDED=1`, which the example reads
|
||||||
|
to call `TranscriptScreen::expand_tail_tools` -- the expanded
|
||||||
|
appearance is otherwise unreachable on a machine with no
|
||||||
|
display and no finger.
|
||||||
|
**What the cards look like, against `ToolRows.kt`:**
|
||||||
|
- *A collapsed card* -- a mark, the tool's name (14pt), the
|
||||||
|
one-line summary `parse_tool_input` derives (12pt, Subtext
|
||||||
|
0, one line, clipped), and the state word at the far right.
|
||||||
|
Same as Compose, except that Compose ellipsises the summary
|
||||||
|
and iris clips it: there is no overflow-ellipsis in
|
||||||
|
`TextAttrs` yet (IRIS_TODO).
|
||||||
|
- *An open card* -- the timeout at the top right, the tool's
|
||||||
|
own description, the subject in a `Verbatim` panel with
|
||||||
|
`client_core::highlight`'s spans, the leftover input fields
|
||||||
|
under it, then the output. Same order as Compose.
|
||||||
|
- *A group* -- "Called N tools" (Compose's exact wording, and
|
||||||
|
so the name a `ui-trace` script taps), the cards on a Mantle
|
||||||
|
surface, and a chevron bar at the foot that closes it from
|
||||||
|
the end the reader is looking at.
|
||||||
|
- *States* -- `client_core::transcript_fold::ToolState`, five
|
||||||
|
of them, each with its own word and colour: nothing for
|
||||||
|
`Succeeded`, "running" (Subtext 0), "your turn" (Peach, the
|
||||||
|
Compose card's own wording and colour), "failed" (Red) and
|
||||||
|
**"no result" (Yellow)**. The last two are new -- Compose
|
||||||
|
can say neither.
|
||||||
|
**Two things the port had to add to be able to say "it
|
||||||
|
broke".** `event_model::Event::ToolEnd` gained `is_error`
|
||||||
|
(`#[serde(default)]`), read from the CLI's own `tool_result`
|
||||||
|
by one function used by both the live translator and the
|
||||||
|
import replay (`import::tool_result_is_error`); without it a
|
||||||
|
result was all a card had and a failed call drew exactly as
|
||||||
|
confidently as one that worked. And `ToolState` separates
|
||||||
|
`Succeeded`-with-empty-output from `NoResult`: both leave the
|
||||||
|
same empty string, and only the session's own status tells
|
||||||
|
them apart, which is why `TranscriptScreen::
|
||||||
|
set_session_working` exists and why only the *newest* row can
|
||||||
|
be "running" (every row behind it belongs to a turn that has
|
||||||
|
ended).
|
||||||
|
**Pass condition, met**:
|
||||||
|
`collapsed_cards_shape_only_their_summary_lines`
|
||||||
|
(`transcript-ui/src/lib.rs`) opens a group of three cards
|
||||||
|
whose calls carry 88 kB of output each and asserts the
|
||||||
|
text-shape count equals the same group's over three bytes.
|
||||||
|
**17 either way.** Confirmed to be a real test, not a
|
||||||
|
tautology, by pushing the output block into the collapsed
|
||||||
|
branch: **17 against 20**.
|
||||||
|
`a_result_arriving_redraws_one_card_whatever_the_run_holds` is
|
||||||
|
the second: one `ToolEnd` costs the same number of
|
||||||
|
`Widget::draw` calls in a twelve-call run as in a three-call
|
||||||
|
one.
|
||||||
|
**Three defects found on the way, all by looking at the
|
||||||
|
render rather than at the diff:**
|
||||||
|
1. **A `Span` of `Pad`ded children inside another `Span`
|
||||||
|
places those children a slot out of step.** Every card drew
|
||||||
|
its content one card's height below its own box, so the
|
||||||
|
group read as empty bars with somebody else's summary in
|
||||||
|
them. Bisected against
|
||||||
|
`IRIS_TOOLS_EXPANDED=1 iris/run-headless.sh transcript`:
|
||||||
|
removing the inner `Span` fixes it, and so does removing
|
||||||
|
the cards' own `Pad`; the card background, the `Sized`
|
||||||
|
wrappers and the per-card `WidgetPtr` all make no
|
||||||
|
difference. Worked around by building the group as **one**
|
||||||
|
`Span` (header, cards, collapse bar), which costs the 4dp
|
||||||
|
inset Compose holds its cards off the group's edge by. The
|
||||||
|
framework defect is still open -- IRIS_TODO has it, and it
|
||||||
|
is not the `mov`/`reposition` one f5b8893 fixed (it
|
||||||
|
survives that commit).
|
||||||
|
2. **`scrollable_on(Axis::X)` on a non-editable `Text` draws
|
||||||
|
nothing at all** -- an empty panel where the command should
|
||||||
|
be. A markdown fence does the same thing to a `TextEdit`
|
||||||
|
and is fine. So a card's verbatim block is `masked()` and
|
||||||
|
clips rather than panning; when this is fixed the pan
|
||||||
|
belongs there too, because the long command is the one
|
||||||
|
being read closely.
|
||||||
|
3. **`NotoSans-Regular.ttf` has no U+25B8/25BE/25B4** (read
|
||||||
|
out of the bundled `cmap`s) while `NotoSansMono-Regular`
|
||||||
|
does, so the expander mark is set in the monospace face at
|
||||||
|
the one place the character is written. The old
|
||||||
|
`build_tools` summary drew that codepoint in the sans face,
|
||||||
|
which was a missing glyph nobody had looked closely enough
|
||||||
|
to see.
|
||||||
|
**Checks**: `cargo fmt --all --check` clean in both
|
||||||
|
workspaces; `cargo clippy -p iris -p iris-core -p
|
||||||
|
transcript-ui -p desktop-app -p tabs-ui --all-targets` and
|
||||||
|
`cargo clippy --all-targets` in `client-core`/`server`/
|
||||||
|
`event-model` warning-free; tests 86 (iris) + 13 (iris-core) +
|
||||||
|
36 (transcript-ui, +5) + 137 (client-core, +11) + 160
|
||||||
|
(server, +1).
|
||||||
|
**Not done**: nothing on the emulator or the phone (the AVD
|
||||||
|
was another agent's this pass, so no frame times were taken);
|
||||||
|
a card's text is not selectable, unlike Compose's, since
|
||||||
|
`Selection` is keyed per markdown block and a card has none
|
||||||
|
(IRIS_TODO); no per-corner radius, so the "connected stack"
|
||||||
|
shape `connectedShape` draws is a 2dp gap instead;
|
||||||
|
`AskUserQuestionBody`/`PermissionAsk`'s answer buttons are not
|
||||||
|
ported -- an unanswered ask forces its card open and says
|
||||||
|
"your turn", but there is nothing to press yet, which is P1d's
|
||||||
|
modal/controls work.
|
||||||
- [ ] **P1c — history paging and jump-to-latest.** Wire
|
- [ ] **P1c — history paging and jump-to-latest.** Wire
|
||||||
`client-core::transcript_source` into `transcript-ui`:
|
`client-core::transcript_source` into `transcript-ui`:
|
||||||
the opening page, paging back on scroll with the cushion
|
the opening page, paging back on scroll with the cushion
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
@@ -150,6 +150,19 @@ pub enum Event {
|
|||||||
ToolEnd {
|
ToolEnd {
|
||||||
id: String,
|
id: String,
|
||||||
output: String,
|
output: String,
|
||||||
|
/// Whether the tool reported that the call *failed*, from the
|
||||||
|
/// CLI's own `is_error` on the `tool_result`.
|
||||||
|
///
|
||||||
|
/// Added 2026-09-06 with the tool-call cards (RUST.md's P1b),
|
||||||
|
/// because without it a result is the only thing a card has and a
|
||||||
|
/// failed call is drawn as confidently as a successful one -- the
|
||||||
|
/// missing state, not a wrong one. `#[serde(default)]` so a
|
||||||
|
/// transcript written before this field, or a peer on an older
|
||||||
|
/// build, reads back as "not reported to have failed" rather than
|
||||||
|
/// failing to parse; that is the same claim the field's absence
|
||||||
|
/// used to make implicitly.
|
||||||
|
#[serde(default)]
|
||||||
|
is_error: bool,
|
||||||
},
|
},
|
||||||
/// An image the session produced or was sent, saved under the session
|
/// An image the session produced or was sent, saved under the session
|
||||||
/// dir and referenced by id; the phone fetches it by URL.
|
/// dir and referenced by id; the phone fetches it by URL.
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
//! with `ui-trace record --do "tap 'Tools'"` on Android, to prove
|
//! with `ui-trace record --do "tap 'Tools'"` on Android, to prove
|
||||||
//! hold-the-edge expand).
|
//! 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::*;
|
use iris::prelude::*;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -43,6 +44,75 @@ 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 {
|
||||||
|
tool_call_in("run1", id, tool, input, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same, in a named run. Two runs in one transcript must not share a
|
||||||
|
/// `run_id`: it is the row's identity in the list (`row::row_key`), and
|
||||||
|
/// two rows under one key is the duplicate-key fault AGENTS.md's
|
||||||
|
/// "Importing" section describes. Here it made two rows swap cached
|
||||||
|
/// heights and draw at each other's boxes.
|
||||||
|
fn tool_call_in(
|
||||||
|
run: &str,
|
||||||
|
id: &str,
|
||||||
|
tool: &str,
|
||||||
|
input: &str,
|
||||||
|
result: Option<(&str, bool)>,
|
||||||
|
) -> TranscriptItem {
|
||||||
|
TranscriptItem::ToolRun {
|
||||||
|
seq: 3,
|
||||||
|
id: id.into(),
|
||||||
|
run_id: run.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_in("run2", 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> {
|
fn synthetic_rows() -> Vec<FoldedRow> {
|
||||||
vec and a fenced block:\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```",
|
"# Sure\n\nHere's a [link to the repo](https://example.com/ai-app-2) 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![
|
FoldedRow::Tools(vec![
|
||||||
TranscriptItem::ToolRun {
|
tool_call(
|
||||||
seq: 3,
|
"t1",
|
||||||
id: "t1".into(),
|
"Read",
|
||||||
run_id: "run1".into(),
|
r#"{"file_path": "src/main.rs"}"#,
|
||||||
tool: "Read".into(),
|
Some(("fn main() {}\n", false)),
|
||||||
input: "{\"file\": \"src/main.rs\"}".into(),
|
),
|
||||||
output: "fn main() {}\n".into(),
|
tool_call(
|
||||||
done: true,
|
"t2",
|
||||||
asks: Vec::new(),
|
"Bash",
|
||||||
images: Vec::new(),
|
r#"{"command": "cargo build --release", "timeout": 480000, "description": "Build it"}"#,
|
||||||
},
|
Some((
|
||||||
TranscriptItem::ToolRun {
|
"error: could not compile `iris`\nCaused by: linker not found",
|
||||||
seq: 4,
|
true,
|
||||||
id: "t2".into(),
|
)),
|
||||||
run_id: "run1".into(),
|
),
|
||||||
tool: "Edit".into(),
|
tool_call("t3", "Grep", r#"{"pattern": "fn fold_event"}"#, None),
|
||||||
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(),
|
|
||||||
},
|
|
||||||
]),
|
]),
|
||||||
|
// 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!"),
|
msg(6, true, "Looks good, thanks!"),
|
||||||
// Every block kind `client_core::markdown_blocks` names, in one
|
// Every block kind `client_core::markdown_blocks` names, in one
|
||||||
// row, so P1a's appearance can be looked at against the Compose
|
// row, so P1a's appearance can be looked at against the Compose
|
||||||
@@ -151,6 +219,52 @@ impl DefaultAppState for Client {
|
|||||||
text: "clear".into(),
|
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_in(
|
||||||
|
"run2",
|
||||||
|
"t6",
|
||||||
|
"Read",
|
||||||
|
r#"{"file_path": "docs/RUST.md"}"#,
|
||||||
|
Some(("# Moving the app to Rust\n", false)),
|
||||||
|
),
|
||||||
|
tool_call_in(
|
||||||
|
"run2",
|
||||||
|
"t7",
|
||||||
|
"Bash",
|
||||||
|
r#"{"command": "cargo clippy --workspace --all-targets"}"#,
|
||||||
|
Some(("error: unused variable `x`", true)),
|
||||||
|
),
|
||||||
|
tool_call_in("run2", "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 }
|
Self { ui_state, screen }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+367
-33
@@ -47,6 +47,7 @@ pub mod composer;
|
|||||||
pub mod markdown;
|
pub mod markdown;
|
||||||
pub mod row;
|
pub mod row;
|
||||||
pub mod selection;
|
pub mod selection;
|
||||||
|
pub mod tool;
|
||||||
|
|
||||||
use client_core::transcript_fold::TranscriptRow as FoldedRow;
|
use client_core::transcript_fold::TranscriptRow as FoldedRow;
|
||||||
use iris::prelude::*;
|
use iris::prelude::*;
|
||||||
@@ -66,14 +67,17 @@ pub struct TranscriptScreen {
|
|||||||
/// interior mutability, per `push_row`'s existing `&self`). Drained by
|
/// interior mutability, per `push_row`'s existing `&self`). Drained by
|
||||||
/// [`Self::take_rebuilds`].
|
/// [`Self::take_rebuilds`].
|
||||||
rebuilds: std::cell::Cell<usize>,
|
rebuilds: std::cell::Cell<usize>,
|
||||||
/// The per-block widgets of the row at the live end of the list --
|
/// What the row at the live end of the list kept so the next event
|
||||||
/// the only row a streamed delta ever lands in -- so
|
/// can change part of it rather than all of it -- one markdown block
|
||||||
/// [`Self::apply`]'s `ReplaceLast` can replace one markdown block
|
/// of a streaming message (`row::RowBlocks::apply_delta`), or one card
|
||||||
/// instead of rebuilding the message
|
/// of a tool run whose result just arrived (`tool::ToolRow::
|
||||||
/// (`row::RowBlocks::apply_delta`). `None` for a tail that has no
|
/// apply_calls`). `None` before anything has been pushed. Its removal
|
||||||
/// delta path (a tool run) or before anything has been pushed. Its
|
/// is every path that replaces or drops the tail row, below.
|
||||||
/// removal is every path that replaces or drops the tail row, below.
|
tail: RefCell<Option<(RowKey, row::TailRow)>>,
|
||||||
tail: RefCell<Option<(RowKey, row::RowBlocks)>>,
|
/// 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 {
|
impl TranscriptScreen {
|
||||||
@@ -85,39 +89,117 @@ impl TranscriptScreen {
|
|||||||
where
|
where
|
||||||
Rsc::State: FocusHost + OpenUrl,
|
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.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
|
/// Whether the session this transcript belongs to is still doing
|
||||||
/// if this really is a delta into the same message, and say whether
|
/// something (`client_core::transcript_fold::session_working`).
|
||||||
/// 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
|
/// The one thing a tool card cannot read off its own call: a call with
|
||||||
/// `Single`, or a change `RowBlocks::apply_delta` will not take.
|
/// 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
|
fn apply_tail_delta<Rsc: HasEvents>(&self, rsc: &mut Rsc, key: RowKey, row: &FoldedRow) -> bool
|
||||||
where
|
where
|
||||||
Rsc::State: FocusHost + OpenUrl,
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
{
|
{
|
||||||
let FoldedRow::Single(item) = row else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let mut tail = self.tail.borrow_mut();
|
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;
|
return false;
|
||||||
};
|
};
|
||||||
if *tail_key != key {
|
if *tail_key != key {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let (sender, markdown_src) = row::item_content(item);
|
match (kept, row) {
|
||||||
blocks.apply_delta(
|
(row::TailRow::Blocks(blocks), FoldedRow::Single(item)) => {
|
||||||
rsc,
|
let (sender, markdown_src) = row::item_content(item);
|
||||||
self.list,
|
// A tool call is drawn as a card, never as markdown, so a
|
||||||
self.selection.clone(),
|
// row that kept blocks and now holds one is a different
|
||||||
key,
|
// row -- rebuild it.
|
||||||
sender,
|
if matches!(
|
||||||
&markdown_src,
|
item,
|
||||||
)
|
client_core::transcript_fold::TranscriptItem::ToolRun { .. }
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
blocks.apply_delta(
|
||||||
|
rsc,
|
||||||
|
self.list,
|
||||||
|
self.selection.clone(),
|
||||||
|
key,
|
||||||
|
sender,
|
||||||
|
&markdown_src,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
(row::TailRow::Tools(tools), FoldedRow::Tools(calls)) => {
|
||||||
|
tools.apply_calls(rsc, calls, self.session_working.get())
|
||||||
|
}
|
||||||
|
(row::TailRow::Tools(tools), FoldedRow::Single(item)) => {
|
||||||
|
tools.apply_calls(rsc, std::slice::from_ref(item), self.session_working.get())
|
||||||
|
}
|
||||||
|
(row::TailRow::Blocks(_), FoldedRow::Tools(_)) => false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply the effect of one more folded event without rebuilding the
|
/// Apply the effect of one more folded event without rebuilding the
|
||||||
@@ -196,11 +278,16 @@ impl TranscriptScreen {
|
|||||||
// pointing at widgets the `drop` below frees (the shape
|
// pointing at widgets the `drop` below frees (the shape
|
||||||
// docs/REVIEW-2026-09-06.md's finding 1 called out).
|
// docs/REVIEW-2026-09-06.md's finding 1 called out).
|
||||||
self.selection.borrow_mut().unregister(old_key);
|
self.selection.borrow_mut().unregister(old_key);
|
||||||
let (new_key, widget, blocks) =
|
let (new_key, widget, kept) = row::build_row(
|
||||||
row::build_row(rsc, self.list, self.selection.clone(), &new_rows[common]);
|
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));
|
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
|
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..] {
|
for row in &new_rows[common + 1..] {
|
||||||
self.push_row(rsc, row);
|
self.push_row(rsc, row);
|
||||||
}
|
}
|
||||||
@@ -279,9 +366,13 @@ where
|
|||||||
// say so.
|
// say so.
|
||||||
let mut tail = None;
|
let mut tail = None;
|
||||||
for row in &rows {
|
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));
|
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
|
// Wheel/trackpad scrolling -- the same idiom `trait_fns.rs`'s
|
||||||
@@ -339,6 +430,7 @@ where
|
|||||||
(
|
(
|
||||||
TranscriptScreen {
|
TranscriptScreen {
|
||||||
tail: RefCell::new(tail),
|
tail: RefCell::new(tail),
|
||||||
|
session_working: std::cell::Cell::new(false),
|
||||||
list,
|
list,
|
||||||
composer,
|
composer,
|
||||||
selection,
|
selection,
|
||||||
@@ -421,6 +513,7 @@ mod diff_tests {
|
|||||||
input: "x".to_string(),
|
input: "x".to_string(),
|
||||||
output: String::new(),
|
output: String::new(),
|
||||||
done: false,
|
done: false,
|
||||||
|
failed: false,
|
||||||
asks: Vec::new(),
|
asks: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
}
|
}
|
||||||
@@ -577,6 +670,7 @@ mod apply_tests {
|
|||||||
input: "x".to_string(),
|
input: "x".to_string(),
|
||||||
output: String::new(),
|
output: String::new(),
|
||||||
done: false,
|
done: false,
|
||||||
|
failed: false,
|
||||||
asks: Vec::new(),
|
asks: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
}
|
}
|
||||||
@@ -765,4 +859,244 @@ mod apply_tests {
|
|||||||
Vec2::new(10.0, 10.0),
|
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,12 @@ fn options() -> Options {
|
|||||||
/// (`highlight`'s module doc), so the offsets are walked once rather than
|
/// (`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
|
/// converted per span -- a fence is scanned on every delta that lands in
|
||||||
/// it, and it is the only block a delta re-renders.
|
/// 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()];
|
let code = &text[range.clone()];
|
||||||
// char index -> byte offset within `code`, plus the end, so a span's
|
// char index -> byte offset within `code`, plus the end, so a span's
|
||||||
// `end` is always in range.
|
// `end` is always in range.
|
||||||
|
|||||||
+38
-116
@@ -25,6 +25,7 @@
|
|||||||
|
|
||||||
use crate::markdown::{BlockFrame, Link, frame_of, render_block};
|
use crate::markdown::{BlockFrame, Link, frame_of, render_block};
|
||||||
use crate::selection::{SelKey, Selection};
|
use crate::selection::{SelKey, Selection};
|
||||||
|
use crate::tool::ToolRow;
|
||||||
use client_core::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks};
|
use client_core::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks};
|
||||||
use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
|
use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
|
||||||
use iris::prelude::*;
|
use iris::prelude::*;
|
||||||
@@ -461,105 +462,20 @@ where
|
|||||||
build_text_row(rsc, list, selection, key, sender, &markdown_src)
|
build_text_row(rsc, list, selection, key, sender, &markdown_src)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A run of adjacent tool calls: collapsed to a one-line summary by
|
/// What a row keeps so the next event can change part of it instead of
|
||||||
/// default, expanding in place to every call's own tool/input/output on
|
/// all of it -- one variant per kind of row that has such a path.
|
||||||
/// tap -- see the module doc for the hold-the-edge contract this wires
|
///
|
||||||
/// against `list`.
|
/// Two mechanisms would have been two answers to the same question ("what
|
||||||
fn build_tools<Rsc: HasEvents>(
|
/// can this row do cheaply?"), so the caller holds one of these for its
|
||||||
rsc: &mut Rsc,
|
/// tail row and asks it, rather than holding a `RowBlocks` and a
|
||||||
list: WeakWidget<List>,
|
/// `ToolRow` and choosing between them at each call site.
|
||||||
selection: Rc<RefCell<Selection>>,
|
pub enum TailRow {
|
||||||
key: RowKey,
|
/// A message: a column of one text widget per markdown block, so a
|
||||||
calls: Vec<TranscriptItem>,
|
/// streamed delta costs the last block.
|
||||||
) -> StrongWidget
|
Blocks(RowBlocks),
|
||||||
where
|
/// A tool call or a run of them: a column of cards, so an arriving
|
||||||
Rsc::State: FocusHost + OpenUrl,
|
/// result costs one card.
|
||||||
{
|
Tools(ToolRow),
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_row<Rsc: HasEvents>(
|
pub fn build_row<Rsc: HasEvents>(
|
||||||
@@ -567,26 +483,32 @@ pub fn build_row<Rsc: HasEvents>(
|
|||||||
list: WeakWidget<List>,
|
list: WeakWidget<List>,
|
||||||
selection: Rc<RefCell<Selection>>,
|
selection: Rc<RefCell<Selection>>,
|
||||||
row: &FoldedRow,
|
row: &FoldedRow,
|
||||||
) -> (RowKey, StrongWidget, Option<RowBlocks>)
|
working: bool,
|
||||||
|
) -> (RowKey, StrongWidget, Option<TailRow>)
|
||||||
where
|
where
|
||||||
Rsc::State: FocusHost + OpenUrl,
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
{
|
{
|
||||||
match row {
|
// A lone tool call is a card too, not a message with markdown in it:
|
||||||
FoldedRow::Single(item) => {
|
// `group_tool_runs` leaves one call as a `Single` because "Called 1
|
||||||
let key = row_key(&item.key());
|
// tool" hides a card to say the same thing in more words, and the
|
||||||
let (widget, blocks) = build_single(rsc, list, selection, key, item);
|
// *card* is what both cases draw (`ToolRows.kt`).
|
||||||
(key, widget, Some(blocks))
|
let calls = match row {
|
||||||
}
|
FoldedRow::Single(item @ TranscriptItem::ToolRun { .. }) => {
|
||||||
FoldedRow::Tools(calls) => {
|
Some(std::slice::from_ref(item))
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
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(TailRow::Blocks(blocks)))
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,897 @@
|
|||||||
|
//! 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;
|
||||||
|
/// 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;
|
||||||
|
/// A cap of nothing would draw an empty panel and a "Show all" for every
|
||||||
|
/// output there is, which reads as a rendering fault rather than as a cap.
|
||||||
|
/// Checked at compile time, since both are constants.
|
||||||
|
const _: () = assert!(OUTPUT_LINES > 0 && OUTPUT_BYTES > 0);
|
||||||
|
|
||||||
|
/// 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 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) {
|
||||||
|
// Only when the list actually has an extent for this row. `None`
|
||||||
|
// means the row has not been drawn yet -- which happens the moment
|
||||||
|
// something opens a group before the first frame
|
||||||
|
// (`TranscriptScreen::expand_tail_tools`, the headless screenshot) --
|
||||||
|
// and standing in `0.0` for it tells the layout pass to hold an edge
|
||||||
|
// at the top of the viewport that nothing was ever at. The whole list
|
||||||
|
// then places itself against that invented anchor: rows drawn at each
|
||||||
|
// other's cached heights, tool cards as empty bars with their text a
|
||||||
|
// group's height below them (`docs/bench/p1b-2026-09-06/`'s first
|
||||||
|
// attempt). Nothing to hold is not the same as an edge at zero.
|
||||||
|
if let Some((top, _bottom)) = (shared.list)(rsc).extent(shared.key) {
|
||||||
|
(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. A long line is **clipped** here rather than pannable, which a
|
||||||
|
/// markdown fence (`row.rs`'s `BlockFrame::Verbatim`) is not: adding
|
||||||
|
/// `.scrollable_on(Axis::X)` to this non-editable `Text` made it draw
|
||||||
|
/// nothing at all -- an empty panel where the command should be, seen on
|
||||||
|
/// 2026-09-06 in `docs/bench/p1b-2026-09-06/` and bisected to that one
|
||||||
|
/// call (the fence, which does the same thing to a `TextEdit`, is fine).
|
||||||
|
/// Recorded in docs/IRIS_TODO.md; when it is fixed, the pan belongs here
|
||||||
|
/// too, because the long command is the one being read closely.
|
||||||
|
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
|
||||||
|
.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)> {
|
||||||
|
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();
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
// The cards go straight into the group's own `Span`, not into a
|
||||||
|
// second one inside it. **A `Span` of `Pad`ded children inside another
|
||||||
|
// `Span` places those children a slot out of step** -- each card's
|
||||||
|
// content drew one card's height below its own box, so the cards read
|
||||||
|
// as empty bars with somebody else's summary in them. Bisected on
|
||||||
|
// 2026-09-06 against `iris/run-headless.sh transcript` with
|
||||||
|
// `IRIS_TOOLS_EXPANDED=1`: removing the inner `Span` fixes it and
|
||||||
|
// removing the cards' own `Pad` fixes it, while the card background,
|
||||||
|
// the `Sized` wrappers and the per-card `WidgetPtr` all make no
|
||||||
|
// difference. It is a framework defect rather than this file's --
|
||||||
|
// docs/RUST.md's P1b box and docs/IRIS_TODO.md carry the repro -- and
|
||||||
|
// one `Span` is the shape that works today. What it costs is the 4dp
|
||||||
|
// inset the Compose group holds its cards off its edge by; the cards'
|
||||||
|
// own padding stands in for it.
|
||||||
|
for index in 0..count {
|
||||||
|
group.push(build_card_ptr(rsc, shared, index).0);
|
||||||
|
}
|
||||||
|
// 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()),
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
// A call *joining* the run rebuilds the row's content rather than
|
||||||
|
// appending one card: the group's `Span` holds its collapse bar
|
||||||
|
// after the cards, and `Span::push` would put the new card behind
|
||||||
|
// it. That is still O(this row) -- every other row is untouched --
|
||||||
|
// and it is much rarer than a result arriving, which is the case
|
||||||
|
// the per-card `WidgetPtr` above exists for.
|
||||||
|
if calls.len() > old.len() {
|
||||||
|
let content = build_content(rsc, &self.shared);
|
||||||
|
self.shared.set_content(rsc, content);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -700,6 +700,7 @@ impl Translator {
|
|||||||
events.push(Event::ToolEnd {
|
events.push(Event::ToolEnd {
|
||||||
id: about.clone(),
|
id: about.clone(),
|
||||||
output: texts.join("\n"),
|
output: texts.join("\n"),
|
||||||
|
is_error: crate::session::import::tool_result_is_error(block),
|
||||||
});
|
});
|
||||||
// Deliberately does *not* finish a subagent `about` might name:
|
// Deliberately does *not* finish a subagent `about` might name:
|
||||||
// the Task tool runs in the background by default, so this
|
// the Task tool runs in the background by default, so this
|
||||||
@@ -1015,7 +1016,44 @@ mod tests {
|
|||||||
},
|
},
|
||||||
Event::ToolEnd {
|
Event::ToolEnd {
|
||||||
id: "toolu_01".to_string(),
|
id: "toolu_01".to_string(),
|
||||||
output: "probe-ok".to_string()
|
output: "probe-ok".to_string(),
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other half of the test above, and the one it cannot stand in
|
||||||
|
/// for: a call the tool itself reported as failed. Both lines are
|
||||||
|
/// `tool_result`s and both carry output, so nothing but `is_error`
|
||||||
|
/// tells them apart -- which is why dropping the field made a broken
|
||||||
|
/// call draw exactly like one that worked.
|
||||||
|
#[test]
|
||||||
|
fn a_failed_tool_result_says_so() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
|
||||||
|
let events = translate_lines(
|
||||||
|
&mut translator,
|
||||||
|
&[
|
||||||
|
r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_02","type":"tool_result","content":"No such file or directory","is_error":true}]},"parent_tool_use_id":null}"#,
|
||||||
|
// No `is_error` at all: every transcript written before
|
||||||
|
// the field was read looks like this, and it means the
|
||||||
|
// call was not reported to have failed.
|
||||||
|
r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_03","type":"tool_result","content":"fine"}]},"parent_tool_use_id":null}"#,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
events,
|
||||||
|
vec![
|
||||||
|
Event::ToolEnd {
|
||||||
|
id: "toolu_02".to_string(),
|
||||||
|
output: "No such file or directory".to_string(),
|
||||||
|
is_error: true,
|
||||||
|
},
|
||||||
|
Event::ToolEnd {
|
||||||
|
id: "toolu_03".to_string(),
|
||||||
|
output: "fine".to_string(),
|
||||||
|
is_error: false,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -1459,6 +1497,7 @@ mod tests {
|
|||||||
Event::ToolEnd {
|
Event::ToolEnd {
|
||||||
id: "toolu_05".to_string(),
|
id: "toolu_05".to_string(),
|
||||||
output: "took a screenshot".to_string(),
|
output: "took a screenshot".to_string(),
|
||||||
|
is_error: false,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ impl EchoDriver {
|
|||||||
self.emit(Event::ToolEnd {
|
self.emit(Event::ToolEnd {
|
||||||
id,
|
id,
|
||||||
output: format!("{label} step {index} finished"),
|
output: format!("{label} step {index} finished"),
|
||||||
|
is_error: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -645,6 +646,7 @@ impl EchoDriver {
|
|||||||
send(Event::ToolEnd {
|
send(Event::ToolEnd {
|
||||||
id,
|
id,
|
||||||
output: format!("call {i} finished"),
|
output: format!("call {i} finished"),
|
||||||
|
is_error: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
finish();
|
finish();
|
||||||
@@ -698,6 +700,7 @@ impl EchoDriver {
|
|||||||
send(Event::ToolEnd {
|
send(Event::ToolEnd {
|
||||||
id,
|
id,
|
||||||
output: format!("ran: {command}"),
|
output: format!("ran: {command}"),
|
||||||
|
is_error: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,6 +720,7 @@ impl EchoDriver {
|
|||||||
send(Event::ToolEnd {
|
send(Event::ToolEnd {
|
||||||
id,
|
id,
|
||||||
output: format!("echoed: {input}"),
|
output: format!("echoed: {input}"),
|
||||||
|
is_error: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -817,6 +821,7 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
|
|||||||
send(Event::ToolEnd {
|
send(Event::ToolEnd {
|
||||||
id,
|
id,
|
||||||
output: format!("beat {beat}: forty-two lines of nothing in particular"),
|
output: format!("beat {beat}: forty-two lines of nothing in particular"),
|
||||||
|
is_error: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// A run of three, which the app folds into one collapsed group -- the
|
// A run of three, which the app folds into one collapsed group -- the
|
||||||
@@ -829,9 +834,20 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
|
|||||||
tool: if i % 2 == 0 { "Bash" } else { "Grep" }.to_string(),
|
tool: if i % 2 == 0 { "Bash" } else { "Grep" }.to_string(),
|
||||||
input: serde_json::json!({ "command": format!("grep -rn 'beat {beat}' /tmp") }),
|
input: serde_json::json!({ "command": format!("grep -rn 'beat {beat}' /tmp") }),
|
||||||
});
|
});
|
||||||
|
// The middle one fails, so this fixture carries a run in
|
||||||
|
// which the three calls are not all in the same state --
|
||||||
|
// the case a card drawing every finished call the same way
|
||||||
|
// looks correct on. `is_error` is the CLI's own field
|
||||||
|
// (`import::tool_result_is_error`), and a driver that
|
||||||
|
// never sets it makes the failed appearance unreachable
|
||||||
|
// from the sandbox.
|
||||||
send(Event::ToolEnd {
|
send(Event::ToolEnd {
|
||||||
id,
|
id,
|
||||||
output: format!("beat {beat}, call {i} of 3"),
|
output: match i == 2 {
|
||||||
|
true => format!("beat {beat}, call {i} of 3: No such file or directory"),
|
||||||
|
false => format!("beat {beat}, call {i} of 3"),
|
||||||
|
},
|
||||||
|
is_error: i == 2,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -856,6 +872,7 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
|
|||||||
send(Event::ToolEnd {
|
send(Event::ToolEnd {
|
||||||
id,
|
id,
|
||||||
output: format!("beat {beat}: captured"),
|
output: format!("beat {beat}: captured"),
|
||||||
|
is_error: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Somebody else's voice, which is its own row shape.
|
// Somebody else's voice, which is its own row shape.
|
||||||
@@ -904,6 +921,7 @@ async fn run_helper(id: String, sink: EventSink, subagents: Arc<Subagents>) {
|
|||||||
Event::ToolEnd {
|
Event::ToolEnd {
|
||||||
id: tool_id,
|
id: tool_id,
|
||||||
output: "helper done".to_string(),
|
output: "helper done".to_string(),
|
||||||
|
is_error: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let target = Duration::from_secs(3);
|
let target = Duration::from_secs(3);
|
||||||
@@ -915,6 +933,7 @@ async fn run_helper(id: String, sink: EventSink, subagents: Arc<Subagents>) {
|
|||||||
let _ = sink.send(Event::ToolEnd {
|
let _ = sink.send(Event::ToolEnd {
|
||||||
id,
|
id,
|
||||||
output: "subagent finished".to_string(),
|
output: "subagent finished".to_string(),
|
||||||
|
is_error: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1069,6 +1088,7 @@ impl Driver for EchoDriver {
|
|||||||
self.emit(Event::ToolEnd {
|
self.emit(Event::ToolEnd {
|
||||||
id: call,
|
id: call,
|
||||||
output: format!("answered: {answer}"),
|
output: format!("answered: {answer}"),
|
||||||
|
is_error: false,
|
||||||
});
|
});
|
||||||
// The work carries on where it left off, which is what makes the
|
// The work carries on where it left off, which is what makes the
|
||||||
// asked-here row a boundary with a group on each side rather than
|
// asked-here row a boundary with a group on each side rather than
|
||||||
|
|||||||
@@ -363,6 +363,22 @@ fn is_hidden(record: &Value) -> bool {
|
|||||||
|| record.get("isMeta").and_then(Value::as_bool) == Some(true)
|
|| record.get("isMeta").and_then(Value::as_bool) == Some(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a `tool_result` block says the call itself failed.
|
||||||
|
///
|
||||||
|
/// One reader for the field rather than one per caller: the live
|
||||||
|
/// translator (`translate.rs`) and this replay of the CLI's own file look
|
||||||
|
/// at the same block shape, and a call drawn as failed in one and as
|
||||||
|
/// succeeded in the other would be the same conversation disagreeing with
|
||||||
|
/// itself. Absent means "not reported to have failed" -- which is what the
|
||||||
|
/// CLI writes for a call that went fine, and also what every transcript
|
||||||
|
/// written before this field was read says.
|
||||||
|
pub(crate) fn tool_result_is_error(block: &Value) -> bool {
|
||||||
|
block
|
||||||
|
.get("is_error")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
fn text_of(content: &Value) -> String {
|
fn text_of(content: &Value) -> String {
|
||||||
match content {
|
match content {
|
||||||
Value::String(text) => text.clone(),
|
Value::String(text) => text.clone(),
|
||||||
@@ -549,6 +565,7 @@ fn push_user(events: &mut Vec<Event>, content: &Value, session_dir: &std::path::
|
|||||||
events.push(Event::ToolEnd {
|
events.push(Event::ToolEnd {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
output: text_of(block.get("content").unwrap_or(&Value::Null)),
|
output: text_of(block.get("content").unwrap_or(&Value::Null)),
|
||||||
|
is_error: tool_result_is_error(block),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -744,6 +744,7 @@ mod tests {
|
|||||||
Event::ToolEnd {
|
Event::ToolEnd {
|
||||||
id: "t1".into(),
|
id: "t1".into(),
|
||||||
output: "done".into(),
|
output: "done".into(),
|
||||||
|
is_error: false,
|
||||||
},
|
},
|
||||||
Event::Image {
|
Event::Image {
|
||||||
image: "img1".into(),
|
image: "img1".into(),
|
||||||
|
|||||||
Reference in new issue
Block a user