diff --git a/docs/sonny/agent-name-opens.png b/docs/sonny/agent-name-opens.png new file mode 100644 index 000000000..5a162ef94 Binary files /dev/null and b/docs/sonny/agent-name-opens.png differ diff --git a/docs/sonny/agent-name-status.png b/docs/sonny/agent-name-status.png new file mode 100644 index 000000000..2aa68c03b Binary files /dev/null and b/docs/sonny/agent-name-status.png differ diff --git a/sonny/FEATURES.md b/sonny/FEATURES.md index 67d3d3835..cad2435dd 100644 --- a/sonny/FEATURES.md +++ b/sonny/FEATURES.md @@ -76,7 +76,7 @@ per feature; group small things into the row they belong to. | Staged messages, the open line while a reply streams, turn timers | shipped | streaming 10-26 #172; staged 10-27 #175; timers 10-28 #266; each message a call streams drawn apart 10-30 #396 | Show what is in flight | ? | | Footer: context size, branch, ↻ background count, agents at work | shipped | context 10-26 #163; branch 10-27 #182; ↻ 10-28 #267; agents at work 10-29 #367; context as % left of the window the daemon sends 10-30 #432 | One line of state under the composer | ? | | Colour: 16 terminal colours, one meaning each; marks say it first, muted gray and diff tints from the theme's own colours, `NO_COLOR` honoured | shipped | added 10-29 #326; one visual system 10-30 #405 | Colour that carries meaning, in any theme, and reads with low contrast, colour blindness or no colour | ? | -| Links and file paths clickable | shipped | a path opens from the folder that named it 10-10 #98; web addresses 10-27 #179; link text, paths 10-27 #230; never drawn over a list 10-30 #416; copied, not opened, over SSH with no display 10-30 #437 | Open what an agent names | ? | +| Links, file paths and agents' names clickable | shipped | a path opens from the folder that named it 10-10 #98; web addresses 10-27 #179; link text, paths 10-27 #230; never drawn over a list 10-30 #416; copied, not opened, over SSH with no display 10-30 #437; an agent's name opens it in the pane 10-30 #435 | Open what an agent names | ? | | Find (Ctrl+F) and long histories (older pages on scroll, Home to the first turn) | shipped | Ctrl+F 10-28 #258, 10-29 #343; pages 10-29 #331; lay out near the view 10-29 #353; Home 10-29 #364 | Long conversations stay fast and searchable | ? | | One Markdown renderer for every surface; code coloured with Oniguruma; headings bold with dim `#` marks, no hue | shipped | renderer 10-29 #325; Oniguruma 10-29 #336; Markdown as prose 10-30 #405 | Same rendering everywhere, three times faster colouring | ? | | Pictures: SVG, Mermaid, math and HTML drawn in the terminal | shipped | added 10-26 #147; `\[ \]` math 10-29 #313; sent once 10-29 #338 | Agents can show, not only tell | ? | @@ -100,7 +100,7 @@ per feature; group small things into the row they belong to. | Feature | Status | History | Why | Used? | |---|---|---|---|---| -| The pane beside the conversation: files, `/status`, `/background`, tasks; back and forward | shipped | added 10-28 #264; `/background` 10-28 #283; tasks in the pane 10-29 #326; `/status` opens the agent clicked 10-30 #384; ↑↓ Enter pick one 10-30 #421; a two-column gutter each side of its line 10-30 #427 | Look at something without losing the conversation | ? | +| The pane beside the conversation: files, `/status`, `/background`, tasks; back and forward | shipped | added 10-28 #264; `/background` 10-28 #283; tasks in the pane 10-29 #326; `/status` opens the agent clicked 10-30 #384; ↑↓ Enter pick one 10-30 #421; a two-column gutter each side of its line 10-30 #427; `/status` opens on the agent named last 10-30 #435 | Look at something without losing the conversation | ? | | Pictures, HTML and diagrams in the pane; a folder as a tree | shipped | drawing 10-29 #303; tree 10-29 #319 | Read what agents made, in place | ? | | `/side`: a side chat with a copy of the agent | shipped | added 10-27 #183; a full fork with every tool 10-29 #315 | Ask about the work without steering it | ? | | Margin: notes agents keep beside the conversation (`/margin`) | experimental | added 10-29 #308; a task's notes in the lead's margin 10-29 #367; replay 10-30 #383; opens on its first note 10-30 #384; a note opens the agent that asks, ↗ 10-30 #395 | A place for state that outlives one reply | ? | diff --git a/sonny/README.md b/sonny/README.md index 0920530ba..20fa94006 100644 --- a/sonny/README.md +++ b/sonny/README.md @@ -504,7 +504,9 @@ one of its tasks, with its name (`👀 calc.lead`) under the agent's own. shows `↻ 2 triggers` beside its model. A click on an agent's name opens it in the pane, as a click on a task in a roster does, and so does Enter on the agent ↑ and ↓ pick (`›`); ‹ in its title, - or Ctrl+[ from the conversation, goes back to `/status`. + or Ctrl+[ from the conversation, goes back to `/status`. It opens + with the agent the conversation named last already picked, so + `/status` and Enter open the task a reply just pointed you to. ![/status with ↑↓ pick · enter open in its title, the picked task marked ›](../docs/sonny/status-keys.png) @@ -757,6 +759,20 @@ and Ctrl+] shows it again. ![Three tasks started together, each with what it does now](../docs/sonny/tasks-roster.png) +An agent's name anywhere in the conversation opens that agent in the +pane, as a GitHub `@name` links to its account: in a reply ("tally.halves +has a question for you"), in a row of task updates, in a message from +another agent, and in the pane's own text. Only a name the daemon knows +is one, so it is drawn in the agents' blue, and a word that only looks +like one stays plain. In a row a click opens, such as `↳ From calc.neg +▸`, the name opens the agent and the rest of the row the message. Leads +and tasks write an agent's whole name when they point you to it, where +that helps you act. + +![A reply naming tally.halves, clicked: the task opened in the pane](../docs/sonny/agent-name-opens.png) + +![/status opened with tally.halves, the agent named last, already picked](../docs/sonny/agent-name-status.png) + Sonny keeps to the terminal's own 16 colors, so a theme sets their shades, and each means one thing: magenta is you (your `›`), blue the agents (task names, who sent a message, the lead in the footer), green diff --git a/sonny/agents/lead.md b/sonny/agents/lead.md index 04c981580..719089ecd 100644 --- a/sonny/agents/lead.md +++ b/sonny/agents/lead.md @@ -26,7 +26,7 @@ which joins the turn it is running, or starts one when it is idle, so it acts on - Raise to the person what only the person can do: approvals, decisions, credentials or access, merges, and failures you cannot fix. Put each decision to them with the ask tool, which shows it where they answer with a click, rather than in a reply. A task's question is answered in that task, so do not ask it again; carry an answer already written down across, as above. Before asking anyone, the person included, look where the answer may be written down, such as the code, docs, tickets and chat history your tools reach: ask only for what is not written down or needs their decision, and say what you checked. Keep their open decisions few, the one that matters most first; settle smaller ones yourself by your best reading of what they want, and say which way you went. - Nothing goes out in the person's name, such as a message, a comment, a review, a publish or a ticket change, from you or a task, unless the person's own words ask for that send. When they do not name it, as in "follow your recs", ask before it goes with the ask tool, its why quoting their words and saying exactly what would go out and to whom. A brief for a task that sends quotes the words that asked for it. -Lead a reply with what needs the person, when anything does, pointing to the ask or margin note that holds each, then what finished, briefly, with the evidence. Do not retell what the person already read in a task's or another lead's tab, such as the answer to a turn they asked for there; say only what it changes for the rest of the work. Say what you checked and what you only infer, and when something you said turns out wrong, correct it in your next reply. A status reply with nothing to raise or pass on is one line, and a turn Task updates started with nothing for the person ends with an empty reply. A question asking for your view, such as what you think of a change, gets a real answer: answer it yourself when a quick look is enough, otherwise have a task do the reading and answer when it reports back, without reading the same things yourself. +Lead a reply with what needs the person, when anything does, pointing to the ask or margin note that holds each, then what finished, briefly, with the evidence. When you point them to another agent, such as the task where they answer a question or the one with a finding, write its whole name, project and all, such as shop.fix: a click on it opens that agent beside the conversation. Name one where it helps them act, not every agent you mention. Do not retell what the person already read in a task's or another lead's tab, such as the answer to a turn they asked for there; say only what it changes for the rest of the work. Say what you checked and what you only infer, and when something you said turns out wrong, correct it in your next reply. A status reply with nothing to raise or pass on is one line, and a turn Task updates started with nothing for the person ends with an empty reply. A question asking for your view, such as what you think of a change, gets a real answer: answer it yourself when a quick look is enough, otherwise have a task do the reading and answer when it reports back, without reading the same things yourself. To wake an agent later, again and again, or when something happens, add a trigger with the triggers tool, for yourself unless it names a bot, or a start to make one on the first fire in this folder, as a task is made. Write its message as what to check and when to stop, including removing its own trigger once the work is done; give it an if command when a check that finds nothing should cost no turn. To watch something for the person, such as a team channel, an inbox or a queue, do not check it in your own turns: read the automation skill listed under Skills and give it a monitor, its own agent, so only what needs you reaches you. diff --git a/sonny/agents/task.md b/sonny/agents/task.md index 112320af8..81980adf9 100644 --- a/sonny/agents/task.md +++ b/sonny/agents/task.md @@ -3,7 +3,7 @@ name: task description: What every agent Sonny starts other than a lead is told, after any role it starts in --- -Your lead, LEAD below, is the lead that started you: "${AGENT_BOT%%.*}.lead" or another lead of the folder, named like "${AGENT_BOT%%.*}.lead.NAME", as created_by in "$AGENT_BIN" ls --name "$AGENT_BOT" says. The other agents of this project are named like you, PROJECT.NAME: "$AGENT_BIN" ls --name "${AGENT_BOT%%.*}.*" lists them, other leads' tasks among them. Your lead sees every message written to you and how each of your turns ends, so it needs no report or copy beyond your reply. It holds all of the person's work, so ask it, with "$AGENT_BIN" run --detach --delivery queue --bot LEAD -- MESSAGE, for what lies outside your brief instead of telling the person to: a task of its own for separate work the person asks of you, saying so to the person in a line, or what another agent found or decided. To see what another agent is doing or found, read its turns with the history tool and its bot, not its files or Agent's store. Message another agent only when your work depends on its work or its work on yours, the same way. A message another agent wrote ends with a line saying so: it is that agent's, never the person's. When you need something you do not have, look in the other agents' turns or the commons, or ask your lead, before you tell the person you do not know. When you find something agents in other projects could use, post it to the commons. Put a decision only the person can make to them with the ask tool, not in your reply: your lead hears the question, and the person's answer comes to you. +Your lead, LEAD below, is the lead that started you: "${AGENT_BOT%%.*}.lead" or another lead of the folder, named like "${AGENT_BOT%%.*}.lead.NAME", as created_by in "$AGENT_BIN" ls --name "$AGENT_BOT" says. The other agents of this project are named like you, PROJECT.NAME: "$AGENT_BIN" ls --name "${AGENT_BOT%%.*}.*" lists them, other leads' tasks among them. Your lead sees every message written to you and how each of your turns ends, so it needs no report or copy beyond your reply. It holds all of the person's work, so ask it, with "$AGENT_BIN" run --detach --delivery queue --bot LEAD -- MESSAGE, for what lies outside your brief instead of telling the person to: a task of its own for separate work the person asks of you, saying so to the person in a line, or what another agent found or decided. When you point the person to another agent, write its whole name, project and all, such as shop.fix: a click on it opens that agent. To see what another agent is doing or found, read its turns with the history tool and its bot, not its files or Agent's store. Message another agent only when your work depends on its work or its work on yours, the same way. A message another agent wrote ends with a line saying so: it is that agent's, never the person's. When you need something you do not have, look in the other agents' turns or the commons, or ask your lead, before you tell the person you do not know. When you find something agents in other projects could use, post it to the commons. Put a decision only the person can make to them with the ask tool, not in your reply: your lead hears the question, and the person's answer comes to you. The person you work for may be under pressure, short on time, or carrying more than they mention. Care about how they are doing and whether they succeed, not only about the ask in front of you. Take work off their plate rather than adding to it. Notice what they will need next: do it when it plainly follows from what they asked, otherwise offer it. Help them see what they would not see alone, and get better at what they do. When you see a better way to work, something that keeps costing them time, a risk, something they missed or said they would do and have not, or a mistake of theirs, tell them as soon as you see it, plainly and without softening it, with what to do instead: they would rather hear it straight than late. Say each thing once; when it comes back, the repeat is the news, so name the pattern and coach the habit, not the instance. Do not lecture, flatter or pad a reply to look caring: helping them well is the care. diff --git a/sonny/src/links.rs b/sonny/src/links.rs index 3e23a3660..b99cfb76c 100644 --- a/sonny/src/links.rs +++ b/sonny/src/links.rs @@ -76,8 +76,15 @@ pub fn cells( None => { let lead = word.len() - word.trim_start_matches(['(', '[', '{', '\'']).len(); let path = trim(&word[lead..]); - match file(&path) { - Some(target) => (lead, target, path.len()), + // A name's or a path's own `'s` is no part of it, unless + // a file's name ends so. + let opens = |path: String| file(&path).map(|target| (target, path.len())); + let found = opens(path.clone()).or_else(|| { + let short = path.strip_suffix("'s")?; + opens(trim(short)) + }); + match found { + Some((target, kept)) => (lead, target, kept), None => continue, } } @@ -156,6 +163,77 @@ fn label( parts } +/// What a click on an agent's name does: opens it in the pane. +pub const AGENT: &str = "sonny:agent/"; + +/// Whether `word` may be an agent's name, as the daemon names bots (1 to +/// 128 of `A-Z a-z 0-9 . _ -`) and as a project names its agents: a +/// project and a name a dot apart (`calc.neg`, `_tools.lead`), a letter in +/// it, and no dot at either end. So `e.g` may be and is asked, and a +/// version (`0.7.0`) or a plain word is not. +pub fn may_name(word: &str) -> bool { + let bytes = word.as_bytes(); + (3..=128).contains(&bytes.len()) + && word.contains('.') + && !word.contains("..") + && bytes.iter().any(u8::is_ascii_alphabetic) + && bytes[0] != b'.' + && bytes[bytes.len() - 1] != b'.' + && (bytes.iter()).all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(b)) +} + +/// The agents named in what is drawn, as the daemon answered: a word that +/// may be a name is asked about once, and opens the agent once it is one, +/// as GitHub links an `@name` only when that account is there. +#[derive(Default)] +pub struct Names { + /// Each word asked about: an agent's name, or when it was found not + /// to be one. + known: std::collections::HashMap>, + /// The words to ask about. + wanted: Vec, +} + +/// How many words are kept known: past it they are asked about again. +const NAMES_KEPT: usize = 4096; +/// How long a word is taken for no agent's name: an agent may be made +/// with it later, or the daemon may not have answered. +const NOT_NAMES_KEPT: std::time::Duration = std::time::Duration::from_secs(30); + +impl Names { + /// Where `word` opens, if it names an agent; one not yet asked about + /// is asked about next. + pub fn link(&mut self, word: &str) -> Option { + if !may_name(word) { + return None; + } + match self.known.get(word) { + Some(None) => Some(format!("{AGENT}{word}")), + Some(Some(at)) if at.elapsed() < NOT_NAMES_KEPT => None, + _ => { + if !self.wanted.iter().any(|wanted| wanted == word) { + self.wanted.push(word.to_owned()); + } + None + } + } + } + + /// The words to ask the daemon about, taken. + pub fn wanted(&mut self) -> Vec { + std::mem::take(&mut self.wanted) + } + + /// What the daemon said of `word`. + pub fn learn(&mut self, word: String, agent: bool) { + if self.known.len() >= NAMES_KEPT { + self.known.clear(); + } + let not = (!agent).then(std::time::Instant::now); + self.known.insert(word, not); + } +} + /// Where a web address starts in `word`, if one does: not inside a word, /// as in `xhttps://`. fn web(word: &str) -> Option { @@ -481,7 +559,12 @@ mod tests { file_line(&dir, "src/main.rs#L40-L44"), Some(format!("{main}#L40")) ); - assert_eq!(file_line(&dir, "src/main.rs"), Some(main)); + assert_eq!(file_line(&dir, "src/main.rs"), Some(main.clone())); + // A file's `'s` is no part of it, unless its name ends so. + assert_eq!(opens("src/main.rs's tests", 80), [(2, main.clone())]); + std::fs::write(dir.join("src/owner's"), "").unwrap(); + let owners = format!("file://{}/src/owner%27s", dir.display()); + assert_eq!(opens("see src/owner's", 80), [(6, owners)]); std::fs::remove_dir_all(&dir).unwrap(); } @@ -496,4 +579,50 @@ mod tests { .is_empty() ); } + + #[test] + fn a_word_may_be_an_agents_name_only_as_a_project_names_them() { + for word in ["calc.neg", "shop.web.fix", "a1.b_2-c", "e.g", "_tools.lead"] { + assert!(may_name(word), "{word}"); + } + for word in [ + "neg", + "0.7.0", + ".hidden", + "calc.", + "calc..neg", + "a/b.c", + "x*.y", + "ab", + ] { + assert!(!may_name(word), "{word}"); + } + } + + #[test] + fn a_name_is_asked_about_once_and_opens_once_it_is_an_agents() { + let mut names = Names::default(); + assert_eq!(names.link("calc.neg"), None); + assert_eq!(names.link("calc.neg"), None); + assert_eq!(names.link("main.rs"), None); + assert_eq!(names.link("plain"), None); + assert_eq!(names.wanted(), ["calc.neg", "main.rs"]); + assert!(names.wanted().is_empty()); + names.learn("calc.neg".into(), true); + names.learn("main.rs".into(), false); + assert_eq!( + names.link("calc.neg").as_deref(), + Some("sonny:agent/calc.neg") + ); + assert_eq!(names.link("main.rs"), None); + assert!(names.wanted().is_empty(), "not asked again at once"); + let long_ago = std::time::Instant::now() - NOT_NAMES_KEPT; + names.known.insert("main.rs".into(), Some(long_ago)); + assert_eq!(names.link("main.rs"), None); + assert_eq!( + names.wanted(), + ["main.rs"], + "asked again once it was a while" + ); + } } diff --git a/sonny/src/main.rs b/sonny/src/main.rs index 8e36aa7df..e5b5aea52 100644 --- a/sonny/src/main.rs +++ b/sonny/src/main.rs @@ -481,6 +481,9 @@ async fn start_lookout(client: &Client) -> Result<(), String> { .await } +/// How long a word waits on the daemon to say whether it names an agent. +const NAME_LOOKUP: Duration = Duration::from_secs(2); + /// What Sonny names its companions in the daemon. const LOOKOUT: &str = "sonny-lookout"; const APPROVER: &str = "approver-auto"; @@ -1928,6 +1931,11 @@ struct App { /// Buttons pressed whose work waits for the loop: a note the person /// took away, to tell the agent; a view to open. pressed: Vec, + /// The agents named in what is drawn, as the daemon answered: a + /// click on one's name opens it in the pane. + names: std::cell::RefCell, + /// The words being asked about, and what the daemon said of each. + naming: Option>>, /// A program in the pane drew something: the screen is drawn again. woken: Arc, /// Every call drawn, rather than the clean view of the bot's words and @@ -2735,6 +2743,8 @@ impl App { plan_full: None, jump: None, pressed: Vec::new(), + names: Default::default(), + naming: None, woken: Arc::new(tokio::sync::Notify::new()), verbose: false, instruct: None, @@ -2945,6 +2955,7 @@ impl App { self.read_tasks().await; self.read_running_tasks().await; self.read_references().await; + self.read_names().await; for what in std::mem::take(&mut self.pressed) { self.press(&what).await; } @@ -3779,6 +3790,9 @@ impl App { brief: started::first(arguments["brief"].as_str().unwrap_or("")), state: "starting".into(), }; + // Its name opens it at once, where it was drawn before it was made. + self.names.get_mut().learn(task.bot.clone(), true); + self.screen.relink(); self.tasks_read = None; if let Some((at, drawn)) = self.starting && drawn == self.rows_drawn @@ -4044,8 +4058,8 @@ impl App { /// What a button clicked in the conversation does: `fold/N` opens or /// folds the calls folded as N; `said/NODE` opens or shuts the message - /// between agents at NODE; `task/BOT` opens the task BOT in the pane; - /// `answer/N/ID` answers the question + /// between agents at NODE; `task/BOT`, and `agent/BOT` on BOT's name + /// in the conversation, opens BOT in the pane; `answer/N/ID` answers the question /// call ID asked with its option N, while it is open. async fn press_row(&mut self, button: &str) { if let Some(at) = button @@ -4060,7 +4074,7 @@ impl App { self.draw_cluster(at); } } - if let Some(bot) = button.strip_prefix("task/") { + if let Some(bot) = (button.strip_prefix("task/")).or(button.strip_prefix("agent/")) { self.open_task(bot); } if let Some(node) = button @@ -4595,6 +4609,49 @@ impl App { .collect() } + /// The words drawn that may name agents, asked about off the loop + /// that draws and reads keys, a batch at a time: once one does, the + /// links on screen are found again, so its name opens it. + async fn read_names(&mut self) { + if self.naming.as_ref().is_some_and(|job| job.is_finished()) { + let job = self.naming.take().expect("a lookup"); + let mut found = false; + for (word, agent) in job.await.unwrap_or_default() { + let agent = agent && word != self.bot; + found |= agent; + self.names.get_mut().learn(word, agent); + } + if found { + self.screen.relink(); + if let Some(pane) = &mut self.pane { + pane.screen.relink(); + } + } + } + if self.naming.is_some() { + return; + } + let wanted = self.names.get_mut().wanted(); + if wanted.is_empty() { + return; + } + let (client, woken) = (self.client.clone(), self.woken.clone()); + self.naming = Some(tokio::spawn(async move { + let mut known = Vec::new(); + for word in wanted { + let asked = client.request("bots", json!({"name": word, "limit": 1})); + // Unanswered, it is not taken for a name for a while. + let agent = match tokio::time::timeout(NAME_LOOKUP, asked).await { + Ok(Ok(page)) => page["bots"][0]["name"] == word.as_str(), + _ => false, + }; + known.push((word, agent)); + } + woken.notify_one(); + known + })); + } + /// 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. @@ -6041,6 +6098,15 @@ impl App { picked: None, pick: None, }); + // Picked already, the agent named last in what shows: Enter opens + // the one the conversation just pointed to. + if let Some(named) = self.screen.last_agent() + && let Some(watch) = &mut self.watch + { + watch.pick = card_bots(watch, self.width.max(40), &self.bot) + .iter() + .position(|bot| *bot == named); + } self.open_pane(pane::Pane::card("/status", PICK_KEYS)); } @@ -6851,15 +6917,7 @@ impl App { && watch.usage.is_none() && watch.commons.is_none() { - let bots: Vec = (watch_card(watch, self.width.max(40), &self.bot).2) - .into_iter() - .filter_map(|(_, mark)| match mark { - screen::Mark::Link(link) => link - .strip_prefix(&format!("{}task/", screen::BUTTON)) - .map(str::to_owned), - _ => None, - }) - .collect(); + let bots = card_bots(watch, self.width.max(40), &self.bot); if !bots.is_empty() { let last = bots.len() - 1; let pick = watch.pick.map(|pick| pick.min(last)); @@ -9021,7 +9079,9 @@ impl App { // Paths in a file are read from its folder first. let base = open.folder().unwrap_or(&workspace).to_owned(); let file = |word: &str| { - links::file_line(&base, word).or_else(|| links::file_line(&workspace, word)) + (links::file_line(&base, word)) + .or_else(|| links::file_line(&workspace, word)) + .or_else(|| self.names.borrow_mut().link(word)) }; pane_cursor = open.render(at, frame.buffer_mut(), self.pane_keys, switch, file); linked.extend(open.screen.link_cells(frame.buffer_mut())); @@ -9056,7 +9116,9 @@ impl App { Constraint::Min(0), ]) .areas(area); - let file = |word: &str| links::file_line(&workspace, word); + let file = |word: &str| { + links::file_line(&workspace, word).or_else(|| self.names.borrow_mut().link(word)) + }; let history = ratatui::layout::Rect { width: column, ..history @@ -9602,6 +9664,19 @@ const STATUS_KEYS: &str = "esc close"; /// What the title row says the keys do on `/status` itself. const PICK_KEYS: &str = "↑↓ pick · enter open · esc close"; +/// The agents `/status` lists, in its order: what its keys pick from. +fn card_bots(watch: &Watch, width: usize, me: &str) -> Vec { + (watch_card(watch, width, me).2) + .into_iter() + .filter_map(|(_, mark)| match mark { + screen::Mark::Link(link) => link + .strip_prefix(&format!("{}task/", screen::BUTTON)) + .map(str::to_owned), + _ => None, + }) + .collect() +} + /// `/status`'s card at `width`, each line after a space, its times /// running turns counted on from when its rows were read; whether any task in it /// is resolved; and the line each agent but `me` starts on, which a click diff --git a/sonny/src/screen.rs b/sonny/src/screen.rs index 5c2559e95..07ba2d9d8 100644 --- a/sonny/src/screen.rs +++ b/sonny/src/screen.rs @@ -1228,6 +1228,20 @@ impl Screen { } } + /// The agent named lowest in the rows drawn last: the conversation's + /// newest mention of one in view. + pub fn last_agent(&self) -> Option { + (self.links.iter().rev()) + .find_map(|(.., url)| url.strip_prefix(crate::links::AGENT)) + .map(str::to_owned) + } + + /// The links found again at the next draw: a word read anew may open + /// something now, as an agent's name the daemon has just answered for. + pub fn relink(&mut self) { + self.linked = None; + } + /// Draw the rows that fit `area` of `buf`: the newest, or those the /// view is scrolled to, the selection reversed and links underlined. /// `file` is where a word that may be a path opens, if it is one. @@ -1285,6 +1299,13 @@ impl Screen { self.links = self.find_links(drawn, &file); } for (y, from, to, url) in &self.links { + // An agent's name in the agents' color, as a button is drawn: + // never underlined. + if url.starts_with(crate::links::AGENT) { + for x in *from..(*to).min(area.right()) { + buf[(x, *y)].set_fg(Color::Blue); + } + } if url.starts_with(BUTTON) { continue; } @@ -1449,7 +1470,8 @@ impl Screen { } } // A row its line links opens there across its text, whatever was - // read from its words. + // read from its words but the agents they name, which open the + // agent: found first, they are what a click on them finds. for row in top..bottom { let Some(url) = &self.shown[row].link else { continue; @@ -1460,7 +1482,7 @@ impl Screen { .collect(); let from = text.width() - text.trim_start().width(); let to = text.trim_end().width().min(drawn.width as usize); - links.retain(|link| link.0 != y); + links.retain(|link| link.0 != y || link.3.starts_with(crate::links::AGENT)); if from < to { links.push((y, drawn.x + from as u16, drawn.x + to as u16, url.clone())); } @@ -2723,6 +2745,53 @@ mod tests { assert!(!screen.replace_tagged(Tag::Group(2), Vec::new(), Vec::new())); } + #[test] + fn an_agents_name_opens_it_in_its_color_even_on_a_row_that_opens_elsewhere() { + let mut screen = Screen::default(); + screen.push(vec![Line::from("calc.neg's tests pass; ask calc.add.")]); + screen.push_marked( + vec![Line::from("↳ From calc.neg ▸")], + vec![(0, Mark::Link(format!("{BUTTON}said/7")))], + ); + let area = Rect::new(0, 0, 40, 3); + let mut buf = Buffer::empty(area); + let agent = |word: &str| { + ["calc.neg", "calc.add"] + .contains(&word) + .then(|| format!("{}{word}", crate::links::AGENT)) + }; + screen.render(area, &mut buf, agent); + let click = |screen: &mut Screen, x, y| { + screen.mouse(mouse(MouseEventKind::Down(MouseButton::Left), x, y)); + screen.mouse(mouse(MouseEventKind::Up(MouseButton::Left), x, y)) + }; + // Its own `'s` and the sentence's dot are no part of it. + assert_eq!( + click(&mut screen, 2, 0), + Some(Action::Open("sonny:agent/calc.neg".into())) + ); + assert_eq!(click(&mut screen, 8, 0), None); + assert_eq!( + click(&mut screen, 30, 0), + Some(Action::Open("sonny:agent/calc.add".into())) + ); + assert_eq!(click(&mut screen, 35, 0), None); + assert_eq!(buf[(0, 0)].fg, Color::Blue); + assert!(!buf[(0, 0)].modifier.contains(Modifier::UNDERLINED)); + assert_eq!(buf[(9, 0)].fg, Color::Reset); + // The name opens the agent; the rest of the row, the message. + assert_eq!( + click(&mut screen, 9, 1), + Some(Action::Open("sonny:agent/calc.neg".into())) + ); + assert_eq!( + click(&mut screen, 3, 1), + Some(Action::Open("sonny:said/7".into())) + ); + assert_eq!(screen.last_agent().as_deref(), Some("calc.neg")); + assert!(screen.link_cells(&buf).is_empty(), "never an OSC 8 link"); + } + #[test] fn older_rows_drawn_above_keep_their_marks() { let mut screen = Screen::default();