diff --git a/client/src/config.rs b/client/src/config.rs index ed3ae4f47..216a88395 100644 --- a/client/src/config.rs +++ b/client/src/config.rs @@ -29,6 +29,7 @@ //! chime = false # rings when work ends or waits, the default //! verbose = true # every tool call drawn, not the clean view //! margin_opens = "never" # "always", the default, or "unless-closed" +//! agent_opens = "beside" # a click on an agent's name: "tab", the default, or "beside" //! suggest = false # no guess at the next message, on by default //! commons = false # agents go without the commons, on by default //! commons_invites = 3 # least threads a day that send invites (more with the square root of the week's agents), 0 none; answers add up to as many again @@ -175,6 +176,9 @@ pub struct Sonny { pub verbose: bool, /// When a note the agent puts up or changes opens the margin. pub margin_opens: MarginOpens, + /// Where a click on an agent's name takes the person; a right click + /// takes them to the other. + pub agent_opens: AgentOpens, /// Whether a copy of the agent guesses the person's next message after /// a turn they started, shown in the empty composer for Tab to take. pub suggest: bool, @@ -280,6 +284,7 @@ impl Default for Sonny { verbose: false, start_at_boot: false, margin_opens: MarginOpens::default(), + agent_opens: AgentOpens::default(), sources: Vec::new(), browser_confirm: BROWSER_CONFIRM.map(str::to_owned).to_vec(), } @@ -343,6 +348,17 @@ pub enum Reviewer { None, } +/// Where a click on an agent's name takes the person: `"tab"`, the +/// default, to the agent's own tab, where a lead in Rex, Herdr or cmux laid +/// it out; or `"beside"`, a look at it beside the conversation. A right +/// click takes them to the other, and an agent with no tab opens beside. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AgentOpens { + #[default] + Tab, + Beside, +} + /// A command Sonny's lookout runs, whose every printed line is news for /// the agents subscribed to it by name. #[derive(Clone, Debug, PartialEq)] @@ -658,6 +674,7 @@ fn parse(text: &str) -> Result { "chime", "verbose", "margin_opens", + "agent_opens", "suggest", "start_at_boot", "commons", @@ -806,6 +823,16 @@ fn parse(text: &str) -> Result { }; continue; } + if key == "agent_opens" { + config.sonny.agent_opens = match value.as_str() { + "tab" => AgentOpens::Tab, + "beside" => AgentOpens::Beside, + other => { + return Err(format!("sonny.agent_opens is tab or beside, not {other}")); + } + }; + continue; + } // The daemon judges models and levels when the agents are made. *match (name.as_str(), key.as_str()) { ("judge", "model") => &mut config.judge.model, @@ -1256,6 +1283,10 @@ mod tests { assert_eq!(config.sonny.margin_opens, MarginOpens::Always); let never = parse("[sonny]\nmargin_opens = \"never\"").unwrap(); assert_eq!(never.sonny.margin_opens, MarginOpens::Never); + assert_eq!(config.sonny.agent_opens, AgentOpens::Tab); + let beside = parse("[sonny]\nagent_opens = \"beside\"").unwrap(); + assert_eq!(beside.sonny.agent_opens, AgentOpens::Beside); + assert!(parse("[sonny]\nagent_opens = \"window\"").is_err()); let closed = parse("[sonny]\nmargin_opens = \"unless-closed\"").unwrap(); assert_eq!(closed.sonny.margin_opens, MarginOpens::UnlessClosed); assert!(config.sonny.chime && !config.sonny.verbose); diff --git a/docs/sonny/agent-pane-title.png b/docs/sonny/agent-pane-title.png new file mode 100644 index 000000000..451aec3a0 Binary files /dev/null and b/docs/sonny/agent-pane-title.png differ diff --git a/sonny/FEATURES.md b/sonny/FEATURES.md index 1231f4aed..07f465b92 100644 --- a/sonny/FEATURES.md +++ b/sonny/FEATURES.md @@ -88,7 +88,7 @@ names it: upgrade step `ID`. Both name the release they are deleted in. | 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: what you talk to and where on the left (permissions mark when not auto, model and effort, the folder and branch), what wants you on the right (needs you, failed, working, ↻ background, āœŽ notes, šŸ“Ž, context left); every item has a long and a short form and gives way by one ladder when the row is too long, what needs you never | 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; āœ— counts failures not yet seen in `/background` 10-30; the profile, only when not `lead` or `task` 10-31; decluttered (George, 10-31): tiers and a give-way ladder (words before marks before items, as Powerline drops segments), `āµāµ full access` / `āø ask me` / `⊘ read only` marks, the model as pickers name it, folders cut by whole parts as fish does, no agent name (the composer says it), hues only on what asks something of you, counts spaced instead of dotted, āœ— and ↻ open `/background` 10-31 | One line of state under the composer that stays readable in 44 to 100 columns | ? | | 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; a hue on a dim row drawn at full strength 10-31 | Colour that carries meaning, in any theme, and reads with low contrast, colour blindness or no colour | ? | -| 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 | ? | +| 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; a click goes to its tab when laid out, a right click opens it beside, `agent_opens` swaps them 10-31 #533 | 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; Mermaid and math drawn by Sonny's own QuickJS, not node or bun, so nothing to install 10-31 | Agents can show, not only tell | ? | diff --git a/sonny/README.md b/sonny/README.md index 71f7a5e41..4b9b95009 100644 --- a/sonny/README.md +++ b/sonny/README.md @@ -247,6 +247,7 @@ at its model's own effort (`AGENT_EFFORT` when its model is | A guess at your next message | `[sonny] suggest` (true, the default, or false) | | | Whether agents have the commons | `[sonny] commons` (true, the default, or false) | | | When a note opens the margin | `[sonny] margin_opens` (`always`, the default, `unless-closed` or `never`) | | +| Where a click on an agent's name goes | `[sonny] agent_opens` (`tab`, the default, or `beside`) | | | The daemon started with the machine | `[sonny] start_at_boot` (true or false, the default) | | | News from outside for the lookout | `[sonny.sources.NAME] command`, `every` | | @@ -509,7 +510,7 @@ way `/react` does, and each note in the margin has its own `↩`. - `/config` (or `/settings`) lists every key `~/.agent/config.toml` takes, with its value or what its default is, over the composer. ↑/↓ picks one; ←/→ steps a key with a few values (efforts, `web_search`, - `senders`, `side_chat`, `chime`, `verbose`, `margin_opens`, `suggest`, + `senders`, `side_chat`, `chime`, `verbose`, `margin_opens`, `agent_opens`, `suggest`, `start_at_boot`) through them and its default; Enter types one that takes a name (`model`, `provider`, `judge.model`, the profiles), empty for its default; ⌫ @@ -641,8 +642,8 @@ way `/react` does, and each note in the margin has its own `↩`. reopens it. Each agent's time is how long its running turn has been at it, or how long its last turn took. An agent with automations or follows of its own - shows `↻ 2 automations` 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 + shows `↻ 2 automations` beside its model. A click on an agent's name goes + to it as a click on a task in a roster does (below), and so does Enter on the agent ↑ and ↓ pick (`›`); ‹ in its title, or Ctrl+[ from the conversation, goes back to `/status`. It opens with the agent the conversation named last already picked, so @@ -1090,15 +1091,21 @@ say did not fail, but did not reply either, so it reads `no reply`, not once a turn ends, and `checked` once its goal's check passed. ![A task whose goal's check passed beside one still checking and one only done](../docs/sonny/tasks-checked.png) A click -on a task opens it in the pane: its own Sonny, live, to watch and to talk -to, as a side chat is. Ctrl+\ takes the keys back to the conversation and +on a task goes to its own tab, where the lead laid it out in Rex, Herdr or +cmux; a right click (or a click, with no tab) opens it in the pane: its own +Sonny, live, to watch and to talk to, as a side chat is. `[sonny] +agent_opens = "beside"` swaps the two. In the pane, its name in the title +goes to its tab too, drawn in the agents' blue, as Ctrl+T does; where the +title is narrow, its keys go from the last before the name is cut. + +![The pane's title at 72, 59 and 40 columns, outside a multiplexer and laid out, with the keys in the pane and in the chat](../docs/sonny/agent-pane-title.png) Ctrl+\ takes the keys back to the conversation and gives them to the pane again; from the conversation Ctrl+[ hides the task 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 +An agent's name anywhere in the conversation goes to that agent, as a +click on a task does, the way 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 diff --git a/sonny/src/cmux.rs b/sonny/src/cmux.rs index e30d156e9..ea666d32b 100644 --- a/sonny/src/cmux.rs +++ b/sonny/src/cmux.rs @@ -437,6 +437,11 @@ impl Backend for Cmux { } } + fn focus(&self, block: &Block) -> Result<(), String> { + let at = json!({"surface_id": block.id, "workspace_id": self.workspace}); + self.call("surface.focus", at).map(|_| ()) + } + /// cmux's heartbeats keep the stream from outliving Sonny: the first /// one written after it is gone ends the command. fn watch(&self) -> Option { diff --git a/sonny/src/config.rs b/sonny/src/config.rs index 61e9def5c..fae650cbb 100644 --- a/sonny/src/config.rs +++ b/sonny/src/config.rs @@ -56,7 +56,7 @@ fn short(key: &str) -> &str { } /// Every key `agent_client::config` reads. -pub const SETTINGS: [Setting; 28] = [ +pub const SETTINGS: [Setting; 29] = [ Setting { key: "model", about: "a new agent's model, when none is named (provider/model)", @@ -141,6 +141,12 @@ pub const SETTINGS: [Setting; 28] = [ values: &["always", "unless-closed", "never"], default: "always", }, + Setting { + key: "sonny.agent_opens", + about: "where a click on an agent's name takes you: its tab in Rex, Herdr or cmux, or a look beside the conversation; a right click, the other", + values: &["tab", "beside"], + default: "tab", + }, Setting { key: "sonny.suggest", about: "after a turn you started, a copy of the agent guesses your next message for Tab to take; one small call", diff --git a/sonny/src/exec.rs b/sonny/src/exec.rs index 35c2b773e..fe2d7aa7f 100644 --- a/sonny/src/exec.rs +++ b/sonny/src/exec.rs @@ -1925,12 +1925,15 @@ text("done");"#; ] ); assert_eq!(asked[1]["arguments"], json!({"ms": 10})); - // With no gate to ask, every call runs. + // With no gate to ask, every call runs. Its time is not what this + // run checks: 300 ms can go waiting for one of the tool host's + // script slots, shared with every other test running at once. + let roomy = script.replace("\"timeout_ms\": 300", "\"timeout_ms\": 30000"); let said = run( &hub, Some(&dir), "shop.lead", - script, + &roomy, Duration::from_secs(30), None, ) diff --git a/sonny/src/herdr.rs b/sonny/src/herdr.rs index 0216aeba6..ef262833b 100644 --- a/sonny/src/herdr.rs +++ b/sonny/src/herdr.rs @@ -563,6 +563,10 @@ impl Backend for Herdr { } } + fn focus(&self, block: &Block) -> Result<(), String> { + self.call(&["tab", "focus", &block.window]).map(|_| ()) + } + /// Herdr's CLI has no stream; its socket does, and Herdr names it in /// every pane it runs, as in the one Sonny runs in. fn watch(&self) -> Option { diff --git a/sonny/src/main.rs b/sonny/src/main.rs index 4e5e03d01..6b3a27113 100644 --- a/sonny/src/main.rs +++ b/sonny/src/main.rs @@ -1052,6 +1052,7 @@ async fn run() -> Result<(), String> { app.verbose = settings.verbose; app.zen = zen::focus(app.reactions_key.as_deref()); app.margin_opens = settings.margin_opens; + app.agent_opens = settings.agent_opens; // A bot keeps the model it was made with; --model runs this window's // turns on another. if let Some(model) = options.model.as_ref().filter(|m| **m != app.model) { @@ -1987,6 +1988,9 @@ struct App { /// The read under way, of the workspace named, and when the last one /// began: the branch can change under a running turn. place_job: Option<(String, tokio::task::JoinHandle)>, + /// The agent whose tab is being looked for, from a window with no + /// layout of its own, and the look. + tab_job: Option<(String, tokio::task::JoinHandle>)>, place_read: Option<(String, Instant)>, model: String, effort: Option, @@ -2188,6 +2192,8 @@ struct App { /// last looked at, and whether such a note opens it. margin_posted: u64, margin_opens: agent_client::config::MarginOpens, + /// Where a click on an agent's name takes the person. + agent_opens: agent_client::config::AgentOpens, /// The pane closed last, with its `/status` or `/background`, which /// Ctrl+] opens again. closed: Option<(pane::Pane, Option)>, @@ -2383,6 +2389,15 @@ struct App { suggestion: Option, } +/// What a job of the lead's layout has to say: a line in the lead's view, +/// or, for something the person asked of it, a notice; or that an agent +/// clicked has no tab, so it opens in the pane. +enum Told { + Line(String), + Notice(String), + Beside(String), +} + /// The lead's tasks, laid out in the project's home in a multiplexer: a window for /// each task while it works, opened as its turn starts. It follows every /// bot on its own connection, so the lead's own view is not disturbed. @@ -2391,8 +2406,8 @@ struct Fleet { /// the screen. layout: mux::Worker, /// What its jobs have to say in the lead's view, as they finish. - said: tokio::sync::mpsc::UnboundedReceiver, - tell: tokio::sync::mpsc::UnboundedSender, + said: tokio::sync::mpsc::UnboundedReceiver, + tell: tokio::sync::mpsc::UnboundedSender, client: Arc, events: Events, socket: PathBuf, @@ -2772,12 +2787,27 @@ impl Fleet { Err(error) => Some(error), }; if let Some(said) = said.filter(|_| tell) { - let _ = told.send(said); + let _ = told.send(Told::Line(said)); } }); self.doing(bot, "working"); } + /// Bring the task's own tab into view in the multiplexer, as the + /// person asked; looked for on the worker, after every window opened + /// so far. With none, `beside` opens it in the pane, else says so. + fn go_to(&self, bot: &str, beside: bool) { + let (bot, told) = (bot.to_owned(), self.tell.clone()); + self.layout.send(move |layout| { + if let Err(error) = layout.focus(&bot) { + let _ = told.send(match beside { + true => Told::Beside(bot), + false => Told::Notice(error), + }); + } + }); + } + /// Close the task's window, and every block it asked for. fn close(&self, bot: &str) { let bot = bot.to_owned(); @@ -3060,6 +3090,7 @@ impl App { workspace: record["workspace"].as_str().unwrap_or("").to_owned(), place: place::Place::default(), place_job: None, + tab_job: None, place_read: None, model, effort: record["effort"].as_str().map(str::to_owned), @@ -3165,6 +3196,7 @@ impl App { pane_keys: false, margin_posted: 0, margin_opens: Default::default(), + agent_opens: Default::default(), pane_mouse: false, closed: None, composer_at: None, @@ -3361,10 +3393,18 @@ impl App { // What the layout's worker said, between the daemon's events. let event = match news { Ok(event) => event, - Err(said) => { + Err(Told::Line(said)) => { self.fleet_said(said); continue; } + Err(Told::Notice(notice)) => { + self.notice = Some(notice); + continue; + } + Err(Told::Beside(bot)) => { + self.open_task(&bot); + continue; + } }; let Some(event) = event else { if let Some(fleet) = &mut self.fleet { @@ -3434,6 +3474,7 @@ impl App { self.shell_ran().await; self.listed(); self.follow_place().await; + self.follow_tab().await; self.count_behind().await; self.read_tasks().await; self.read_running_tasks().await; @@ -4531,6 +4572,59 @@ impl App { turn } + /// An agent named or picked: to its own tab, when the lead's layout + /// has one for it, or a look at it in the pane, as `[sonny] + /// agent_opens` says; `other`, a right click, the one it doesn't. With + /// no tab, it opens in the pane. + fn open_agent(&mut self, bot: &str, other: bool) { + use agent_client::config::AgentOpens; + let tab = (self.agent_opens == AgentOpens::Tab) != other; + match &self.fleet { + Some(fleet) if tab => fleet.go_to(bot, true), + // A task's window, or one in the pane, lays nothing out + // itself: its project's layout is found off the screen, and + // with no tab there the agent opens in the pane after all. + None if tab && self.tab_job.is_none() => { + let (client, socket, me) = ( + self.client.clone(), + self.options.socket.clone(), + self.bot.clone(), + ); + let lead = self.project.clone(); + let bot = bot.to_owned(); + let job = tokio::spawn({ + let bot = bot.clone(); + async move { + let project = match lead { + Some(project) => project, + None => project_of(&client, &me).await, + }; + tokio::task::spawn_blocking(move || { + mux::for_agent(&socket, &project, None)?.focus(&bot) + }) + .await + .map_err(|e| e.to_string())? + } + }); + self.tab_job = Some((bot, job)); + } + _ => self.open_task(bot), + } + } + + /// The tab looked for off the screen: with none, the agent opens in + /// the pane, as a click on it does where nothing is laid out. + async fn follow_tab(&mut self) { + if let Some((_, job)) = &self.tab_job + && job.is_finished() + { + let (bot, job) = self.tab_job.take().unwrap(); + if !matches!(job.await, Ok(Ok(()))) { + self.open_task(&bot); + } + } + } + /// The task `bot` opened in the pane: its own Sonny, to watch it and /// talk to it. fn open_task(&mut self, bot: &str) { @@ -4541,8 +4635,9 @@ impl App { match self.embed(&name, &args) { // Its title's button opens it in the main view. Ok(()) => { + let tab = self.fleet.is_some(); if let Some(pane) = &mut self.pane { - pane.as_agent(bot); + pane.as_agent(bot, tab); } } Err(error) => self.notice = Some(format!("could not open {name}: {error}")), @@ -4610,7 +4705,9 @@ 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`, and `agent/BOT` on BOT's name - /// in the conversation, opens BOT in the pane; `answer/N/ID` answers the question + /// in the conversation, goes to BOT (see `open_agent`), and + /// `other/BOT`, a right click on either, the other way; + /// `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 @@ -4626,7 +4723,10 @@ impl App { } } if let Some(bot) = (button.strip_prefix("task/")).or(button.strip_prefix("agent/")) { - self.open_task(bot); + self.open_agent(bot, false); + } + if let Some(bot) = button.strip_prefix("other/") { + self.open_agent(bot, true); } if let Some(node) = button .strip_prefix("said/") @@ -7857,6 +7957,14 @@ impl App { self.pressed.push(format!("main/{agent}")); return true; } + // Ctrl+T goes to its own tab in the multiplexer, as its name does. + if pane::to_tab(key) + && let Some(pane) = self.pane.as_ref().filter(|p| p.has_tab()) + && let Some(agent) = pane.agent.clone() + { + self.pressed.push(format!("tab/{agent}")); + return true; + } // Ctrl+[ and Ctrl+] step the pane, but for a program that has them. let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); let program = self.pane_keys && self.pane.as_ref().is_some_and(|p| p.program.is_some()); @@ -7901,7 +8009,8 @@ impl App { }; if key.code == KeyCode::Enter { if let Some(at) = pick { - self.open_task(&bots[at]); + let bot = bots[at].clone(); + self.open_agent(&bot, false); } } else if let Some(watch) = &mut self.watch { watch.pick = pick; @@ -8191,6 +8300,7 @@ impl App { ("ctrl+\\", "keys to the pane and back"), ("ctrl+[ ctrl+]", "hide the pane, show it again"), ("ctrl+g", "open the pane's agent in the main view"), + ("ctrl+t", "go to the pane's agent's own tab"), ("alt+t", "boost the next message; again for more"), ("ctrl+v", "paste a picture"), ("ctrl+c", "copy the selection, clear, or quit"), @@ -8457,6 +8567,15 @@ impl App { "status" if self.options.side.is_none() => self.status().await, // An agent's title button: this window becomes its Sonny. "main" => self.switch_command(id).await, + // Its name: its own tab, where the lead's tasks are laid out. + "tab" => match &self.fleet { + Some(fleet) => fleet.go_to(id, false), + None => { + self.notice = Some(format!( + "{id} has no tab: this Sonny is not in Rex, Herdr or cmux" + )) + } + }, "background" if self.options.side.is_none() => self.background(), "status" | "background" => {} _ => self.press_row(what).await, @@ -8929,6 +9048,7 @@ impl App { let settings = settings::Settings::load(); self.sounds.chime = settings.chime; self.margin_opens = settings.margin_opens; + self.agent_opens = settings.agent_opens; if self.verbose != settings.verbose { self.verbose = settings.verbose; self.repaint = true; @@ -12081,7 +12201,7 @@ mod tests { .is_some_and(|n| n.starts_with("/switch NAME")) ); let mut pane = pane::Pane::card("shop.lead", STATUS_KEYS); - pane.as_agent("shop.lead"); + pane.as_agent("shop.lead", false); app.open_pane(pane); app.input.set("/switch"); app.submit().await; diff --git a/sonny/src/mux.rs b/sonny/src/mux.rs index 35c6be9d9..8e508545e 100644 --- a/sonny/src/mux.rs +++ b/sonny/src/mux.rs @@ -161,6 +161,8 @@ pub trait Backend: Send { ) -> Result { self.split(anchor, "right", false, label, cwd, command, keep) } + /// Bring the block into view: its window made the one shown. + fn focus(&self, block: &Block) -> Result<(), String>; /// The block Sonny itself runs in, when in this home. fn own_block(&self) -> Option; fn places(&self) -> Result; @@ -576,6 +578,17 @@ impl Layout { self.backend.rename_window(window, name); } + /// Bring the bot's block into view where the person watches. + pub fn focus(&mut self, bot: &str) -> Result<(), String> { + let known = self.blocks.get(bot).cloned(); + let listed = self.listed(); + let block = (listed.iter()) + .find(|b| Some(&b.id) == known.as_ref()) + .or_else(|| listed.iter().find(|b| b.label == bot)) + .ok_or_else(|| format!("{bot} has no tab in {}", self.home()))?; + self.backend.focus(block) + } + /// Take on the block Sonny itself runs in, when in this home, named /// after the bot. pub fn adopt_own(&mut self, bot: &str) { @@ -901,6 +914,7 @@ mod tests { /// While set, the blocks cannot be read. down: Arc, news: RefCell>, + focused: RefCell>, } impl Fake { @@ -989,6 +1003,10 @@ mod tests { workspace: "w1".into(), } } + fn focus(&self, block: &Block) -> Result<(), String> { + self.focused.borrow_mut().push(block.id.clone()); + Ok(()) + } fn watch(&self) -> Option { self.news.borrow_mut().take() } @@ -1020,6 +1038,16 @@ mod tests { } } + #[test] + fn a_task_is_brought_into_view_by_its_block() { + let fake = Fake::default(); + fake.add("shop.fix", "w1"); + let mut layout = Layout::new(Box::new(fake)); + assert_eq!(layout.focus("shop.fix"), Ok(())); + let none = layout.focus("shop.gone"); + assert_eq!(none, Err("shop.gone has no tab in Fake home".into())); + } + #[test] fn with_no_news_the_home_is_read_once_a_batch() { let fake = Fake::default(); diff --git a/sonny/src/pane.rs b/sonny/src/pane.rs index 135e3939e..c83980e18 100644 --- a/sonny/src/pane.rs +++ b/sonny/src/pane.rs @@ -93,6 +93,10 @@ pub struct Pane { /// opens in the main view; and that button's columns as last drawn. pub agent: Option, main_at: Option<(u16, u16)>, + /// Where its name was last drawn, when a click on it goes to the + /// agent's own tab in the multiplexer. + name_at: Option<(u16, u16)>, + tab: bool, } /// The button beside an agent's name that opens it in the main view: the @@ -101,6 +105,9 @@ pub struct Pane { const MAIN: &str = " ↗"; /// What the keys do on an agent's Sonny in the pane. const AGENT_KEYS: &str = "ctrl+g main view Ā· ctrl+\\ chat"; +/// The same, for one laid out in a multiplexer, whose tab its name and +/// Ctrl+T go to. +const LAID_OUT_KEYS: &str = "ctrl+g main view Ā· ctrl+\\ chat Ā· ctrl+t its tab"; /// Ctrl+G, which opens the agent in the pane in the main view, as its /// title's button does: no Sonny key takes it. @@ -109,6 +116,13 @@ pub fn to_main(key: &ratatui::crossterm::event::KeyEvent) -> bool { key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('g') } +/// Ctrl+T, which goes to the agent's own tab in the multiplexer, as a +/// click on its name does. +pub fn to_tab(key: &ratatui::crossterm::event::KeyEvent) -> bool { + use ratatui::crossterm::event::{KeyCode, KeyModifiers}; + key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('t') +} + impl Pane { /// A file's preview, from a `file://` address as a link holds it, /// with a `#L…` line to show, at `width`. Paths are shown from @@ -174,6 +188,8 @@ impl Pane { over: false, agent: None, main_at: None, + name_at: None, + tab: false, }) } @@ -280,6 +296,8 @@ impl Pane { over: false, agent: None, main_at: None, + name_at: None, + tab: false, } } @@ -307,14 +325,26 @@ impl Pane { over: false, agent: None, main_at: None, + name_at: None, + tab: false, } } /// The program is `agent`'s Sonny: its title's button and Ctrl+G open - /// it in the main view. - pub fn as_agent(&mut self, agent: &str) { + /// it in the main view, and, when it is laid out in a multiplexer + /// (`tab`), its name and Ctrl+T go to its own tab there. + pub fn as_agent(&mut self, agent: &str, tab: bool) { self.agent = Some(agent.to_owned()); - self.keys = AGENT_KEYS; + self.tab = tab; + self.keys = match tab { + true => LAID_OUT_KEYS, + false => AGENT_KEYS, + }; + } + + /// Whether Ctrl+T goes to the agent's tab. + pub fn has_tab(&self) -> bool { + self.tab && self.agent.is_some() } /// Where what the pane holds starts, under its title: the program's @@ -375,6 +405,11 @@ impl Pane { { return Some(format!("{}main/{agent}", crate::screen::BUTTON)); } + if let (Some(agent), Some((from, to))) = (&self.agent, self.name_at) + && (from..to).contains(&column) + { + return Some(format!("{}tab/{agent}", crate::screen::BUTTON)); + } let (_, buttons) = self.head.as_ref()?; (buttons.iter()) .find(|(from, to, _)| (*from..*to).contains(&at)) @@ -435,15 +470,19 @@ impl Pane { if area.height == 0 { return None; } - let keys = match focused { + let mut keys = match focused { true if !self.back.is_empty() => format!(" ← back Ā· {} ", self.keys), true => format!(" {} ", self.keys), false => format!(" {switch} "), }; let mut style = Style::new(); - // A file's name is a link to it. + // A file's name is a link to it. A laid out agent's goes to its + // tab, so it is drawn as any agent's name that opens it is: in the + // agents' color, never underlined. if self.link.is_some() { style = style.add_modifier(Modifier::UNDERLINED); + } else if self.tab { + style = style.fg(ratatui::style::Color::Blue); } if !focused { style = style.add_modifier(Modifier::DIM); @@ -466,6 +505,22 @@ impl Pane { head.push(arrow("›", !self.forward.is_empty())); head.push(Span::raw(" ")); } + // A name keeps its room: the last keys go first, as the footer's + // least urgent do. + if self.head.is_none() { + let button = if self.agent.is_some() { + MAIN.width() + } else { + 0 + }; + let name = + head.iter().map(|s| s.content.width()).sum::() + self.title.width() + button; + while name + keys.width() > area.width as usize + && let Some((kept, _)) = keys.trim_end().rsplit_once(" Ā· ") + { + keys = format!("{kept} "); + } + } // Keys in their one grammar while they go here, else dim. let key_spans = match focused { true => crate::hint::spans(&keys), @@ -503,6 +558,8 @@ impl Pane { let gap = (area.width as usize) .saturating_sub(used + title.width() + button.width() + keys.width()); let at = area.x + (used + title.width() + 1) as u16; + let name = area.x + used as u16; + self.name_at = self.tab.then_some((name, name + title.width() as u16)); head.push(Span::styled(title, style)); self.main_at = None; if !button.is_empty() { @@ -760,7 +817,7 @@ mod tests { #[test] fn an_agents_title_opens_it_in_the_main_view_where_it_fits() { let mut pane = Pane::card("shop.fix", "esc close"); - pane.as_agent("shop.fix"); + pane.as_agent("shop.fix", false); let head = rows(&mut pane, 60, 3).remove(0); assert!(head.starts_with(" shop.fix ↗ "), "{head}"); assert!(head.ends_with("ctrl+g main view Ā· ctrl+\\ chat"), "{head}"); @@ -777,6 +834,42 @@ mod tests { assert_eq!(pane.head_button(column), None); } + #[test] + fn a_laid_out_agents_name_goes_to_its_tab() { + let mut pane = Pane::card("shop.fix", "esc close"); + pane.as_agent("shop.fix", true); + assert!(pane.has_tab()); + let head = rows(&mut pane, 72, 3).remove(0); + assert!(head.starts_with(" shop.fix ↗ "), "{head}"); + assert!( + head.ends_with("ctrl+g main view Ā· ctrl+\\ chat Ā· ctrl+t its tab"), + "{head}" + ); + let name = " shop.fix".width() as u16; + assert_eq!(pane.head_button(1).as_deref(), Some("sonny:tab/shop.fix")); + assert_eq!( + pane.head_button(name - 1).as_deref(), + Some("sonny:tab/shop.fix") + ); + assert_eq!(pane.head_button(name), None); + assert_eq!( + pane.head_button(name + 1).as_deref(), + Some("sonny:main/shop.fix") + ); + // Narrower, the keys go from the last before the name is cut. + let head = rows(&mut pane, 48, 3).remove(0); + assert!(head.starts_with(" shop.fix ↗ "), "{head}"); + assert!(head.ends_with("ctrl+g main view Ā· ctrl+\\ chat"), "{head}"); + let head = rows(&mut pane, 32, 3).remove(0); + assert!(head.starts_with(" shop.fix ↗ "), "{head}"); + assert!(head.ends_with("ctrl+g main view"), "{head}"); + // Not laid out: the name is only a name. + pane.as_agent("shop.fix", false); + rows(&mut pane, 72, 3); + assert!(!pane.has_tab()); + assert_eq!(pane.head_button(1), None); + } + #[test] fn a_cards_foot_stays_on_the_last_row_however_it_is_scrolled() { let mut pane = Pane::card("/background", "esc close"); diff --git a/sonny/src/rex.rs b/sonny/src/rex.rs index 926f59aa7..ee61d4c5e 100644 --- a/sonny/src/rex.rs +++ b/sonny/src/rex.rs @@ -308,6 +308,26 @@ impl Backend for Rex { Ok(block) } + /// Its window made the session's active one; Rex is not brought to + /// the front. + fn focus(&self, block: &Block) -> Result<(), String> { + let out = crate::mux::output( + Command::new(&self.bin) + .env_remove("REX_SESSION") + .env_remove("REX_BLOCK") + .args(&self.host) + .args(["focus", "-s", &self.session, &block.id]), + ) + .map_err(|e| format!("rex: {e}"))?; + match out.status.success() { + true => Ok(()), + false => Err(format!( + "rex focus: {}", + String::from_utf8_lossy(&out.stderr).trim() + )), + } + } + fn own_block(&self) -> Option { let mine = std::env::var("REX_BLOCK").ok().filter(|b| !b.is_empty())?; (self.blocks().ok()?.into_iter()) diff --git a/sonny/src/screen.rs b/sonny/src/screen.rs index ebf751564..f39dbab4c 100644 --- a/sonny/src/screen.rs +++ b/sonny/src/screen.rs @@ -1695,6 +1695,16 @@ impl Screen { selection.dragged = true; } } + // A right click on an agent's name or row: the other way to it + // than a click's, its tab or a look beside. + MouseEventKind::Down(MouseButton::Right) => { + let (.., url) = (self.links.iter()).find(|(y, from, to, _)| { + *y == event.row && (*from..*to).contains(&event.column) + })?; + let bot = (url.strip_prefix(crate::links::AGENT)) + .or(url.strip_prefix(&format!("{BUTTON}task/")))?; + return Some(Action::Open(format!("{BUTTON}other/{bot}"))); + } MouseEventKind::Up(MouseButton::Left) => { let selection = self.selection.as_ref()?; if selection.dragged || selection.unit != Unit::Cell { @@ -2966,6 +2976,17 @@ mod tests { ); assert_eq!(screen.last_agent().as_deref(), Some("calc.neg")); assert!(screen.link_cells(&buf).is_empty(), "never an OSC 8 link"); + // A right click on a name is the other way to it; elsewhere, + // nothing. + let right = |screen: &mut Screen, x, y| { + screen.mouse(mouse(MouseEventKind::Down(MouseButton::Right), x, y)) + }; + assert_eq!( + right(&mut screen, 2, 0), + Some(Action::Open("sonny:other/calc.neg".into())) + ); + assert_eq!(right(&mut screen, 3, 1), None); + assert_eq!(right(&mut screen, 8, 0), None); } #[test] diff --git a/sonny/src/settings.rs b/sonny/src/settings.rs index 6ae6cd946..47a442a1d 100644 --- a/sonny/src/settings.rs +++ b/sonny/src/settings.rs @@ -11,6 +11,8 @@ pub struct Settings { pub verbose: bool, /// When a note opens the margin; `/config` changes it. pub margin_opens: agent_client::config::MarginOpens, + /// Where a click on an agent's name takes the person. + pub agent_opens: agent_client::config::AgentOpens, } impl Default for Settings { @@ -30,6 +32,7 @@ impl Settings { chime: sonny.chime, verbose: sonny.verbose, margin_opens: sonny.margin_opens, + agent_opens: sonny.agent_opens, } }