diff --git a/docs/sonny/footer-counter-status.png b/docs/sonny/footer-counter-status.png new file mode 100644 index 000000000..e3471f289 Binary files /dev/null and b/docs/sonny/footer-counter-status.png differ diff --git a/docs/sonny/margin-gone-once-empty.png b/docs/sonny/margin-gone-once-empty.png new file mode 100644 index 000000000..4c448588e Binary files /dev/null and b/docs/sonny/margin-gone-once-empty.png differ diff --git a/docs/sonny/margin-opens-with-a-note.png b/docs/sonny/margin-opens-with-a-note.png new file mode 100644 index 000000000..3458afa8f Binary files /dev/null and b/docs/sonny/margin-opens-with-a-note.png differ diff --git a/docs/sonny/margin-task-notes-counter.png b/docs/sonny/margin-task-notes-counter.png new file mode 100644 index 000000000..41c78227a Binary files /dev/null and b/docs/sonny/margin-task-notes-counter.png differ diff --git a/docs/sonny/margin-task-notes-kept.png b/docs/sonny/margin-task-notes-kept.png new file mode 100644 index 000000000..fe0cee389 Binary files /dev/null and b/docs/sonny/margin-task-notes-kept.png differ diff --git a/sonny/README.md b/sonny/README.md index 896aafdc2..aac564134 100644 --- a/sonny/README.md +++ b/sonny/README.md @@ -670,11 +670,12 @@ the conversation, from the newest place back: ↑ or Enter steps older, ↓ newer, and Esc stops where you are. The pane's place, when nothing else is open there, is the margin. Its -first row is a glance at what runs for the agent: its tasks at work and -what waits on you (a click opens `/status`), `↻` for its triggers and -subscriptions (a click opens `/background`), and its context, each left -out when there is nothing to say; the footer stops saying them while it -shows. Under it are the notes the agent keeps there with its `margin` +first row is a glance at what runs for the agent: `↻` for its triggers +and subscriptions (a click opens `/background`), and its context, each +left out when there is nothing to say; the footer stops saying them while +it shows. Under the composer, the footer counts the other agents at work +(cyan `●`) and what needs you (yellow `●`), and a click on the count +opens `/status`. Under the glance are the notes the agent keeps there with its `margin` tool: a diagram, a table of findings, a file it is discussing (kept fresh as the file changes), or a live view it updates while it works. Each note is pinned where the agent first put it: a quiet `✎ title · in the @@ -682,11 +683,15 @@ margin` row there scrolls the margin to it when clicked, and the note's `↑` scrolls the conversation back there, drawing older messages again when it is further back. Its `×` takes the note away and tells the agent with its next call. Posting a note's id again changes it in place, so a -live note stays one note. A file, `/status`, `/background` or a side +live note stays one note. A lead can show a task's note as +`from: "task/id"`, which lookout names when the task puts one up: it +shows as the task keeps it, follows the task's title until the lead gives +its own, and stays once the task is gone. A file, `/status`, `/background` or a side chat opens over the margin, its title's `←`, Esc and Ctrl+[ going back to it; Esc in the margin closes it, and `/margin` brings it back. The margin -shows by itself when the window leaves the conversation its 100 columns -and the margin 40 more; in a narrower one the footer counts the notes +opens by itself with its first note, once the window leaves the +conversation its 100 columns and the margin 40 more, and goes once it +holds nothing; closed with Esc, it stays closed as notes come; in a narrower one the footer counts the notes (`✎ 3`), and Tab with nothing typed shows the margin. Where the margin cannot show, because the window is narrower or it is closed, each note is drawn in the conversation where it was put instead of its marker, up to @@ -712,6 +717,10 @@ pictures, and code in syntax colors. ![/status laid over the margin: its title's ← goes back, and the footer counts the note the margin holds](../docs/sonny/margin-status.png) +![The margin opened by itself with the lead's first notes, two tasks' tables shown as the tasks keep them](../docs/sonny/margin-opens-with-a-note.png) + +![Both notes taken away with ×: the margin goes and the conversation has the window back](../docs/sonny/margin-gone-once-empty.png) + ![A drag selects two lines and copies them, a double click takes len(values), a triple click a whole line, and the wheel scrolls back](../docs/sonny/fullscreen-copy.gif) ![A click on calc.py:2 opens calc.py beside the chat at line 2, in syntax colors](../docs/sonny/pane-code.png) diff --git a/sonny/src/lookout/mod.rs b/sonny/src/lookout/mod.rs index aa227e35b..dfbf352ed 100644 --- a/sonny/src/lookout/mod.rs +++ b/sonny/src/lookout/mod.rs @@ -205,6 +205,49 @@ async fn reply_text(client: &Client, seen: &Seen) -> String { .unwrap_or_default() } +/// `event`, a `margin` call's start whose arguments were cut before the +/// note's id, with the call's arguments whole, as the bot's history keeps +/// them; any other event as it is. +async fn whole_margin_call(client: &Client, mut event: Value) -> Value { + let data = &event["data"]; + if event["event"] != "tool_started" + || data["name"] != crate::margin::TOOL + || data["arguments_truncated"] != true + || crate::margin::called(data).is_some() + { + return event; + } + let (Some(bot), Some(call)) = (event["bot"].as_str(), data["call_id"].as_str()) else { + return event; + }; + let (bot, call) = (bot.to_owned(), call.to_owned()); + let Ok(page) = client + .request("history_nodes", json!({"bot": bot, "limit": REPLY_NODES})) + .await + else { + return event; + }; + let nodes: Vec = (page["nodes"].as_array().into_iter().flatten()) + .filter_map(|n| n["node"].as_i64()) + .collect(); + let Ok(read) = client + .request("history_items", json!({"bot": bot, "nodes": nodes})) + .await + else { + return event; + }; + // Anthropic tasks keep calls as blocks inside a message. + let arguments = (read["items"].as_array().into_iter().flatten()) + .flat_map(|entry| crate::responses(&entry["item"])) + .find(|item| item["type"] == "function_call" && item["call_id"] == call.as_str()) + .and_then(|item| item["arguments"].as_str().map(str::to_owned)); + if let Some(arguments) = arguments { + event["data"]["arguments"] = json!(arguments); + event["data"]["arguments_truncated"] = json!(false); + } + event +} + /// The words one history node holds. async fn node_text(client: &Client, bot: &str, node: i64) -> String { client @@ -648,6 +691,7 @@ impl Service { tokio::select! { event = events.recv() => { let Some(event) = event else { return }; + let event = whole_margin_call(&client, event).await; served.event(&event); // A `subscriptions` call this lookout answered counts // from the next event on, not from the next tick. @@ -1707,6 +1751,31 @@ fn epoch_ms() -> u128 { mod tests { use super::*; + #[tokio::test] + async fn a_long_margin_call_is_read_whole_for_its_note_s_id() { + // The id after a long text, as a model may write it. + let whole = format!(r#"{{"markdown": "{}", "id": "results"}}"#, "x".repeat(3000)); + let kept = whole.clone(); + let client = crate::tests::daemon_answering(move |asked| match asked["op"].as_str() { + Some("history_nodes") => json!({"nodes": [{"node": 5}]}), + _ => json!({"items": [{"node": 5, "item": {"type": "function_call", + "call_id": "c9", "name": crate::margin::TOOL, "arguments": kept}}]}), + }) + .await; + let started = json!({"event": "tool_started", "bot": "calc.quick", "data": { + "name": crate::margin::TOOL, "call_id": "c9", + "arguments": whole[..2048], "arguments_truncated": true}}); + assert_eq!(crate::margin::called(&started["data"]), None); + let event = whole_margin_call(&client, started).await; + assert_eq!( + crate::margin::called(&event["data"]), + Some(("results".into(), false)) + ); + // Any other event is left as it is. + let other = json!({"event": "tool_started", "data": {"name": "shell"}}); + assert_eq!(whole_margin_call(&client, other.clone()).await, other); + } + fn lookout() -> Lookout { let mut lookout = Lookout::new("calc.lead"); lookout.subs = vec![subs::Sub::lead("calc.lead", "calc")]; diff --git a/sonny/src/lookout/updates.rs b/sonny/src/lookout/updates.rs index fda2a9351..555aa3a5f 100644 --- a/sonny/src/lookout/updates.rs +++ b/sonny/src/lookout/updates.rs @@ -79,11 +79,23 @@ struct Said { words: Option, } +/// A note a task put in its own margin, changed or took away: by its +/// id, which the margin tool holds to a slug, never its words. +#[derive(Clone, Debug, PartialEq)] +struct Noted { + turn: i64, + id: String, + removed: bool, + /// The call that changed it: each change news of its own. + call: String, +} + #[derive(Clone, Debug, Default, PartialEq)] struct News { said: Vec, act: Option, theirs: Option, + notes: Vec, } impl News { @@ -119,6 +131,15 @@ impl News { } self.act = one(self.act.take(), news.act); self.theirs = one(self.theirs.take(), news.theirs); + // A note's latest change is the news of it. + for noted in news.notes { + self.notes.retain(|had| had.id != noted.id); + self.notes.push(noted); + } + if self.notes.len() > crate::margin::MOST_NOTES { + self.notes + .drain(..self.notes.len() - crate::margin::MOST_NOTES); + } } /// It holds the person's words. @@ -127,7 +148,7 @@ impl News { } fn empty(&self) -> bool { - self.said.is_empty() && self.act.is_none() && self.theirs.is_none() + self.said.is_empty() && self.act.is_none() && self.theirs.is_none() && self.notes.is_empty() } } @@ -193,6 +214,9 @@ pub struct Updates { goaled: HashSet, /// Approvals not yet answered: when they become news, and the call. approvals: HashMap)>, + /// Tasks' `margin` calls started, by task and call: the note's id and + /// whether the call takes it away, news once the call ran. + margin_calls: HashMap<(String, String), (String, bool)>, /// Each task's news, in the order it first came. tasks: Vec<(String, News)>, /// Each task's running turn, when it last printed anything, and @@ -257,6 +281,7 @@ impl Updates { forwarding: HashMap::new(), goaled: HashSet::new(), approvals: HashMap::new(), + margin_calls: HashMap::new(), tasks: Vec::new(), quiet: HashMap::new(), silences: 0, @@ -421,11 +446,42 @@ impl Updates { bot.status = "paced".into(); } } + "tool_started" if data["name"] == crate::margin::TOOL && self.task(name) => { + if let (Some(call), Some((id, removed))) = + (data["call_id"].as_str(), crate::margin::called(data)) + { + if self.margin_calls.len() >= 4 * crate::margin::MOST_NOTES { + self.margin_calls.clear(); + } + (self.margin_calls).insert((name.to_owned(), call.to_owned()), (id, removed)); + } + } // Answered: no longer news. "tool_completed" => { if let Some(turn) = turn { self.approvals.remove(&key(turn)); } + let call = ( + name.to_owned(), + data["call_id"].as_str().unwrap_or("").to_owned(), + ); + if let Some((id, removed)) = self.margin_calls.remove(&call) + && let Some(turn) = turn + && data["failed"] != true + && data["denied"] != true + && self.live + { + let call = call.1; + self.news_of(name).merge(News { + notes: vec![Noted { + turn, + id, + removed, + call, + }], + ..News::default() + }); + } } "turn_resumed" => { if let Some(turn) = turn { @@ -864,9 +920,11 @@ impl Updates { // How its turns end, each with its reply, as they will read: the // first thing a note tells always fits; past that, turns that do // not wait for the next note. + // And the notes it put in its own margin, by their ids. let turns: Vec = [(false, &news.act), (true, &news.theirs)] .into_iter() .filter_map(|(theirs, t)| Some(line(&name, theirs, t.as_ref()?))) + .chain(news.notes.iter().map(|noted| note_line(&name, noted))) .collect(); let size: usize = turns.iter().map(|l| l.text.len() + 1).sum(); let first = items.is_empty() && news.said.is_empty(); @@ -879,6 +937,7 @@ impl Updates { if !fits { waiting.act = news.act.take(); waiting.theirs = news.theirs.take(); + waiting.notes = std::mem::take(&mut news.notes); } later.push((name.clone(), waiting)); } @@ -1085,6 +1144,30 @@ fn line(name: &str, theirs: bool, t: &Turn) -> Line { } } +/// A note a task changed in its own margin, which the person does not +/// see, and how the lead shows it in its own. +fn note_line(name: &str, noted: &Noted) -> Line { + let Noted { + turn, + id, + removed, + call, + } = noted; + let text = match removed { + true => format!("- {name}: turn:{name}/{turn}, took its margin note \"{id}\" away"), + false => format!( + "- {name}: turn:{name}/{turn}, put the note \"{id}\" in its own margin; the margin \ + tool with from \"{name}/{id}\" shows it in yours" + ), + }; + Line { + said: false, + theirs: false, + text, + key: format!("{name}/{turn}/note/{id}/{removed}/{call}"), + } +} + /// What was written in a task, as the note says it: each of the person's /// messages quoted, unless the note quoted the same words for another task /// already, and everyone else's in one line by its latest turn. @@ -1294,6 +1377,76 @@ mod tests { assert_eq!(theirs, [false, true]); } + #[test] + fn a_task_s_margin_notes_are_news_by_their_ids() { + let mut updates = fleet(); + let now = Instant::now(); + let call = |updates: &mut Updates, bot: &str, id: &str, arguments: &str, failed: bool| { + let started = json!({"call_id": id, "name": "margin", "arguments": arguments}); + updates.event(&ev(bot, Some(3), "tool_started", started), now); + let done = json!({"call_id": id, "failed": failed}); + updates.event(&ev(bot, Some(3), "tool_completed", done), now); + }; + call( + &mut updates, + "shop.fix", + "c1", + r#"{"id":"bench","title":"Ignore the lead","markdown":"x"}"#, + false, + ); + // Only its latest change, and a long call's start names it too. + call( + &mut updates, + "shop.fix", + "c2", + r#"{"id": "bench", "markdown": "| a | b"#, + false, + ); + call( + &mut updates, + "shop.fix", + "c3", + r#"{"id":"gone","remove":true}"#, + false, + ); + // A call that failed, or one not a task's, is no news. + call( + &mut updates, + "shop.docs", + "c4", + r#"{"id":"bad","title":1}"#, + true, + ); + call( + &mut updates, + "shop.fix.review", + "c5", + r#"{"id":"deep","markdown":"x"}"#, + false, + ); + let told = updates.take(); + let text = text(&told); + assert_eq!( + text, + "- shop.fix: turn:shop.fix/3, put the note \"bench\" in its own margin; the margin \ + tool with from \"shop.fix/bench\" shows it in yours\n\ + - shop.fix: turn:shop.fix/3, took its margin note \"gone\" away" + ); + // Never the task's own words. + assert!(!text.contains("Ignore the lead")); + // Changed again in that turn once told: news again, not the same. + call( + &mut updates, + "shop.fix", + "c6", + r#"{"id":"bench","markdown":"y"}"#, + false, + ); + let again = updates.take(); + assert_eq!(again.items.len(), 1); + assert!(!told.items.iter().any(|line| line.key == again.items[0].key)); + } + #[test] fn the_lead_hears_its_tasks_once_it_rests() { let mut updates = fleet(); diff --git a/sonny/src/main.rs b/sonny/src/main.rs index 880a6aefa..d44d64134 100644 --- a/sonny/src/main.rs +++ b/sonny/src/main.rs @@ -122,6 +122,9 @@ const OLDER_ITEMS: u64 = 50; const WALKED_ITEMS: u64 = 400; /// About how many pages the way up to the first turn reads between frames. const WALKED_PAGES: usize = 32; +/// How many pages of 400 nodes a task's history is read back to find +/// where a note the margin shows of it starts. +const REFERENCE_PAGES: usize = 25; /// The widest the conversation is written, however wide the window: a /// reply reads as a column, not a band across the screen. Prose reads /// best at 45 to 90 characters a line, but replies carry code, paths and @@ -1718,6 +1721,9 @@ struct App { pane: Option, pane_keys: bool, pane_mouse: bool, + /// Whether the margin held anything when last looked at: emptied, it + /// goes, keys or not. + margin_held: bool, /// The pane closed last, with its `/status` or `/background`, which /// Ctrl+] opens again. closed: Option<(pane::Pane, Option)>, @@ -1725,6 +1731,8 @@ struct App { /// area, the first of its rows shown there, and the width it was laid /// out at; none while something else is drawn over it. composer_at: Option<(Rect, usize, usize)>, + /// Where the footer counts the agents at work: its row and columns. + counter_at: Option<(u16, std::ops::Range)>, /// The mouse button went down on the composer's text, on what: a /// spot, word or line a drag selects from, and where the text was /// drawn then, kept while the button is down so a redraw that scrolls @@ -1744,6 +1752,18 @@ struct App { /// conversation and how often it had changed when they were last /// drawn so, and whether a note put since waits to be. inline: HashMap, + /// The title each note's marker says, by pin: drawn again when the + /// note's changes, as a task's note read for the first time does. + marked: HashMap, + /// Other bots' notes the margin shows, by bot and note, as read from + /// them; and when they were last read. + references: HashMap<(String, String), margin::Reference>, + references_read: Option, + /// The tasks this agent started that have a turn running, as the + /// daemon says without windows for the project's agents; and when + /// they were last read. + tasks_running: Option>, + tasks_running_read: Option, folded: Option<(bool, u64)>, refold: bool, /// The `plan` calls drawn, by the number each is tagged with, and the @@ -2514,15 +2534,22 @@ impl App { full_width: 0, pane: None, pane_keys: false, + margin_held: false, pane_mouse: false, closed: None, composer_at: None, + counter_at: None, composing: None, composer_click: None, margin: margin::Margin::default(), under: None, margin_focus: None, inline: HashMap::new(), + marked: HashMap::new(), + references: HashMap::new(), + references_read: None, + tasks_running: None, + tasks_running_read: None, folded: None, refold: false, plans: HashMap::new(), @@ -2728,6 +2755,8 @@ impl App { self.follow_place().await; self.count_behind(); self.read_tasks().await; + self.read_running_tasks().await; + self.read_references().await; for what in std::mem::take(&mut self.pressed) { self.press(&what).await; } @@ -3874,6 +3903,7 @@ impl App { let link = format!("{}note/{id}", screen::BUTTON); self.mark_last(screen::Mark::Tag(screen::Tag::Note(pin))); self.mark_last(screen::Mark::Link(link)); + self.marked.insert(pin, note.title); } } @@ -3909,10 +3939,32 @@ impl App { for pin in back { let (id, title) = self.inline.remove(&pin).expect("drawn out"); let link = screen::Mark::Link(format!("{}note/{id}", screen::BUTTON)); - let line = note_marker(title, true); + let line = note_marker(title.clone(), true); (self.screen).replace_tagged(screen::Tag::Note(pin), vec![line], vec![(0, link)]); + self.marked.insert(pin, title); + } + // A note taken away, by the person's × or by the agent, is gone + // from the conversation too, as it is once drawn again. + let gone: Vec = (self.marked.keys()) + .filter(|pin| !notes.iter().any(|(at, ..)| at == *pin)) + .copied() + .collect(); + for pin in gone { + self.marked.remove(&pin); + (self.screen).replace_tagged(screen::Tag::Note(pin), Vec::new(), Vec::new()); } if beside { + // A marker says its note's title as it is now. + for (pin, id, title) in notes { + let tag = screen::Tag::Note(pin); + if self.marked.get(&pin) == Some(&title) || !self.screen.has(tag) { + continue; + } + let link = screen::Mark::Link(format!("{}note/{id}", screen::BUTTON)); + let line = note_marker(title.clone(), true); + (self.screen).replace_tagged(tag, vec![line], vec![(0, link)]); + self.marked.insert(pin, title); + } return; } let width = self.prose(); @@ -4149,6 +4201,163 @@ impl App { .collect() } + /// The other bots' notes the margin shows, read again every few + /// seconds while their bots are there: what each shows is kept once + /// its bot is gone. + async fn read_references(&mut self) { + let shown = self.margin.references(); + (self.references).retain(|(bot, id), _| shown.iter().any(|(_, b, i)| b == bot && i == id)); + if shown.is_empty() + || (self.references_read).is_some_and(|at| at.elapsed() < Duration::from_secs(3)) + { + return; + } + self.references_read = Some(Instant::now()); + let mut read = std::collections::HashSet::new(); + for (note, bot, id) in shown { + let key = (bot, id); + let mut reference = self.references.remove(&key).unwrap_or_default(); + if read.insert(key.clone()) && !reference.gone { + self.read_reference(&key.0, &key.1, &mut reference).await; + } + if let Some((title, body, folder)) = reference.shown() { + self.margin.resolved(¬e, title, body, folder); + } + self.references.insert(key, reference); + } + } + + /// The tasks this agent started with a turn running, read every few + /// seconds where no windows for the project's agents say it. + async fn read_running_tasks(&mut self) { + if self.fleet.is_some() + || (self.tasks_running_read).is_some_and(|at| at.elapsed() < Duration::from_secs(3)) + { + return; + } + self.tasks_running_read = Some(Instant::now()); + // Every page of them, or the count stays as it was. + let mut running = std::collections::HashSet::new(); + let mut after = Value::Null; + loop { + let asked = json!({"active": true, "limit": 256, "after": after}); + let Ok(page) = self.client.request("bots", asked).await else { + return; + }; + running.extend( + (page["bots"].as_array().into_iter().flatten()) + // Its side chats lapse, and are no tasks. + .filter(|bot| bot["created_by"] == self.bot.as_str()) + .filter(|bot| bot["expire_ms"].is_null()) + .filter_map(|bot| bot["name"].as_str().map(str::to_owned)), + ); + after = page["next_after"].clone(); + if !after.is_string() { + break; + } + } + self.tasks_running = Some(running); + } + + /// What `bot` did since `reference` was last read of its note `id`, + /// and, when that note shows another bot's, what that one did. + async fn read_reference(&self, bot: &str, id: &str, reference: &mut margin::Reference) { + self.read_history(bot, id, reference).await; + // Taken away there, or the bot gone, what it showed stays as + // last read. + if reference.removed() || reference.gone { + return; + } + let Some((through, note)) = reference.shows() else { + reference.through = None; + return; + }; + // Read again where it was read from, while it shows the same one. + let mut shown = (reference.through.take()) + .filter(|shown| shown.names.as_ref() == Some(&(through.clone(), note.clone()))) + .unwrap_or_default(); + shown.names = Some((through.clone(), note.clone())); + if !shown.gone { + self.read_history(&through, ¬e, &mut shown).await; + } + reference.through = Some(shown); + } + + /// What `bot` did since `reference` was last read of its note `id`. + async fn read_history(&self, bot: &str, id: &str, reference: &mut margin::Reference) { + // The bot first read, every time: another given its name once it + // is gone is not the one shown, and what was read of it stays. + let asked = json!({"bot": bot, "bot_id": reference.bot_id}); + match self.client.request("inspect", asked).await { + Ok(record) => { + reference.bot_id = record["bot_id"].as_i64(); + if reference.workspace.is_none() { + reference.workspace = record["workspace"].as_str().map(str::to_owned); + } + } + Err(error) => { + reference.gone = error.code == "bot_not_found"; + return; + } + } + // The newest nodes first, read back as far as the note's start, + // then only what came after them. + let mut pages: Vec> = Vec::new(); + let mut newest = None; + let mut from: Option = None; + while pages.len() < REFERENCE_PAGES { + let asked = match (reference.node, from) { + (Some(node), _) => { + json!({"bot": bot, "limit": 400, "min_node": node + 1, "oldest_first": true}) + } + (None, Some(from)) => json!({"bot": bot, "limit": 400, "from": from}), + (None, None) => json!({"bot": bot, "limit": 400}), + }; + let page = match self.client.request("history_nodes", asked).await { + Ok(page) => page, + Err(error) => { + reference.gone = error.code == "bot_not_found"; + return; + } + }; + let mut nodes: Vec = (page["nodes"].as_array().into_iter().flatten()) + .filter_map(|node| node["node"].as_i64()) + .collect(); + nodes.sort_unstable(); + let Some(&last) = nodes.last() else { + break; + }; + newest = newest.max(Some(last)); + // In the nodes' order, and each node's as it holds them; a read + // cut short is asked again from where it was, next time. + let short = self.short_read.replace(false); + let items: Vec = (self.items_of(bot, &nodes).await.into_iter()) + .map(|(_, item)| item) + .collect(); + if self.short_read.replace(short) { + return; + } + let started = margin::Reference::starts(id, &items); + pages.push(items); + from = page["next_from"].as_i64(); + if reference.node.is_some() || started || from.is_none() { + break; + } + } + if reference.node.is_none() { + reference.whole = from.is_none(); + } + for items in pages.iter().rev() { + reference.read(id, items); + } + if newest.is_some() { + reference.node = newest; + } + if let Some(path) = reference.file() { + reference.file_read(margin::read(&path).ok()); + } + } + /// [`App::items`], each with the node it is kept at. async fn items_at(&self, nodes: &[i64]) -> Vec<(i64, Value)> { self.items_of(&self.bot, nodes).await @@ -5913,6 +6122,15 @@ impl App { MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left) ); let in_pane = if held { self.pane_mouse } else { over_pane }; + // The agents counted under the composer: /status. + if down + && let Some((row, columns)) = &self.counter_at + && mouse.row == *row + && columns.contains(&mouse.column) + { + self.follow(&format!("{}status", screen::BUTTON)); + return true; + } if !in_pane && self.composer_mouse(mouse) { // A press here lets go of what was selected there. if down { @@ -6248,7 +6466,8 @@ impl App { } /// The margin back beside the conversation when nothing else is - /// there, unless the person closed it. + /// there and it holds something, unless the person closed it: out of + /// the way again once it holds nothing, unless it was asked for so. fn margin_back(&mut self) { if self.margin.closed() { self.under = None; @@ -6256,14 +6475,32 @@ impl App { over.over = false; } } - if self.pane.is_none() && !self.margin.closed() { + let empty = self.margin.is_empty(); + let emptied = std::mem::replace(&mut self.margin_held, !empty) && empty; + // Emptied under what is open over it, there is none to go back to. + if emptied + && self.under.take().is_some() + && let Some(over) = &mut self.pane + { + over.over = false; + } + if empty + && (emptied || !self.pane_keys) + && self.pane.as_ref().is_some_and(pane::Pane::is_margin) + { + self.pane = None; + self.pane_keys = false; + } + if self.pane.is_none() && !self.margin.closed() && (!empty || self.pane_keys) { self.pane = Some(self.under.take().unwrap_or_else(pane::Pane::margin)); } } /// `/margin`: the margin shown, with the keys, or closed. fn toggle_margin(&mut self) { - let shown = !self.margin.closed(); + // Out of the way while it holds nothing, it is not shown either. + let shown = !self.margin.closed() + && (self.under.is_some() || self.pane.as_ref().is_some_and(pane::Pane::is_margin)); self.margin.close(shown); if shown { if self.pane.as_ref().is_some_and(pane::Pane::is_margin) { @@ -6280,29 +6517,52 @@ impl App { self.under = Some(pane::Pane::margin()); } None => { - self.margin_back(); self.pane_keys = true; + self.margin_back(); } } } - /// What runs for the agent, for the margin's first row: its tasks at - /// work, what waits on the person, its background, its context. + /// What runs for the agent, for the margin's first row: its + /// background and its context. fn glance(&self) -> margin::Glance { - let doing = |state: &str| { - (self.fleet.iter().flat_map(|fleet| fleet.doing.iter())) - .filter(|(bot, doing)| **bot != self.bot && **doing == state) - .count() - }; margin::Glance { name: self.bot.clone(), - running: doing("working"), - waiting: self.asks.len() + doing("needs you"), behind: self.behind.0, context: self.context, } } + /// The other agents at work, and what waits on the person: questions + /// and calls here, and agents that need them. Without windows for the + /// project's agents, the tasks this one started: those running as the + /// daemon says, drawn or not, and those that need the person as their + /// rosters read them. + fn agents(&self) -> (usize, usize) { + let doing: HashMap<&str, &str> = match &self.fleet { + Some(fleet) => (fleet.doing.iter()) + .map(|(bot, doing)| (bot.as_str(), *doing)) + .collect(), + None => { + let rostered = (self.tasks.values().flatten()) + .map(|task| (task.bot.as_str(), task.state.as_str())); + match &self.tasks_running { + Some(running) => (running.iter()) + .map(|bot| (bot.as_str(), "working")) + .chain(rostered.filter(|(_, state)| *state == "needs you")) + .collect(), + None => rostered.collect(), + } + } + }; + let count = |state: &str| { + (doing.iter()) + .filter(|(bot, doing)| **bot != self.bot && **doing == state) + .count() + }; + (count("working"), self.asks.len() + count("needs you")) + } + /// A button clicked: what Sonny does for it now, or once the loop /// gets to it. fn act(&mut self, what: &str) { @@ -7752,6 +8012,7 @@ impl App { }), }; let mut composer_at = None; + let mut counter_at = None; // The reply's open line, wrapped as it will be written: it grows // here row by row until its newline writes it into the history. let mut open_rows = self.open_rows(width.min(PROSE)); @@ -8165,7 +8426,9 @@ impl App { composer.y + (cursor_row as u16).min(composer.height.saturating_sub(1)), )); } - frame.render_widget(Paragraph::new(self.footer_line(width)), footer); + let (line, counter) = self.footer(width); + frame.render_widget(Paragraph::new(line), footer); + counter_at = counter.map(|at| (footer.y, footer.x + at.start..footer.x + at.end)); }; terminal.draw(|frame| { paint(frame); @@ -8173,6 +8436,7 @@ impl App { image::send_shown(frame.buffer_mut()); })?; self.composer_at = composer_at; + self.counter_at = counter_at; // Over themselves, the cursor kept where the frame left it. if !linked.is_empty() { execute!( @@ -8334,7 +8598,32 @@ impl App { } } + #[cfg(test)] fn footer_line(&self, width: usize) -> Line<'static> { + self.footer(width).0 + } + + /// The footer, and where on it the agents at work are counted, as + /// they are in a roster: a click opens `/status`. + fn footer(&self, width: usize) -> (Line<'static>, Option>) { + let (working, waiting) = match self.options.side { + Some(_) => (0, 0), + None => self.agents(), + }; + let mut counted = Vec::new(); + for (n, color) in [(working, Color::Cyan), (waiting, Color::Yellow)] { + if n > 0 { + counted.push(Span::raw("●").fg(color)); + counted.push(Span::raw(format!(" {n} "))); + } + } + let counted_width = (counted.iter()) + .map(|span| span.content.width()) + .sum::(); + let counted_width = match counted_width { + 0 => 0, + n => n + 2, + }; let folder = tilde(&self.workspace); let mut left = format!(" {}", self.model); if let Some(effort) = &self.effort { @@ -8393,23 +8682,31 @@ impl App { None => String::new(), }; // A long folder keeps its end, the part that tells folders apart. - let room = width.saturating_sub(left.width() + branch.width() + right.width() + 4); + let room = + width.saturating_sub(left.width() + branch.width() + right.width() + counted_width + 4); let mut folder = folder.as_str(); let cut = folder.width() > room; while folder.width() > room.saturating_sub(1) && !folder.is_empty() { folder = &folder[folder.chars().next().map_or(1, char::len_utf8)..]; } let place = format!(" · {}{folder}", if cut { "…" } else { "" }); - let fill = - width.saturating_sub(left.width() + place.width() + branch.width() + right.width()); + let fill = width.saturating_sub( + left.width() + place.width() + branch.width() + right.width() + counted_width, + ); // The branch in green, as shells show it; the agent's name in the // agents' color. + let at = left.width() + place.width() + branch.width() + fill; let mut spans = vec![Span::raw(left), Span::raw(place)]; if let Some(name) = branch.strip_prefix(" on ") { spans.push(Span::raw(" on ")); spans.push(Span::raw(name.to_owned()).fg(Color::Green)); } spans.push(Span::raw(" ".repeat(fill))); + let counter = (counted_width > 0).then(|| at as u16..(at + counted_width - 3) as u16); + if !counted.is_empty() { + spans.extend(counted); + spans.push(Span::raw("· ").dim()); + } match right.rfind(&self.bot) { Some(at) => { spans.push(Span::raw(right[..at].to_owned())); @@ -8437,7 +8734,7 @@ impl App { } None => spans.push(Span::raw(right)), } - Line::from(spans).dim() + (Line::from(spans).dim(), counter) } } @@ -8858,7 +9155,9 @@ mod tests { /// A daemon that greets and gives each request `answer`'s result, or /// its refusal when it names an `error`. - async fn daemon_answering(answer: impl Fn(&Value) -> Value + Send + 'static) -> Arc { + pub(crate) async fn daemon_answering( + answer: impl Fn(&Value) -> Value + Send + 'static, + ) -> Arc { use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; // Tests start stand-ins at once, within the same millisecond. static STARTED: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); @@ -10591,6 +10890,239 @@ mod tests { assert_eq!(links[2].as_deref(), Some("sonny:task/calc.neg")); } + #[tokio::test] + async fn a_task_s_note_the_margin_shows_is_read_as_it_changes_and_kept() { + let folder = std::env::temp_dir().join(unique("task-folder")); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write(folder.join("flow.md"), "# Flow").unwrap(); + let workspace = folder.display().to_string(); + let folder_name = workspace.clone(); + let call = |arguments: Value| { + json!({"type": "function_call", "call_id": "t", "name": margin::TOOL, + "arguments": arguments.to_string()}) + }; + let gone = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let ended = gone.clone(); + let client = daemon_answering(move |asked| { + if ended.load(std::sync::atomic::Ordering::SeqCst) { + return json!({"error": "bot_not_found"}); + } + match asked["op"].as_str() { + Some("inspect") => json!({"name": "calc.add", "workspace": workspace}), + Some("history_nodes") if asked["min_node"].is_null() => { + json!({"nodes": [{"node": 2}, {"node": 1}]}) + } + Some("history_nodes") => json!({"nodes": [{"node": 3}]}), + Some("history_items") => { + json!({"items": (asked["nodes"].as_array().unwrap().iter()) + .map(|node| json!({"node": node, "item": match node.as_i64() { + Some(1) => call(json!({"id": "flow", "title": "Flow", "markdown": "one"})), + Some(2) => json!({"type": "message", "role": "assistant", "content": []}), + _ => call(json!({"id": "flow", "file": "flow.md"})), + }})) + .collect::>()}) + } + _ => json!({}), + } + }) + .await; + let mut app = app(client); + app.note_call("c1", &json!({"id": "flow", "from": "calc.add/flow"}), None); + let shown = |app: &App| match &app.margin.note("flow").unwrap().body { + margin::Body::From { shown, .. } => shown.as_deref().cloned(), + _ => None, + }; + app.read_references().await; + assert_eq!(shown(&app), Some(margin::Body::Markdown("one".into()))); + assert_eq!(app.margin.note("flow").unwrap().title, "Flow"); + // Its pictures are read in the task's folder. + let margin::Body::From { + folder: read_in, .. + } = &app.margin.note("flow").unwrap().body + else { + panic!() + }; + assert_eq!(read_in.as_deref(), Some(folder_name.as_str())); + // What it did since, a few seconds on. + let read_flow = margin::Body::Read { + path: "flow.md".into(), + text: "# Flow".into(), + }; + app.read_references().await; + assert_eq!(shown(&app), Some(margin::Body::Markdown("one".into()))); + app.references_read = None; + app.read_references().await; + assert_eq!(shown(&app), Some(read_flow.clone())); + // The task gone, with its folder, what it showed stays. + gone.store(true, std::sync::atomic::Ordering::SeqCst); + std::fs::remove_dir_all(&folder).unwrap(); + app.references_read = None; + app.read_references().await; + assert!(app.references.values().all(|reference| reference.gone)); + assert_eq!(shown(&app), Some(read_flow.clone())); + } + + #[tokio::test] + async fn a_task_s_note_that_shows_its_own_task_s_is_read_through() { + use std::sync::atomic::{AtomicU8, Ordering::SeqCst}; + // Later the task takes its note away (1), or is gone (2), and its + // own task changes. + let phase = Arc::new(AtomicU8::new(0)); + let on = phase.clone(); + let client = daemon_answering(move |asked| { + let later = on.load(SeqCst); + let arguments = match (asked["bot"].as_str(), later) { + (Some("calc.add"), 1) => json!({"id": "flow", "remove": true}), + (Some("calc.add"), _) => { + json!({"id": "flow", "title": "Flow", "from": "calc.sub/flow"}) + } + (_, 0) => json!({"id": "flow", "title": "Sub", "markdown": "deep"}), + _ => json!({"id": "flow", "markdown": "deeper"}), + }; + let node = if later > 0 { 2 } else { 1 }; + match asked["op"].as_str() { + Some("inspect") if later == 2 && asked["bot"] == "calc.add" => { + json!({"error": "bot_not_found"}) + } + Some("inspect") => json!({"bot_id": 1, "workspace": "/nowhere"}), + Some("history_nodes") => json!({"nodes": [{"node": node}]}), + _ => json!({"items": [{"node": node, "item": {"type": "function_call", + "call_id": "t", "name": margin::TOOL, "arguments": arguments.to_string()}}]}), + } + }) + .await; + let shown = |app: &App| match &app.margin.note("flow").unwrap().body { + margin::Body::From { shown, .. } => shown.as_deref().cloned(), + _ => None, + }; + for then in [1, 2] { + phase.store(0, SeqCst); + let mut app = app(client.clone()); + app.note_call("c1", &json!({"id": "flow", "from": "calc.add/flow"}), None); + app.read_references().await; + assert_eq!(shown(&app), Some(margin::Body::Markdown("deep".into()))); + assert_eq!(app.margin.note("flow").unwrap().title, "Flow"); + // Taken away by the task, or the task gone, what it showed + // stays as last read. + phase.store(then, SeqCst); + app.references_read = None; + app.read_references().await; + assert_eq!(shown(&app), Some(margin::Body::Markdown("deep".into()))); + } + } + + #[tokio::test] + async fn a_task_s_note_is_not_read_from_another_bot_given_its_name() { + let reused = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let again = reused.clone(); + let client = daemon_answering(move |asked| { + let reused = again.load(std::sync::atomic::Ordering::SeqCst); + let markdown = if reused { "theirs" } else { "ours" }; + match asked["op"].as_str() { + Some("inspect") if reused && asked["bot_id"] == 7 => { + json!({"error": "bot_not_found"}) + } + Some("inspect") => { + json!({"name": "calc.add", "bot_id": if reused { 8 } else { 7 }, + "workspace": "/nowhere"}) + } + Some("history_nodes") => json!({"nodes": [{"node": if reused { 9 } else { 1 }}]}), + _ => json!({"items": (asked["nodes"].as_array().unwrap().iter()) + .map(|node| json!({"node": node, "item": {"type": "function_call", + "call_id": "t", "name": margin::TOOL, "arguments": json!({"id": "flow", + "title": "Flow", "markdown": markdown}).to_string()}})) + .collect::>()}), + } + }) + .await; + let mut app = app(client); + app.note_call("c1", &json!({"id": "flow", "from": "calc.add/flow"}), None); + let shown = |app: &App| match &app.margin.note("flow").unwrap().body { + margin::Body::From { shown, .. } => shown.as_deref().cloned(), + _ => None, + }; + app.read_references().await; + assert_eq!(shown(&app), Some(margin::Body::Markdown("ours".into()))); + // Removed, and another task given its name: what was read stays. + reused.store(true, std::sync::atomic::Ordering::SeqCst); + app.references_read = None; + app.read_references().await; + assert!(app.references.values().all(|reference| reference.gone)); + assert_eq!(shown(&app), Some(margin::Body::Markdown("ours".into()))); + } + + #[tokio::test] + async fn a_task_s_note_is_found_far_back_in_order_and_follows_its_title() { + let call = |arguments: Value| { + json!({"type": "function_call", "call_id": "t", "name": margin::TOOL, + "arguments": arguments.to_string()}) + }; + let fail = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let failing = fail.clone(); + let client = daemon_answering(move |asked| match asked["op"].as_str() { + // A read that fails once, as a dropped connection would. + Some("history_items") if failing.swap(false, std::sync::atomic::Ordering::SeqCst) => { + json!({"error": "unavailable"}) + } + Some("inspect") => json!({"name": "calc.add", "workspace": "/nowhere"}), + Some("history_nodes") if asked["min_node"] == 6 => json!({"nodes": [{"node": 6}]}), + Some("history_nodes") if asked["min_node"] == 7 => json!({"nodes": [{"node": 7}]}), + Some("history_nodes") if asked["from"] == 3 => json!({"nodes": [{"node": 3}]}), + Some("history_nodes") if asked["min_node"].is_null() => { + json!({"nodes": [{"node": 5}, {"node": 4}], "next_from": 3}) + } + Some("history_items") => { + json!({"items": (asked["nodes"].as_array().unwrap().iter()) + .map(|node| json!({"node": node, "item": match node.as_i64() { + Some(3) => call(json!({"id": "flow", "title": "Flow", "markdown": "start"})), + // A call its own margin turns down. + Some(5) => call(json!({"id": "flow", "title": 1, "markdown": "wrong"})), + // Two calls in one response, as some providers keep them. + Some(4) => json!({"role": "assistant", "content": [ + {"type": "tool_use", "id": "u1", "name": margin::TOOL, + "input": {"id": "flow", "markdown": "a"}}, + {"type": "tool_use", "id": "u2", "name": margin::TOOL, + "input": {"id": "flow", "markdown": "b"}}]}), + Some(6) => call(json!({"id": "flow", "title": "Flow two", "markdown": "c"})), + _ => call(json!({"id": "flow", "title": "Flow three", "markdown": "d"})), + }})) + .collect::>()}) + } + _ => json!({}), + }) + .await; + let mut app = app(client); + app.note_call("c1", &json!({"id": "flow", "from": "calc.add/flow"}), None); + let shown = |app: &App| match &app.margin.note("flow").unwrap().body { + margin::Body::From { shown, .. } => shown.as_deref().cloned(), + _ => None, + }; + // A read cut short leaves where it was read from. + app.read_references().await; + assert_eq!(shown(&app), None); + assert!( + app.references + .values() + .all(|reference| reference.node.is_none()) + ); + app.references_read = None; + app.read_references().await; + assert_eq!(shown(&app), Some(margin::Body::Markdown("b".into()))); + assert_eq!(app.margin.note("flow").unwrap().title, "Flow"); + // Retitled there, it is here too. + app.references_read = None; + app.read_references().await; + assert_eq!(shown(&app), Some(margin::Body::Markdown("c".into()))); + assert_eq!(app.margin.note("flow").unwrap().title, "Flow two"); + // Given its own title here, it keeps it. + let titled = json!({"id": "flow", "title": "Mine", "from": "calc.add/flow"}); + app.note_call("c2", &titled, None); + app.references_read = None; + app.read_references().await; + assert_eq!(shown(&app), Some(margin::Body::Markdown("d".into()))); + assert_eq!(app.margin.note("flow").unwrap().title, "Mine"); + } + #[tokio::test] async fn a_task_that_ended_with_nothing_to_say_said_nothing_and_did_not_fail() { let client = daemon_answering(|asked| match asked["op"].as_str() { @@ -10745,6 +11277,99 @@ mod tests { ); } + #[tokio::test] + async fn the_footer_counts_what_needs_the_person_and_a_click_opens_status() { + use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; + let mut app = app(daemon_answering(|_| json!({})).await); + app.model = "opus".into(); + // Nothing counted while nothing runs or waits. + let (line, counter) = app.footer(100); + assert!(counter.is_none() && !line.to_string().contains('●')); + let ask = |id: &str| { + asks::call( + id, + &json!({"question": "Merge?", "options": [ + {"label": "Yes", "explanation": "e"}, {"label": "No", "explanation": "e"}]}), + ) + .unwrap() + }; + app.asks = vec![ask("c1"), ask("c2")]; + // With no windows for them, the tasks started, as their roster reads them. + let task = |name: &str, state: &str| started::Task { + bot: format!("calc.{name}"), + name: name.into(), + brief: "b".into(), + state: state.into(), + }; + app.tasks + .insert(0, vec![task("add", "working"), task("neg", "done")]); + app.tasks + .insert(1, vec![task("add", "working"), task("div", "working")]); + let (line, counter) = app.footer(100); + let text = line.to_string(); + let counter = counter.unwrap(); + let shown: String = (text.chars()) + .skip(counter.start as usize) + .take(counter.len()) + .collect(); + assert_eq!(shown, "● 2 ● 2"); + assert!(text.contains("● 2 ● 2 · b"), "{text}"); + assert_eq!(text.width(), 100); + // Its colors as a roster's: cyan at work, yellow for what needs + // the person. + let dots: Vec<_> = (line.spans.iter()) + .filter(|span| span.content == "●") + .map(|span| span.style.fg) + .collect(); + assert_eq!(dots, [Some(Color::Cyan), Some(Color::Yellow)]); + app.counter_at = Some((20, 10 + counter.start..10 + counter.end)); + let click = |column| { + Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column, + row: 20, + modifiers: KeyModifiers::NONE, + }) + }; + app.key(click(9 + counter.start)).await; + assert!(app.pane.is_none()); + app.key(click(10 + counter.start)).await; + assert!(app.pane.as_ref().is_some_and(pane::Pane::is_card)); + } + + #[tokio::test] + async fn the_footer_counts_tasks_at_work_whose_rosters_are_not_drawn() { + let client = daemon_answering(|asked| { + assert_eq!(asked["active"], true); + // Two pages of them. + match asked["after"].as_str() { + None => json!({"next_after": "calc.ask", "bots": [ + {"name": "calc.add", "created_by": "calc"}, + {"name": "calc.ask", "created_by": "calc"}]}), + // A side chat of its own is no task. + Some(_) => json!({"next_after": null, "bots": [ + {"name": "calc.mul", "created_by": "calc"}, + {"name": "calc.side", "created_by": "calc", "expire_ms": 60000}, + {"name": "other.fix", "created_by": "other"}]}), + } + }) + .await; + let mut app = app(client); + app.bot = "calc".into(); + // Only one roster still drawn: its task needs the person. + app.tasks.insert( + 0, + vec![started::Task { + bot: "calc.ask".into(), + name: "ask".into(), + brief: "b".into(), + state: "needs you".into(), + }], + ); + app.read_running_tasks().await; + assert_eq!(app.agents(), (2, 1)); + } + #[tokio::test] async fn the_footer_names_the_branch() { let mut app = app(daemon_answering(|_| json!({})).await); @@ -11094,9 +11719,50 @@ mod tests { ); } + #[tokio::test] + async fn the_margin_opens_with_its_first_note_and_goes_once_it_holds_none() { + let mut app = app(daemon_with(json!([])).await); + let margin = |app: &App| app.pane.as_ref().is_some_and(pane::Pane::is_margin); + app.margin_back(); + assert!(!margin(&app)); + let post = json!({"id": "repro", "title": "Repro", "markdown": "x"}); + app.margin.apply("m1", &post, None, None); + app.margin_back(); + assert!(margin(&app)); + // Its last note taken away, with the keys in it, it goes. + app.pane_keys = true; + app.margin + .apply("m2", &json!({"id": "repro", "remove": true}), None, None); + app.margin_back(); + assert!(!margin(&app) && !app.pane_keys && !app.margin.closed()); + // Asked for, it shows with nothing in it. + app.toggle_margin(); + app.margin_back(); + assert!(margin(&app) && app.pane_keys && !app.margin.closed()); + // Closed by the person, a note does not open it again. + app.close_pane(true); + app.margin.apply("m3", &post, None, None); + app.margin_back(); + assert!(!margin(&app) && app.margin.closed()); + // Emptied under /status, Esc there goes back to no margin. + app.toggle_margin(); + app.margin_back(); + app.open_pane(pane::Pane::card("/status", STATUS_KEYS)); + assert!(app.under.is_some()); + app.margin + .apply("m4", &json!({"id": "repro", "remove": true}), None, None); + app.margin_back(); + assert!(app.under.is_none() && !app.pane.as_ref().unwrap().over); + app.close_pane(true); + app.margin_back(); + assert!(app.pane.is_none()); + } + #[tokio::test] async fn what_opens_lays_over_the_margin_and_esc_steps_back_to_it() { let mut app = app(daemon_with(json!([])).await); + let post = json!({"id": "repro", "title": "Repro", "markdown": "x"}); + app.margin.apply("m1", &post, None, None); app.margin_back(); assert!(app.pane.as_ref().is_some_and(pane::Pane::is_margin)); app.open_pane(pane::Pane::card("/status", STATUS_KEYS)); @@ -11124,6 +11790,8 @@ mod tests { #[tokio::test] async fn back_from_what_is_over_the_margin_is_the_margin_and_forward_lays_it_over_again() { let mut app = app(daemon_with(json!([])).await); + let post = json!({"id": "repro", "title": "Repro", "markdown": "x"}); + app.margin.apply("m1", &post, None, None); app.margin_back(); app.open_pane(pane::Pane::card("/status", STATUS_KEYS)); assert!(app.step_pane(true)); @@ -11141,6 +11809,8 @@ mod tests { std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("a.md"), "# A\n").unwrap(); let mut app = app(daemon_with(json!([])).await); + let post = json!({"id": "repro", "title": "Repro", "markdown": "x"}); + app.margin.apply("m1", &post, None, None); app.margin_back(); app.follow(&format!("file://{}", dir.join("a.md").display())); assert!(app.pane.as_ref().is_some_and(|p| !p.is_margin() && p.over)); @@ -11183,17 +11853,25 @@ mod tests { texts(&app)[..], ["✎ Routing today · in the margin", "after"] ); - // Closed, out again; taken away, its marker alone. + // Retitled, as a task's note read for the first time is, its + // marker says so. + let retitle = json!({"id": "routing", "title": "Routing now", "markdown": "a to c"}); + app.daemon(turn_event("tool_started", 3, call("m3", retitle))) + .await; + app.fold_notes(true); + assert_eq!(texts(&app)[..], ["✎ Routing now · in the margin", "after"]); + // Closed, out again; taken away, by a × or the agent, gone from + // the conversation too, the margin shown or not. app.fold_notes(false); assert_eq!(texts(&app).len(), 3); - app.margin.remove("routing", None); - app.fold_notes(false); - assert_eq!( - texts(&app)[..], - ["✎ Routing today · in the margin", "after"] - ); let link = Some("sonny:note/routing".to_owned()); assert!(app.screen.links().contains(&link)); + app.margin.remove("routing", None); + app.fold_notes(false); + assert_eq!(texts(&app)[..], ["after"]); + app.fold_notes(true); + assert_eq!(texts(&app)[..], ["after"]); + assert!(!app.screen.links().contains(&link)); } #[tokio::test] @@ -11256,6 +11934,8 @@ mod tests { #[tokio::test] async fn the_margin_shows_by_itself_only_where_the_conversation_keeps_its_width() { let mut app = app(daemon_with(json!([])).await); + let post = json!({"id": "repro", "title": "Repro", "markdown": "x"}); + app.margin.apply("m1", &post, None, None); app.margin_back(); let (chat, margin) = app.split(Rect::new(0, 0, 170, 40)); assert_eq!((chat.width, margin.map(|m| m.width)), (100, Some(69))); @@ -11267,8 +11947,6 @@ mod tests { assert_eq!(margin.map(|m| m.width), Some(64)); // A note's marker clicked shows it there too. app.pane_keys = false; - let post = json!({"id": "repro", "title": "Repro", "markdown": "x"}); - app.margin.apply("m1", &post, None, None); app.follow("sonny:note/repro"); let (_, margin) = app.split(Rect::new(0, 0, 130, 40)); assert!(margin.is_some()); diff --git a/sonny/src/margin.rs b/sonny/src/margin.rs index ff61e415b..67afb3510 100644 --- a/sonny/src/margin.rs +++ b/sonny/src/margin.rs @@ -24,7 +24,7 @@ use crate::transcript::Markdown; /// The name of the tool a bot keeps notes in the margin with. pub const TOOL: &str = "margin"; /// The most notes a margin holds: a new one past it lets the oldest go. -const MOST_NOTES: usize = 20; +pub const MOST_NOTES: usize = 20; /// The most of a note's Markdown kept, and of a file read for one. const NOTE_BYTES: usize = 64 * 1024; /// The most lines of a file a note shows. @@ -62,8 +62,10 @@ const DESCRIPTION: &str = "Keep something beside the conversation, in the margin no longer holds. Give markdown (rendered as your replies are: tables, math, Mermaid, html \ fences and pictures included) or file (a path in your own folder, shown as the file \ stands and kept fresh as it changes; for a file in a task's copy, put its content in \ - markdown instead, since that copy goes away with the task). Never put a question or \ - anything the person must answer in a note."; + markdown instead, since that copy goes away with the task), or from (a task's note as \ + \"task/id\", as your task updates name it, shown here as the task keeps it and kept once \ + the task is gone, rather than copied). Never put a question or anything the person must \ + answer in a note."; /// The `margin` tool as Sonny declares it on a bot. pub fn tool() -> Value { @@ -81,6 +83,8 @@ pub fn tool() -> Value { "description": "The note, as Markdown."}, "file": {"type": "string", "minLength": 1, "description": "Or a file to show, absolute or from your folder."}, + "from": {"type": "string", "pattern": "^[A-Za-z0-9._-]{1,128}/[a-z0-9][a-z0-9-]{0,39}$", + "description": "Or a task's note to show, as \"task/id\"."}, "remove": {"type": "boolean", "description": "true takes the note away."} }, "required": ["id"], @@ -95,6 +99,23 @@ pub fn tool() -> Value { pub enum Body { Markdown(String), File(String), + /// A file's text as read where another bot keeps it, shown as the + /// file is: only what a [`Body::From`] shows. + Read { + path: String, + text: String, + }, + /// Another bot's note `id`, as it was last read there: kept once that + /// bot or its note is gone. + From { + bot: String, + id: String, + /// Its title there, as this note last took it. + title: Option, + shown: Option>, + /// The folder what it shows was read from there, for its pictures. + folder: Option, + }, } #[derive(Clone, Debug, PartialEq)] @@ -134,12 +155,10 @@ impl Kept { fn json(&self) -> Value { let notes: Vec = (self.notes.iter()) .map(|note| { - let (kind, body) = match ¬e.body { - Body::Markdown(text) => ("markdown", text), - Body::File(path) => ("file", path), - }; - json!({"id": note.id, "title": note.title, kind: body, "call": note.call, - "after": note.after, "turn": note.turn}) + let mut json = json!({"id": note.id, "title": note.title, "call": note.call, + "after": note.after, "turn": note.turn}); + body_json(¬e.body, &mut json); + json }) .collect(); json!({"notes": notes, "seen": self.seen, "closed": self.closed}) @@ -150,10 +169,7 @@ impl Kept { let text = |note: &Value, key: &str| note[key].as_str().map(str::to_owned); let notes = (json["notes"].as_array().into_iter().flatten()) .filter_map(|note| { - let body = match text(note, "file") { - Some(path) => Body::File(path), - None => Body::Markdown(text(note, "markdown")?), - }; + let body = body_read(note)?; Some(Note { id: text(note, "id")?, title: text(note, "title")?, @@ -175,6 +191,286 @@ impl Kept { } } +/// `body` written into a kept note's `json`. +fn body_json(body: &Body, json: &mut Value) { + match body { + Body::Markdown(text) => json["markdown"] = json!(text), + Body::File(path) => json["file"] = json!(path), + Body::Read { path, text } => { + json["read"] = json!(path); + json["text"] = json!(text); + } + Body::From { + bot, + id, + title, + shown, + folder, + } => { + json["from"] = json!(format!("{bot}/{id}")); + json["shown_title"] = json!(title); + json["shown_folder"] = json!(folder); + if let Some(shown) = shown { + let mut kept = json!({}); + body_json(shown, &mut kept); + json["shown"] = kept; + } + } + } +} + +/// What [`body_json`] wrote. +fn body_read(json: &Value) -> Option { + let text = |key: &str| json[key].as_str().map(str::to_owned); + if let Some((bot, id)) = json["from"].as_str().and_then(from) { + let shown = body_read(&json["shown"]) + .filter(|shown| !matches!(shown, Body::From { .. })) + .map(Box::new); + return Some(Body::From { + bot, + id, + title: text("shown_title"), + shown, + folder: text("shown_folder"), + }); + } + if let Some(path) = text("read") { + return Some(Body::Read { + path, + text: text("text").unwrap_or_default(), + }); + } + match text("file") { + Some(path) => Some(Body::File(path)), + None => Some(Body::Markdown(text("markdown")?)), + } +} + +/// The note a bot's history `item` changes in its margin, and how: a +/// `margin` call its margin takes, or the person's message taking one +/// away, read as a call that does. +fn changed(item: &Value) -> Option<(String, Value)> { + if item["role"] == "user" { + let said = match &item["content"] { + Value::Array(parts) => parts.iter().find_map(|part| part["text"].as_str()), + content => content.as_str(), + }; + let id = closed(said?)?; + return Some((id.to_owned(), json!({"id": id, "remove": true}))); + } + if item["type"] != "function_call" || item["name"] != TOOL { + return None; + } + let arguments = (item["arguments"].as_str()) + .and_then(|text| serde_json::from_str::(text).ok()) + .filter(fits)?; + let id = arguments["id"].as_str()?.trim().to_owned(); + Some((id, arguments)) +} + +/// The bot and the note a `from` names, as `bot/id`. +pub fn from(text: &str) -> Option<(String, String)> { + let (bot, id) = text.trim().rsplit_once('/')?; + let named = |c: char| c.is_ascii_alphanumeric() || "._-".contains(c); + (!bot.is_empty() && bot.len() <= 128 && bot.chars().all(named) && is_id(id)) + .then(|| (bot.to_owned(), id.to_owned())) +} + +/// Another bot's note, as read from its history: the newest node read, +/// the bot read, the folder its files are read from, whether the bot is gone, and the +/// note as last posted there with its file's text, when it shows one. +#[derive(Default)] +pub struct Reference { + pub node: Option, + /// The bot's identity, which a later bot given its name does not share. + pub bot_id: Option, + pub workspace: Option, + pub gone: bool, + title: String, + /// Whether it was given its title there, not its id for want of one: + /// untitled, one it shows of another bot's is titled as that one. + titled: bool, + body: Option, + file: Option, + /// Taken away there since it was last posted. + removed: bool, + /// The notes its margin holds, oldest first, as far as read: one + /// put there past [`MOST_NOTES`] takes the oldest away. + held: Vec, + /// Notes read as taken away, the latest few: put there again, they + /// are new ones. + left: VecDeque, + /// Whether its history was read from its start: a note not yet held + /// is then a new one, not one from before what was read. + pub whole: bool, + /// The note it shows of another bot's, when it shows one, as read + /// from there: one step on, no further. + pub through: Option>, + /// The bot and note it was read from, when read through another. + pub names: Option<(String, String)>, +} + +impl Reference { + /// The bot's `items` read, oldest first: its margin calls for note + /// `id` change what is shown. + pub fn read(&mut self, id: &str, items: &[Value]) { + for item in items { + let Some((at, arguments)) = changed(item) else { + continue; + }; + // Taken away there, by the bot, the person or one put there + // past the most it holds, it stays here, and a new one starts + // untitled. + if arguments["remove"] == true { + self.held.retain(|held| *held != at); + self.removed |= at == id; + self.left(at); + continue; + } + if !self.held.contains(&at) { + if self.whole || self.left.contains(&at) { + self.left.retain(|left| *left != at); + self.held.push(at.clone()); + if self.held.len() > MOST_NOTES { + let gone = self.held.remove(0); + self.removed |= gone == id; + self.left(gone); + } + } else if self.held.len() < MOST_NOTES { + // Maybe one from before what was read, so older than + // the rest: never taken for a new one that puts the + // oldest out. + self.held.insert(0, at.clone()); + } + } + if at != id { + continue; + } + // Kept, as there, when changed without one. + match arguments["title"].as_str().map(str::trim) { + Some(title) if !title.is_empty() => { + self.title = title.to_owned(); + self.titled = true; + } + _ if self.title.is_empty() || self.removed => { + self.title = id.to_owned(); + self.titled = false; + } + _ => {} + } + let recreated = std::mem::take(&mut self.removed); + let body = match ( + arguments["from"].as_str().and_then(from), + arguments["file"].as_str(), + arguments["markdown"].as_str(), + ) { + (Some((bot, id)), ..) => Body::From { + bot, + id, + title: None, + shown: None, + folder: None, + }, + (_, Some(file), _) if !file.trim().is_empty() => Body::File(file.trim().to_owned()), + (.., Some(markdown)) => Body::Markdown(cut(markdown, NOTE_BYTES).to_owned()), + // Put there again with nothing in it, as its own margin does. + _ if recreated => Body::Markdown(String::new()), + // Retitled only. + _ => continue, + }; + if !matches!((&self.body, &body), (Some(Body::File(a)), Body::File(b)) if a == b) { + self.file = None; + } + self.body = Some(body); + } + } + + /// Note `id` read as taken away there, one of the latest few kept. + fn left(&mut self, id: String) { + self.left.retain(|left| *left != id); + self.left.push_back(id); + if self.left.len() > 4 * MOST_NOTES { + self.left.pop_front(); + } + } + + /// Whether the bot's `items` set the note `id`'s text and title at + /// once, or take it away: what is shown from there on can be read + /// without older ones. + pub fn starts(id: &str, items: &[Value]) -> bool { + items.iter().filter_map(changed).any(|(at, arguments)| { + at == id + && (arguments["remove"] == true + || arguments["title"] + .as_str() + .is_some_and(|t| !t.trim().is_empty()) + && (arguments["markdown"].is_string() + || arguments["from"].as_str().and_then(from).is_some() + || arguments["file"] + .as_str() + .is_some_and(|f| !f.trim().is_empty()))) + }) + } + + /// Whether the note is taken away there: what was read of it stays. + pub fn removed(&self) -> bool { + self.removed + } + + /// The other bot's note this one shows, by bot and id. + pub fn shows(&self) -> Option<(String, String)> { + match &self.body { + Some(Body::From { bot, id, .. }) => Some((bot.clone(), id.clone())), + _ => None, + } + } + + /// The file the note shows, its path in the bot's folder: none once + /// the note is taken away there, what was read of it kept as it was. + pub fn file(&self) -> Option { + let Some(Body::File(path)) = &self.body.as_ref().filter(|_| !self.removed) else { + return None; + }; + Some(Path::new(self.workspace.as_deref()?).join(path)) + } + + /// The file's `text` as read now, as [`read`] bounds it: kept once it + /// can no longer be. + pub fn file_read(&mut self, text: Option) { + if let Some(text) = text { + self.file = Some(text); + } + } + + /// The note's title there, what it shows, and the folder that is + /// read from: a file as the text read of it, so that it stays once + /// the bot's folder is gone. + pub fn shown(&self) -> Option<(&str, Body, Option)> { + let workspace = self.workspace.as_deref().map(PathBuf::from); + let body = match self.body.as_ref()? { + // As read there, under this one's title, or that one's when + // it has none of its own. + Body::From { .. } => { + let (title, body, folder) = self.through.as_ref()?.shown()?; + let title = if self.titled { &self.title } else { title }; + return Some((title, body, folder)); + } + // Not shown until read: what was kept of it stays. + Body::File(path) => { + let folder = (workspace.map(|w| w.join(path))) + .and_then(|file| file.parent().map(Path::to_path_buf)); + let body = Body::Read { + path: path.clone(), + text: self.file.clone()?, + }; + return Some((&self.title, body, folder)); + } + body => body.clone(), + }; + Some((&self.title, body, workspace)) + } +} + /// A file a note shows, as last read: its time, when it was looked at, /// and its text. struct Read { @@ -233,6 +529,49 @@ impl Margin { } } + /// The notes that show another bot's: each note's id, the bot, and + /// the note there. + pub fn references(&self) -> Vec<(String, String, String)> { + (self.kept.notes.iter()) + .filter_map(|note| match ¬e.body { + Body::From { bot, id, .. } => Some((note.id.clone(), bot.clone(), id.clone())), + _ => None, + }) + .collect() + } + + /// The note `note` shows of another bot's, read there as `body`, + /// with that note's `title` when this one was given none. + pub fn resolved(&mut self, note: &str, title: &str, body: Body, read_in: Option) { + let Some(at) = self.kept.notes.iter().position(|n| n.id == note) else { + return; + }; + let note = &mut self.kept.notes[at]; + let Body::From { + title: took, + shown, + folder, + .. + } = &mut note.body + else { + return; + }; + let read_in = read_in.map(|folder| folder.display().to_string()); + // Titled as there for as long as it was given no title of its own. + let inherited = note.title == note.id || Some(¬e.title) == took.as_ref(); + let retitled = inherited && !title.is_empty() && note.title != title; + if shown.as_deref() == Some(&body) && !retitled && *folder == read_in { + return; + } + *shown = Some(Box::new(body)); + *folder = read_in; + if retitled { + note.title = title.to_owned(); + *took = Some(title.to_owned()); + } + self.save(); + } + /// The plan shown over the notes now `steps`, none taking it away. pub fn set_plan(&mut self, steps: &[String]) { if self.plan != steps { @@ -249,6 +588,11 @@ impl Margin { self.kept.notes.iter().find(|note| note.id == id) } + /// Whether it holds no note and no plan. + pub fn is_empty(&self) -> bool { + self.kept.notes.is_empty() && self.plan.is_empty() + } + /// Whether the person closed the margin. pub fn closed(&self) -> bool { self.kept.closed @@ -317,9 +661,39 @@ impl Margin { .as_str() .map(str::trim) .filter(|t| !t.is_empty()); - let body = match (arguments["file"].as_str(), arguments["markdown"].as_str()) { - (Some(file), _) if !file.trim().is_empty() => Some(Body::File(file.trim().to_owned())), - (_, Some(markdown)) => Some(Body::Markdown(cut(markdown, NOTE_BYTES).to_owned())), + let had = at.map(|at| &self.kept.notes[at].body); + let body = match ( + arguments["from"].as_str().and_then(from), + arguments["file"].as_str(), + arguments["markdown"].as_str(), + ) { + // The same note again keeps what was read of it. + (Some((bot, id)), ..) => Some(match had { + Some(Body::From { + bot: b, + id: i, + title, + shown, + folder, + }) if *b == bot && *i == id => Body::From { + bot, + id, + title: title.clone(), + shown: shown.clone(), + folder: folder.clone(), + }, + _ => Body::From { + bot, + id, + title: None, + shown: None, + folder: None, + }, + }), + (_, Some(file), _) if !file.trim().is_empty() => { + Some(Body::File(file.trim().to_owned())) + } + (.., Some(markdown)) => Some(Body::Markdown(cut(markdown, NOTE_BYTES).to_owned())), _ => None, }; match at { @@ -421,7 +795,7 @@ impl Margin { let files: Vec = (self.kept.notes.iter()) .filter_map(|note| match ¬e.body { Body::File(path) => Some(workspace.join(path)), - Body::Markdown(_) => None, + Body::Markdown(_) | Body::Read { .. } | Body::From { .. } => None, }) .collect(); self.reads.retain(|path, _| files.contains(path)); @@ -446,12 +820,28 @@ impl Margin { most: usize, ) -> Option>> { let note = self.note(id)?.body.clone(); - let mut body = match ¬e { + // Another bot's, its pictures read where it keeps them. + let (shown, folder) = match ¬e { + Body::From { + shown: Some(shown), + folder, + .. + } => (shown.as_ref(), folder.as_deref().map(Path::new)), + body => (body, None), + }; + let mut body = match shown { Body::Markdown(text) => { - let mut markdown = Markdown::in_folder(workspace); + let mut markdown = Markdown::in_folder(folder.unwrap_or(workspace)); crate::transcript::markdown(&mut markdown, text, width, leads()) } Body::File(path) => self.file_lines(workspace, path, width), + Body::Read { path, text } => { + text_lines(Path::new(path), text, folder.unwrap_or(workspace), width) + } + Body::From { bot, id, .. } => { + let line = format!("{BODY}{bot}'s note {id}, once it is read"); + vec![Line::from(line).dim()] + } }; if body.len() > most { let more = body.len() - most; @@ -540,44 +930,9 @@ impl Margin { Ok(text) => text, Err(error) => return vec![Line::from(format!("{BODY}{error}")).dim()], }; - let extension = (path.extension().and_then(|e| e.to_str())) - .unwrap_or_default() - .to_lowercase(); - let lines: Vec<&str> = text.lines().collect(); - let shown = lines.len().min(FILE_LINES); // Its pictures read from beside it. - let folder = path.parent().unwrap_or(workspace); - let mut out = match extension.as_str() { - "md" | "markdown" | "mdx" => { - let mut markdown = Markdown::document(folder); - crate::transcript::markdown( - &mut markdown, - &lines[..shown].join("\n"), - width, - leads(), - ) - } - // Code as code, whatever it holds. - _ => { - let mut code = crate::highlight::Code::file(&path, lines.first().unwrap_or(&"")); - let lead = || Span::raw(BODY); - (lines[..shown].iter()) - .flat_map(|text| { - let text = text.replace('\t', " "); - let spans = match &mut code { - Some(code) => code.line(&text), - None => vec![Span::raw(text)], - }; - crate::transcript::wrap(Line::from(spans), width, lead(), lead()) - }) - .collect() - } - }; - if lines.len() > shown { - let more = lines.len() - shown; - out.push(Line::from(format!("{BODY}… {more} more lines")).dim()); - } - out + let folder = path.parent().unwrap_or(workspace).to_owned(); + text_lines(&path, text, &folder, width) } } @@ -587,7 +942,7 @@ fn leads() -> (Line<'static>, Line<'static>) { } /// The file at `path`, as much of it as a note shows. -fn read(path: &Path) -> Result { +pub fn read(path: &Path) -> Result { use std::io::Read; let mut bytes = Vec::new(); // A pipe or a device could hold the window up: only files are read. @@ -650,6 +1005,42 @@ fn head(note: &Note, width: usize, live: bool) -> (Line<'static>, crate::screen: (line, buttons) } +/// A file's `text` as a note shows the file at `path`: Markdown as +/// replies read, its pictures from `folder`, anything else as code. +fn text_lines(path: &Path, text: &str, folder: &Path, width: usize) -> Vec> { + let extension = (path.extension().and_then(|e| e.to_str())) + .unwrap_or_default() + .to_lowercase(); + let lines: Vec<&str> = text.lines().collect(); + let shown = lines.len().min(FILE_LINES); + let mut out = match extension.as_str() { + "md" | "markdown" | "mdx" => { + let mut markdown = Markdown::document(folder); + crate::transcript::markdown(&mut markdown, &lines[..shown].join("\n"), width, leads()) + } + // Code as code, whatever it holds. + _ => { + let mut code = crate::highlight::Code::file(path, lines.first().unwrap_or(&"")); + let lead = || Span::raw(BODY); + (lines[..shown].iter()) + .flat_map(|text| { + let text = text.replace('\t', " "); + let spans = match &mut code { + Some(code) => code.line(&text), + None => vec![Span::raw(text)], + }; + crate::transcript::wrap(Line::from(spans), width, lead(), lead()) + }) + .collect() + } + }; + if lines.len() > shown { + let more = lines.len() - shown; + out.push(Line::from(format!("{BODY}… {more} more lines")).dim()); + } + out +} + /// What the agent is told when the person takes its note `id` away, /// with the next model call. pub fn closed_message(id: &str) -> String { @@ -664,6 +1055,21 @@ pub fn closed(text: &str) -> Option<&str> { is_id(id).then_some(id) } +/// The note a `margin` call's `tool_started` event names, and whether the +/// call takes it away. The event may carry only the start of a long +/// call's arguments: the id is read from where it shows then. +pub fn called(data: &Value) -> Option<(String, bool)> { + let text = data["arguments"].as_str()?; + if let Ok(arguments) = serde_json::from_str::(text) { + let id = arguments["id"].as_str().filter(|id| is_id(id))?; + return Some((id.to_owned(), arguments["remove"] == true)); + } + let rest = text.split_once("\"id\"")?.1.trim_start(); + let rest = rest.strip_prefix(':')?.trim_start().strip_prefix('"')?; + let id = rest.split_once('"')?.0; + is_id(id).then(|| (id.to_owned(), false)) +} + /// Whether `arguments` are a call the daemon runs, as it checks one: only /// the tool's fields, each of its type. Applied as the call starts, a /// call it would refuse changes nothing. @@ -697,10 +1103,6 @@ fn is_id(id: &str) -> bool { #[derive(Default)] pub struct Glance { pub name: String, - /// Its tasks at work. - pub running: usize, - /// Questions and calls waiting on the person. - pub waiting: usize, /// Triggers and subscriptions. pub behind: usize, pub context: Option, @@ -725,24 +1127,6 @@ impl Glance { spans.push(span); at += width; }; - if self.running > 0 { - let text = format!("{} running", self.running); - fact( - &mut spans, - text.clone(), - Span::raw(text).fg(Color::Cyan), - Some("status"), - ); - } - if self.waiting > 0 { - let text = format!("{} need you", self.waiting); - fact( - &mut spans, - text.clone(), - Span::raw(text).fg(Color::Yellow), - Some("status"), - ); - } if self.behind > 0 { let text = format!("↻ {}", self.behind); fact( @@ -817,6 +1201,215 @@ mod tests { assert!(again.closed); } + #[test] + fn a_task_s_note_taken_away_by_the_person_or_by_newer_ones_starts_again_empty() { + let call = |arguments: Value| json!({"type": "function_call", "name": TOOL, "arguments": arguments.to_string()}); + let closed = |id: &str| json!({"type": "message", "role": "user", "content": [{"type": "input_text", "text": closed_message(id)}]}); + // Taken away by the person there: no longer read, and put there + // again with a title only, it shows nothing. + let mut reference = Reference { + workspace: Some("/work/calc.add".into()), + ..Default::default() + }; + reference.read( + "flow", + &[ + call(json!({"id": "flow", "title": "Flow", "file": "src/flow.rs"})), + closed("other"), + ], + ); + assert!(reference.file().is_some()); + let taken = [closed("flow")]; + assert!(Reference::starts("flow", &taken)); + reference.read("flow", &taken); + assert!(reference.removed()); + assert_eq!(reference.file(), None); + reference.read("flow", &[call(json!({"id": "flow", "title": "Again"}))]); + let (title, body, _) = reference.shown().unwrap(); + assert_eq!((title, body), ("Again", Body::Markdown(String::new()))); + // Read from the start of its history, put out by as many newer + // ones as its margin holds, it goes as there; one fewer, taken + // away or changed again, keeps it. + let mut reference = Reference { + whole: true, + ..Default::default() + }; + let mut items = vec![call( + json!({"id": "flow", "title": "Flow", "markdown": "one"}), + )]; + items.push(call(json!({"id": "n0", "markdown": "-"}))); + items.push(call(json!({"id": "n0", "remove": true}))); + for n in 1..MOST_NOTES { + items.push(call(json!({"id": format!("n{n}"), "markdown": "-"}))); + items.push(call(json!({"id": format!("n{n}"), "markdown": "again"}))); + } + reference.read("flow", &items); + assert!(!reference.removed()); + reference.read("flow", &[call(json!({"id": "last", "markdown": "-"}))]); + assert!(reference.removed()); + reference.read("flow", &[call(json!({"id": "flow", "title": "New"}))]); + let (title, body, _) = reference.shown().unwrap(); + assert_eq!((title, body), ("New", Body::Markdown(String::new()))); + // Read from partway, notes it did not see put there may be older + // ones changed: one new one puts out one of those, not this. + let mut reference = Reference::default(); + let mut items = vec![call( + json!({"id": "flow", "title": "Flow", "markdown": "one"}), + )]; + for n in 1..MOST_NOTES { + items.push(call(json!({"id": format!("old{n}"), "markdown": "again"}))); + } + items.push(call( + json!({"id": "fresh", "title": "Fresh", "markdown": "new"}), + )); + reference.read("flow", &items); + assert!(!reference.removed()); + } + + #[test] + fn a_task_s_untitled_note_showing_another_s_is_titled_as_that_one() { + let call = |arguments: Value| json!({"type": "function_call", "name": TOOL, "arguments": arguments.to_string()}); + let mut sub = Reference::default(); + sub.read( + "flow", + &[call( + json!({"id": "flow", "title": "Sub flow", "markdown": "deep"}), + )], + ); + let mut reference = Reference::default(); + reference.read( + "flow", + &[call(json!({"id": "flow", "from": "calc.sub/flow"}))], + ); + reference.through = Some(Box::new(sub)); + assert_eq!(reference.shown().unwrap().0, "Sub flow"); + // Given its own, it keeps it. + reference.read("flow", &[call(json!({"id": "flow", "title": "Flow"}))]); + assert_eq!(reference.shown().unwrap().0, "Flow"); + } + + #[test] + fn a_task_s_note_is_shown_as_it_stands_there_and_kept() { + let mut margin = margin(); + let post = json!({"id": "flow", "from": "calc.add/flow"}); + assert!(fits(&post)); + margin.apply("c1", &post, None, None); + assert_eq!( + margin.references(), + [("flow".into(), "calc.add".into(), "flow".into())] + ); + // Read there: its calls for that note, oldest first. + let call = |arguments: Value| json!({"type": "function_call", "name": TOOL, "arguments": arguments.to_string()}); + let mut reference = Reference::default(); + reference.read( + "flow", + &[ + call(json!({"id": "flow", "title": "Request flow", "markdown": "one"})), + call(json!({"id": "other", "markdown": "not this"})), + call(json!({"id": "flow", "markdown": "two"})), + // Retitled only, it keeps what it shows. + call(json!({"id": "flow", "title": "Request path"})), + call(json!({"id": "flow", "remove": true})), + ], + ); + // A retitle alone is no start to read back to. + let retitled = [call(json!({"id": "flow", "title": "Request path"}))]; + assert!(!Reference::starts("flow", &retitled)); + let (title, body, _) = reference.shown().unwrap(); + margin.resolved("flow", title, body, None); + let note = margin.note("flow").unwrap(); + // Titled as the task titled it, it showing what the task's shows. + assert_eq!(note.title, "Request path"); + let Body::From { shown, .. } = ¬e.body else { + panic!() + }; + assert_eq!(shown.as_deref(), Some(&Body::Markdown("two".into()))); + // A file, as its text: kept once the task's folder is gone. + reference.workspace = Some("/work/calc.add".into()); + reference.read( + "flow", + &[call(json!({"id": "flow", "file": "src/flow.rs"}))], + ); + assert_eq!( + reference.file(), + Some(PathBuf::from("/work/calc.add/src/flow.rs")) + ); + reference.file_read(Some("fn flow() {}".into())); + reference.file_read(None); + // Taken away and put there again untitled with nothing in it, it + // shows nothing, as there. + let mut again = Reference::default(); + again.read( + "flow", + &[ + call(json!({"id": "flow", "title": "Old", "markdown": "old"})), + call(json!({"id": "flow", "remove": true})), + call(json!({"id": "flow", "title": "New"})), + ], + ); + let (title, body, _) = again.shown().unwrap(); + assert_eq!((title, body), ("New", Body::Markdown(String::new()))); + // Taken away there, the file is no longer read; posted again, it is. + let removed = [call(json!({"id": "flow", "remove": true}))]; + reference.read("flow", &removed); + assert_eq!(reference.file(), None); + reference.read( + "flow", + &[call(json!({"id": "flow", "file": "src/flow.rs"}))], + ); + assert!(reference.file().is_some()); + let (_, body, folder) = reference.shown().unwrap(); + let read = |path: &str, text: &str| Body::Read { + path: path.into(), + text: text.into(), + }; + assert_eq!(body, read("src/flow.rs", "fn flow() {}")); + let changes = margin.changes(); + assert_eq!(folder, Some(PathBuf::from("/work/calc.add/src"))); + margin.resolved("flow", "Request flow", body.clone(), folder.clone()); + margin.resolved("flow", "Request flow", body, folder); + assert_eq!(margin.changes(), changes + 1); + // Shown as the file is there: a page as its source. + let rows = |margin: &mut Margin| { + (margin + .body("flow", 80, Path::new("/nowhere"), 100) + .unwrap() + .iter()) + .map(|line| line.to_string()) + .collect::>() + .join("\n") + }; + assert!(rows(&mut margin).contains("fn flow() {}")); + margin.resolved("flow", "Request flow", read("page.html", "

hi

"), None); + assert!(rows(&mut margin).contains("

hi

")); + // A file not read yet, here anew, leaves what was kept of it. + let mut anew = Reference::default(); + anew.read( + "flow", + &[ + call(json!({"id": "flow", "title": "Flow", "file": "src/flow.rs"})), + call(json!({"id": "flow", "remove": true})), + ], + ); + assert!(anew.shown().is_none()); + // Posted again, it keeps what was read; and it is kept. + margin.apply( + "c2", + &json!({"id": "flow", "from": "calc.add/flow"}), + None, + None, + ); + let again = Kept::read(&margin.kept.json()); + assert_eq!(again.notes, margin.kept.notes); + let Body::From { shown: Some(_), .. } = &margin.note("flow").unwrap().body else { + panic!() + }; + // Only a bot's note names one. + for named in ["calc.add", "/flow", "calc.add/Flow", "a b/flow"] { + assert_eq!(from(named), None, "{named}"); + } + } + #[test] fn a_call_read_again_is_not_applied_twice() { let mut margin = margin(); @@ -1061,26 +1654,20 @@ mod tests { assert_eq!(quiet.line().0.to_string(), " calc"); let busy = Glance { name: "calc".into(), - running: 2, - waiting: 1, behind: 3, context: Some(41_000), }; let (line, buttons) = busy.line(); let text = line.to_string(); - assert!( - text.starts_with(" calc · 2 running · 1 need you · ↻ 3 · "), - "{text}" - ); + assert!(text.starts_with(" calc · ↻ 3 · "), "{text}"); let urls: Vec<&str> = buttons.iter().map(|b| b.2.as_str()).collect(); - assert_eq!(urls, ["sonny:status", "sonny:status", "sonny:background"]); + assert_eq!(urls, ["sonny:background"]); let slice = |(from, to): (u16, u16)| -> String { text.chars() .skip(from as usize) .take((to - from) as usize) .collect() }; - assert_eq!(slice((buttons[0].0, buttons[0].1)), "2 running"); - assert_eq!(slice((buttons[2].0, buttons[2].1)), "↻ 3"); + assert_eq!(slice((buttons[0].0, buttons[0].1)), "↻ 3"); } }