diff --git a/client/src/config.rs b/client/src/config.rs index 71365494b..0afdd5443 100644 --- a/client/src/config.rs +++ b/client/src/config.rs @@ -16,8 +16,11 @@ //! [sonny] //! lead_effort = "medium" # a new lead's effort //! task_effort = "high" # each new task's -//! lead_profile = "my-lead" # the role a new lead starts in, not lead +//! lead_profile = "my-lead" # the role a new lead starts in, not lead //! task_profile = "worker" # what every other agent is told, not task +//! side_chat = "fork" # "answer", the default, calls no tool +//! chime = false # rings when work ends or waits, the default +//! verbose = true # every tool call drawn, not the clean view //! suggest = false # no guess at the next message, on by default //! unsandboxed = ["mcpx"] # commands run outside agents' sandboxes //! @@ -74,7 +77,7 @@ pub struct Judge { pub effort: Option, } -/// What Sonny starts its agents with. +/// What Sonny starts its agents with, and how it shows them. #[derive(Clone, Debug, PartialEq)] pub struct Sonny { pub lead_effort: Option, @@ -90,6 +93,12 @@ pub struct Sonny { pub unsandboxed: Vec, /// The MCP servers Sonny's agents reach, by name. pub mcp_servers: BTreeMap, + pub side_chat: SideChat, + /// Whether finished and waiting work rings. Notifications show either way. + pub chime: bool, + /// Whether every tool call is drawn, or only the bot's words and the + /// calls that move the work on (the clean view, the default). + pub verbose: bool, /// 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, @@ -106,12 +115,25 @@ impl Default for Sonny { task_profile: None, unsandboxed: Vec::new(), mcp_servers: BTreeMap::new(), + side_chat: SideChat::default(), suggest: true, + chime: true, + verbose: false, sources: Vec::new(), } } } +/// What the copy `/side` talks to may do: `"answer"`, the default, from +/// what its agent knows, calling no tool; or `"fork"`, anything its agent +/// may, with its tools, in its folder, under its approvals. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SideChat { + #[default] + Answer, + Fork, +} + /// A command Sonny's lookout runs, whose every printed line is news for /// the agents subscribed to it by name. #[derive(Clone, Debug, PartialEq)] @@ -335,6 +357,9 @@ fn parse(text: &str) -> Result { "task_effort", "lead_profile", "task_profile", + "side_chat", + "chime", + "verbose", "suggest", "unsandboxed", ], @@ -358,8 +383,13 @@ fn parse(text: &str) -> Result { if !keys.contains(&key.as_str()) { return Err(format!("unknown key {name}.{key}")); } - if key == "suggest" { - config.sonny.suggest = value + if let Some(switch) = match key.as_str() { + "chime" => Some(&mut config.sonny.chime), + "suggest" => Some(&mut config.sonny.suggest), + "verbose" => Some(&mut config.sonny.verbose), + _ => None, + } { + *switch = value .as_bool() .ok_or_else(|| format!("{name}.{key} must be true or false"))?; continue; @@ -377,6 +407,14 @@ fn parse(text: &str) -> Result { continue; } let value = string(&format!("{name}.{key}"), value)?; + if key == "side_chat" { + config.sonny.side_chat = match value.as_str() { + "answer" => SideChat::Answer, + "fork" => SideChat::Fork, + other => return Err(format!("sonny.side_chat is answer or fork, 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, @@ -439,6 +477,15 @@ fn string(key: &str, value: &toml::Value) -> Result { /// new one goes at the end of its table, or of the top level for a bare /// key. The file is written whole, and only if it still reads. pub fn set(path: &Path, pairs: &[(&str, &str)]) -> Result<(), String> { + let pairs: Vec<(&str, Option)> = (pairs.iter()) + .map(|(k, v)| (*k, Some(toml::Value::String((*v).to_owned())))) + .collect(); + write(path, &pairs) +} + +/// As `set`, for any value, where a key with none has its line taken out, +/// so it is its default again; one the file does not name is left so. +pub fn write(path: &Path, pairs: &[(&str, Option)]) -> Result<(), String> { read(path)?; let text = match std::fs::read_to_string(path) { Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), @@ -447,7 +494,6 @@ pub fn set(path: &Path, pairs: &[(&str, &str)]) -> Result<(), String> { let mut lines: Vec = text.lines().map(str::to_owned).collect(); for (key, value) in pairs { let (table, name) = key.split_once('.').unwrap_or(("", key)); - let line = format!("{name} = {}", toml::Value::String((*value).to_owned())); let at = |line: &str| { line.trim_start().strip_prefix('[').map(|h| { h.trim_start_matches('[') @@ -460,10 +506,12 @@ pub fn set(path: &Path, pairs: &[(&str, &str)]) -> Result<(), String> { }; // The lines of `table`: after its header, or the top level's // before the first header, up to the next header. + let header = lines.iter().position(|l| at(l).as_deref() == Some(table)); let begin = match table { "" => 0, - _ => match lines.iter().position(|l| at(l).as_deref() == Some(table)) { + _ => match header { Some(header) => header + 1, + None if value.is_none() => continue, None => { if lines.last().is_some_and(|l| !l.trim().is_empty()) { lines.push(String::new()); @@ -481,6 +529,19 @@ pub fn set(path: &Path, pairs: &[(&str, &str)]) -> Result<(), String> { .split_once('=') .is_some_and(|(k, _)| k.trim().trim_matches('"') == name) }); + let Some(value) = value else { + if let Some(i) = named { + lines.remove(i); + // A table left with nothing in it goes too. + if let Some(header) = header + && lines[begin..end - 1].iter().all(|l| l.trim().is_empty()) + { + lines.drain(header..end - 1); + } + } + continue; + }; + let line = format!("{name} = {value}"); match named { Some(i) => lines[i] = line, None => { @@ -495,8 +556,34 @@ pub fn set(path: &Path, pairs: &[(&str, &str)]) -> Result<(), String> { } } } + while lines.last().is_some_and(|l| l.trim().is_empty()) { + lines.pop(); + } + let before = text; let mut text = lines.join("\n"); text.push('\n'); + // A key written another way, as a dotted key or an inline table, is + // not one the lines above find: it is refused, not left as it was. + let table: toml::Table = text.parse().unwrap_or_default(); + for (key, value) in pairs { + let (name, inner) = key + .split_once('.') + .map_or((*key, None), |(t, k)| (t, Some(k))); + let found = match inner { + Some(inner) => table.get(name).and_then(|t| t.get(inner)), + None => table.get(name), + }; + if found != value.as_ref() { + return Err(format!( + "config_unwritable: {}: {key} is written in a form only an editor can change", + path.display() + )); + } + } + // Nothing changed, as a default taken out of a file without it. + if text == before || (before.is_empty() && lines.is_empty()) { + return Ok(()); + } replace(path, &text) } @@ -591,6 +678,12 @@ mod tests { let off = parse("web_search = \"disabled\"").unwrap(); assert_eq!(off.web_search, WebSearch::Disabled); assert!(!config.hide_senders); + assert_eq!(config.sonny.side_chat, SideChat::Answer); + let fork = parse("[sonny]\nside_chat = \"fork\"").unwrap(); + assert_eq!(fork.sonny.side_chat, SideChat::Fork); + assert!(config.sonny.chime && !config.sonny.verbose); + let quiet = parse("[sonny]\nchime = false\nverbose = true").unwrap(); + assert!(!quiet.sonny.chime && quiet.sonny.verbose); assert!(parse("senders = \"hidden\"").unwrap().hide_senders); assert!(!parse("senders = \"shown\"").unwrap().hide_senders); for (text, error) in [ @@ -600,6 +693,14 @@ mod tests { ), ("web_search = false", "web_search must be a string"), ("senders = \"off\"", "senders is shown or hidden, not off"), + ( + "[sonny]\nchime = \"no\"", + "sonny.chime must be true or false", + ), + ( + "[sonny]\nside_chat = \"all\"", + "sonny.side_chat is answer or fork, not all", + ), ("colour = \"red\"", "unknown key colour"), ("model = 3", "model must be a string"), ("[web]\nsearch = true", "unknown table [web]"), @@ -753,6 +854,47 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + #[test] + fn a_key_written_with_no_value_is_its_default_again() { + let dir = std::env::temp_dir().join(format!("agent-config-unset-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let file = dir.join("config.toml"); + // Nothing to take out leaves no file and no table behind. + write(&file, &[("judge.model", None)]).unwrap(); + assert!(!file.exists()); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + &file, + "# mine\nmodel = \"a/b\"\n\n[judge]\neffort = \"low\"\n\n[sonny]\nlead_effort = \"low\" # cheap\nside_chat = \"fork\"\n", + ) + .unwrap(); + write( + &file, + &[ + ("sonny.lead_effort", None), + ("sonny.task_effort", Some("high".into())), + ("sonny.chime", Some(false.into())), + ("model", None), + ("judge.effort", None), + ], + ) + .unwrap(); + assert_eq!( + std::fs::read_to_string(&file).unwrap(), + "# mine\n\n[sonny]\nside_chat = \"fork\"\ntask_effort = \"high\"\nchime = false\n" + ); + // A key the lines cannot find is refused, the file kept. + for text in ["sonny.chime = false\n", "sonny = { chime = false }\n"] { + std::fs::write(&file, text).unwrap(); + for value in [None, Some(true.into())] { + let error = write(&file, &[("sonny.chime", value)]).unwrap_err(); + assert!(error.starts_with("config_"), "{text}: {error}"); + assert_eq!(std::fs::read_to_string(&file).unwrap(), text); + } + } + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn a_table_is_replaced_whole_and_the_rest_kept() { let dir = std::env::temp_dir().join(format!("agent-config-table-{}", std::process::id())); diff --git a/docs/sonny/config.png b/docs/sonny/config.png new file mode 100644 index 000000000..a58165459 Binary files /dev/null and b/docs/sonny/config.png differ diff --git a/docs/sonny/side-fork.png b/docs/sonny/side-fork.png new file mode 100644 index 000000000..bfe626803 Binary files /dev/null and b/docs/sonny/side-fork.png differ diff --git a/sonny/README.md b/sonny/README.md index 0573c3f63..e79ff6ce9 100644 --- a/sonny/README.md +++ b/sonny/README.md @@ -138,6 +138,8 @@ at its model's own effort (`AGENT_EFFORT` when its model is | Each new task's model | (the lead's) | `threads_model` | | Each new task's effort | `[sonny] task_effort` | `threads_effort` | | The approver's and lookout's judge | `[judge] model`, `effort` | | +| What `/side`'s copy may do | `[sonny] side_chat` (`answer` or `fork`) | | +| The chime, and every tool call drawn | `[sonny] chime`, `verbose` (true or false) | | | A guess at your next message | `[sonny] suggest` (true, the default, or false) | | | News from outside for the lookout | `[sonny.sources.NAME] command`, `every` | | @@ -324,14 +326,29 @@ off again never shows. - `/side [QUESTION]` opens a side chat with a copy of the agent in this window, taken where it is now, mid-turn included: in the pane beside the conversation on the full screen, on the alternate screen inline. - The copy answers from the agent's history and may call no tool; - follow-ups stay with it, and Esc goes back. In the pane, every key goes - to the side chat but Shift+Tab, which goes back to the main composer - while the side chat stays open. The agent carries on meanwhile, in - view on the full screen, waiting on the main screen inline. The daemon - deletes the copy once it has been idle 15 minutes (`fork --expires`), and - no copy shows among the project's agents, in `/status`, or to the - lookout. + The copy answers from the agent's history and may call no tool, unless + `~/.agent/config.toml` sets `[sonny] side_chat = "fork"`: then it is a + full fork that can do anything the agent can, with its tools, in its + folder, under its approvals. Follow-ups stay with it, and Esc goes + back. In the pane, every key goes to the side chat but Shift+Tab, which + goes back to the main composer while the side chat stays open. The agent + carries on meanwhile, in view on the full screen, waiting on the main + screen inline. The daemon deletes the copy once it has been idle 15 + minutes (`fork --expires`), and no copy shows among the project's + agents, in `/status`, or to the lookout. + + ![A side chat forked with every tool writes a file beside the lead](../docs/sonny/side-fork.png) +- `/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`, `suggest`) through them + and its default; Enter types one that takes a name (`model`, `provider`, + `judge.model`, the profiles), empty for its default; ⌫ + takes a key back to its default. Each change is written at once, the + file's comments and order kept, and a file that does not read is + refused by name, as every reader refuses it. + + ![/config over the composer, side_chat set to fork](../docs/sonny/config.png) - `/model` picks the lead's model, from every model its provider family lists, and its effort (←/→), and writes them to the project's `.agents/project.toml` when the folder has one. A pick made while a turn @@ -489,7 +506,10 @@ turn of the task numbered after that one reopens it. ![/status with the lead first, then its tasks under Needs you, Working, Ready for review, Idle and the resolved count](../docs/sonny/status-groups.png) -Settings Sonny keeps for every folder are in `~/.agent/sonny/settings.json`. +Settings Sonny keeps for every folder are in `~/.agent/config.toml`'s +`[sonny]`, which `/config` shows: the chime (`chime`, on by default) and +the view (`verbose`, off by default), besides the efforts and the side chat +above; only the screen is in `~/.agent/sonny/settings.json`. At each start Sonny writes what every agent leans on: the `memory`, `memory-cleanup`, `plan` and `automation` skills, as normal skills in `~/.agents/skills` (replacing links the Agent app left there), and diff --git a/sonny/src/config.rs b/sonny/src/config.rs new file mode 100644 index 000000000..cbf8d626e --- /dev/null +++ b/sonny/src/config.rs @@ -0,0 +1,435 @@ +//! `/config`: the person's `~/.agent/config.toml`, read and changed from +//! Sonny, every key the file takes in one list. A key with a few values +//! steps through them, and one that takes a name is typed; either is +//! written to the file at once, the person's comments and order kept, and +//! a key taken back to its default has its line taken out. +use ratatui::crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; +use ratatui::{ + style::{Color, Stylize}, + text::{Line, Span}, +}; +use std::path::PathBuf; +use unicode_width::UnicodeWidthStr; + +/// One key of the file. +pub struct Setting { + /// As the file names it: `table.key`, or the bare key. + pub key: &'static str, + /// What it sets, and when that is read. + pub about: &'static str, + /// The values it takes, or none for one that takes a name. + pub values: &'static [&'static str], + /// What an unset key is. + pub default: &'static str, +} + +const EFFORTS: &[&str] = &["low", "medium", "high", "xhigh", "max"]; +/// A switch, written as TOML's own true or false. +const SWITCH: &[&str] = &["true", "false"]; + +/// Every key `agent_client::config` reads. +pub const SETTINGS: [Setting; 14] = [ + Setting { + key: "model", + about: "a new agent's model, when none is named (provider/model)", + values: &[], + default: "none", + }, + Setting { + key: "provider", + about: "the daemon's providers, as AGENT_PROVIDER takes them; read when it starts", + values: &[], + default: "AGENT_PROVIDER", + }, + Setting { + key: "web_search", + about: "whether new agents search and fetch the web where their provider offers it", + values: &["live", "disabled"], + default: "live", + }, + Setting { + key: "senders", + about: "whether a new agent's model reads a line naming who sent it each message", + values: &["shown", "hidden"], + default: "shown", + }, + Setting { + key: "judge.model", + about: "who judges for auto approvals and the lookout (provider/model)", + values: &[], + default: "the agent's own model", + }, + Setting { + key: "judge.effort", + about: "the judge's effort", + values: EFFORTS, + default: "the model's own", + }, + Setting { + key: "sonny.lead_effort", + about: "a new lead's effort", + values: EFFORTS, + default: "the model's own", + }, + Setting { + key: "sonny.task_effort", + about: "each new task's effort", + values: EFFORTS, + default: "its lead's", + }, + Setting { + key: "sonny.lead_profile", + about: "the profile a new lead runs as", + values: &[], + default: "lead", + }, + Setting { + key: "sonny.task_profile", + about: "the profile every other agent Sonny starts is told", + values: &[], + default: "task", + }, + Setting { + key: "sonny.side_chat", + about: "/side's copy answers calling no tool, or is a fork that can do anything its agent can", + values: &["answer", "fork"], + default: "answer", + }, + Setting { + key: "sonny.chime", + about: "whether finished and waiting work rings; notifications show either way", + values: SWITCH, + default: "true", + }, + Setting { + key: "sonny.verbose", + about: "every tool call drawn, or the clean view: replies, tasks, questions and the plan", + values: SWITCH, + default: "false", + }, + 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", + values: SWITCH, + default: "true", + }, +]; + +/// What the keys say: open in `/config`. +pub enum Key { + Stay, + Close, +} + +pub struct View { + path: PathBuf, + /// Each setting's value in the file, `None` for its default. + values: Vec>, + pick: usize, + /// The name being typed for the picked setting. + typing: Option, + /// What the last change came to: saved, or why not. + said: Option>, +} + +impl View { + /// The file as it is; an unreadable or invalid one is refused, as + /// every other reader refuses it. + pub fn open() -> Result { + let path = agent_client::config::path().ok_or("HOME is not set")?; + let mut view = View { + path, + values: Vec::new(), + pick: 0, + typing: None, + said: None, + }; + view.values = view.read()?; + Ok(view) + } + + fn read(&self) -> Result>, String> { + agent_client::config::read(&self.path)?; + let text = std::fs::read_to_string(&self.path).unwrap_or_default(); + let table: toml::Table = text.parse().unwrap_or_default(); + Ok(SETTINGS + .iter() + .map(|setting| { + let value = match setting.key.split_once('.') { + Some((name, key)) => table.get(name).and_then(|t| t.get(key)), + None => table.get(setting.key), + }; + value.and_then(|v| match v { + toml::Value::Boolean(on) => Some(on.to_string()), + v => v.as_str().map(str::to_owned), + }) + }) + .collect()) + } + + /// Writes the picked setting's `value`, `None` for its default, and + /// reads the file again. + fn write(&mut self, value: Option) { + let setting = &SETTINGS[self.pick]; + let value = value.map(|value| match setting.values == SWITCH { + true => toml::Value::Boolean(value == "true"), + false => toml::Value::String(value), + }); + let written = agent_client::config::write(&self.path, &[(setting.key, value.clone())]) + .and_then(|()| self.read()); + self.said = Some(match written { + Ok(values) => { + self.values = values; + Ok(match value { + Some(value) => format!("{} = {value}", setting.key), + None => format!("{} is its default again", setting.key), + }) + } + Err(error) => Err(error), + }); + } + + /// The next of the picked setting's values, `by` steps on, through + /// its default and round again. + fn step(&mut self, by: isize) { + let setting = &SETTINGS[self.pick]; + if setting.values.is_empty() { + return; + } + // The default first, then each value that is not the default + // written out. + let values: Vec<&str> = (setting.values.iter()) + .copied() + .filter(|value| *value != setting.default) + .collect(); + let now = match &self.values[self.pick] { + None => 0, + Some(value) => (values.iter()) + .position(|v| v == value) + .map_or(0, |at| at as isize + 1), + }; + let next = (now + by).rem_euclid(values.len() as isize + 1) as usize; + self.write(next.checked_sub(1).map(|at| values[at].to_owned())); + } + + pub fn event(&mut self, event: &Event) -> Key { + if let Event::Paste(text) = event + && let Some(typed) = &mut self.typing + { + typed.push_str(text.lines().next().unwrap_or_default()); + } + let Event::Key(key) = event else { + return Key::Stay; + }; + if key.kind == KeyEventKind::Release { + return Key::Stay; + } + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + return Key::Close; + } + let named = SETTINGS[self.pick].values.is_empty(); + if let Some(typed) = &mut self.typing { + match key.code { + KeyCode::Char(c) => typed.push(c), + KeyCode::Backspace => { + typed.pop(); + } + KeyCode::Esc => self.typing = None, + KeyCode::Enter => { + let typed = self.typing.take().unwrap_or_default(); + let typed = typed.trim(); + self.write((!typed.is_empty()).then(|| typed.to_owned())); + } + _ => {} + } + return Key::Stay; + } + self.said = None; + match key.code { + KeyCode::Esc => return Key::Close, + KeyCode::Up => self.pick = self.pick.saturating_sub(1), + KeyCode::Down => self.pick = (self.pick + 1).min(SETTINGS.len() - 1), + KeyCode::Left => self.step(-1), + KeyCode::Right => self.step(1), + KeyCode::Enter if named => { + self.typing = Some(self.values[self.pick].clone().unwrap_or_default()) + } + KeyCode::Enter => self.step(1), + KeyCode::Backspace | KeyCode::Delete if self.values[self.pick].is_some() => { + self.write(None) + } + // Typing a name starts it afresh. + KeyCode::Char(c) if named => self.typing = Some(c.to_string()), + _ => {} + } + Key::Stay + } + + /// Up to `room` settings around the pick, each with its value. + pub fn rows(&self, room: usize) -> Vec> { + let shown = SETTINGS.len().min(room.max(1)); + let first = (self.pick + 1).saturating_sub(shown); + let wide = SETTINGS.iter().map(|s| s.key.len()).max().unwrap_or(0); + (SETTINGS.iter().zip(&self.values)) + .enumerate() + .skip(first) + .take(shown) + .map(|(i, (setting, value))| { + let key = format!("{: Span::raw(format!(" {value}")), + None => Span::raw(format!(" {} (default)", setting.default)).dim(), + }; + if i == self.pick { + let value = match &self.typing { + Some(typed) => Span::raw(format!(" {typed}")), + None => value, + }; + Line::from(vec![ + Span::raw(" "), + Span::raw(format!(" {key}")).reversed(), + value.reversed(), + Span::raw(" ").reversed(), + ]) + } else { + Line::from(vec![Span::raw(format!(" {key}")), value]) + } + }) + .collect() + } + + /// The composer's line: what the picked setting sets, what the last + /// change came to, or the name being typed; and where the cursor goes + /// when one is. + pub fn line(&self) -> (Line<'static>, Option) { + if let Some(typed) = &self.typing { + let line = Line::from(vec![Span::raw("› ").bold(), Span::raw(typed.clone())]); + return (line, Some(2 + typed.width() as u16)); + } + let what = match &self.said { + Some(Ok(saved)) => Span::raw(format!("saved {saved}")).fg(Color::Green), + Some(Err(error)) => Span::raw(error.clone()).fg(Color::Red), + None => Span::raw(SETTINGS[self.pick].about).dim(), + }; + (Line::from(vec![Span::raw("› ").bold(), what]), None) + } + + /// The keys the picked setting takes. + pub fn keys(&self) -> Line<'static> { + let file = crate::tilde(&self.path.display().to_string()); + let keys = match (&self.typing, SETTINGS[self.pick].values.is_empty()) { + (Some(_), _) => " enter to save, empty for the default · esc".to_owned(), + (None, true) => format!(" ↑↓ setting · enter to type · ⌫ default · esc · {file}"), + (None, false) => format!(" ↑↓ setting · ←→ value · ⌫ default · esc · {file}"), + }; + Line::from(keys).dim() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::crossterm::event::KeyEvent; + + fn press(view: &mut View, code: KeyCode) { + view.event(&Event::Key(KeyEvent::new(code, KeyModifiers::NONE))); + } + + #[test] + fn every_key_the_file_takes_is_listed() { + // A key or value the reader does not take would be refused. + let dir = std::env::temp_dir().join(format!("sonny-config-keys-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut view = View { + path: dir.join("config.toml"), + values: vec![None; SETTINGS.len()], + pick: 0, + typing: None, + said: None, + }; + for (at, setting) in SETTINGS.iter().enumerate() { + view.pick = at; + let value = setting.values.last().copied().unwrap_or("a/b"); + view.write(Some(value.to_owned())); + assert!(matches!(view.said, Some(Ok(_))), "{:?}", view.said); + assert_eq!(view.values[at].as_deref(), Some(value)); + } + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn values_step_through_the_default_and_names_are_typed() { + let dir = std::env::temp_dir().join(format!("sonny-config-view-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("config.toml"); + std::fs::write(&path, "# mine\n[sonny]\nlead_effort = \"low\"\n").unwrap(); + let mut view = View { + path: path.clone(), + values: Vec::new(), + pick: 0, + typing: None, + said: None, + }; + view.values = view.read().unwrap(); + assert_eq!(view.values[6].as_deref(), Some("low")); + // The side chat: a fork, then its default again either way. + view.pick = 10; + press(&mut view, KeyCode::Right); + assert_eq!(view.values[10].as_deref(), Some("fork")); + press(&mut view, KeyCode::Right); + assert_eq!(view.values[10], None); + press(&mut view, KeyCode::Left); + assert_eq!(view.values[10].as_deref(), Some("fork")); + // Efforts step through every level. + view.pick = 5; + press(&mut view, KeyCode::Left); + assert_eq!(view.values[5].as_deref(), Some("max")); + press(&mut view, KeyCode::Enter); + assert_eq!(view.values[5], None); + // A model is typed whole, and an empty one is the default. + view.pick = 0; + for c in "a/b".chars() { + press(&mut view, KeyCode::Char(c)); + } + assert_eq!(view.values[0], None); + press(&mut view, KeyCode::Enter); + assert_eq!(view.values[0].as_deref(), Some("a/b")); + press(&mut view, KeyCode::Enter); + for _ in 0..3 { + press(&mut view, KeyCode::Backspace); + } + press(&mut view, KeyCode::Enter); + assert_eq!(view.values[0], None); + // The lead's effort back to its default. + view.pick = 6; + press(&mut view, KeyCode::Backspace); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "# mine\n[sonny]\nside_chat = \"fork\"\n" + ); + // A switch is written as TOML's own. + view.pick = 11; + press(&mut view, KeyCode::Right); + assert_eq!(view.values[11].as_deref(), Some("false")); + assert!(!agent_client::config::read(&path).unwrap().sonny.chime); + assert!( + std::fs::read_to_string(&path) + .unwrap() + .ends_with("chime = false\n") + ); + press(&mut view, KeyCode::Right); + assert_eq!(view.values[11], None); + // A value the reader refuses is said, and the file kept. + view.pick = 7; + view.typing = None; + std::fs::write(&path, "[sonny]\nbogus = \"x\"\n").unwrap(); + press(&mut view, KeyCode::Right); + assert!(matches!(&view.said, Some(Err(e)) if e.contains("unknown key sonny.bogus"))); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "[sonny]\nbogus = \"x\"\n" + ); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/sonny/src/main.rs b/sonny/src/main.rs index aaf63efb0..1c14bd544 100644 --- a/sonny/src/main.rs +++ b/sonny/src/main.rs @@ -17,6 +17,7 @@ mod banner; mod cache; mod calls; mod cmux; +mod config; mod declared; mod diff; mod embed; @@ -826,11 +827,17 @@ async fn run() -> Result<(), String> { app.turn_began().await; if options.side.is_some() { let source = name.rsplit_once(".side-").map_or(name.as_str(), |(s, _)| s); - app.notice = Some(format!( - "a copy of {source} that answers without acting; {source} carries on" - )); + // One allowed no tool answers; one with its source's acts. + let acts = !bot["allowed"] + .as_array() + .is_some_and(|tools| tools.is_empty()); + app.notice = Some(match acts { + true => format!("a copy of {source} that can do anything it can; {source} carries on"), + false => format!("a copy of {source} that answers without acting; {source} carries on"), + }); } - // The person's agent can react to them; a side chat calls no tool. + // The person's agent can react to them; a side chat has its agent's + // tools, or none. if options.side.is_none() { app.declare = declared::declare(&bot, project.is_some()); } @@ -1099,6 +1106,24 @@ fn unique(prefix: &str) -> String { format!("{prefix}-{}-{}", now.as_millis(), std::process::id()) } +/// The agent a side chat is a copy of, from the name `/side` gives it, +/// `SOURCE.side-HEX`; `None` for any other bot. A name alone can be a +/// task's, so `side_copy` checks the record too. +pub fn side_source(bot: &str) -> Option<&str> { + let (source, stamp) = bot.rsplit_once(".side-")?; + let hex = |b: u8| b.is_ascii_digit() || (b'a'..=b'f').contains(&b); + (!source.is_empty() && (1..=6).contains(&stamp.len()) && stamp.bytes().all(hex)) + .then_some(source) +} + +/// The agent `record`, a bot as `bots` lists it, is a side chat of: named +/// as `/side` names one, made by that agent, and expiring as a side chat +/// does. `None` for any other bot, a task named `NAME.side-feed` included. +pub fn side_copy(record: &Value) -> Option<&str> { + let source = side_source(record["name"].as_str()?)?; + (record["created_by"] == source && record["expire_ms"].is_u64()).then_some(source) +} + /// Program status (OSC 7501), so a terminal or multiplexer that reads it /// (Ghostty, Rex, cmux) can mark this pane working, blocked or done; and /// the same to Herdr, which takes it from its own CLI instead. @@ -1314,9 +1339,9 @@ fn hold(unsent: &mut Vec, record: String) { } /// The slash commands, as the menu lists them: name and what it does. -/// `/agents` and `/ps` also open `/status`, and `/triggers` and `/lookout` -/// open `/background`, unlisted. -const COMMANDS: [(&str, &str); 17] = [ +/// `/agents` and `/ps` also open `/status`, `/triggers` and `/lookout` +/// open `/background`, and `/settings` opens `/config`, unlisted. +const COMMANDS: [(&str, &str); 18] = [ ( "/side", "a side chat with a copy of this agent, which carries on", @@ -1339,6 +1364,7 @@ const COMMANDS: [(&str, &str); 17] = [ ), ("/usage", "the tokens the agents spent, over time"), ("/mcp", "the MCP servers agents' scripts call"), + ("/config", "the settings in ~/.agent/config.toml"), ("/sound", "the chime when work ends, on or off"), ( "/verbose", @@ -1585,6 +1611,8 @@ struct App { retry: Option, /// `/model`, open over the composer. picker: Option, + /// `/config`, open over the composer. + config: Option, /// The model listing `/model` waits on. listing: Option>>, /// The model and effort picked, set once no turn runs. @@ -2220,6 +2248,7 @@ impl App { lead: String::new(), retry: None, picker: None, + config: None, listing: None, switch: None, cache: None, @@ -4001,9 +4030,15 @@ impl App { /// happens, when its window closes too; replayed, by the time the /// lead's turn ended, unless the mark it made then is kept. async fn resolve(&mut self, arguments: &Value, call: &str, turn: Option, now: bool) { - let Some(project) = &self.project else { + // A side chat forked from a lead resolves that lead's tasks. + let side = (self.options.side.is_some()) + .then(|| side_source(&self.bot)) + .flatten() + .and_then(|source| source.strip_suffix(".lead")); + let Some(project) = self.project.as_deref().or(side).map(str::to_owned) else { return; }; + let project = &project; let (Some(task), Some(turn)) = (resolved::call(arguments, project), turn) else { return; }; @@ -4901,6 +4936,7 @@ impl App { if matches!(key.code, KeyCode::Tab | KeyCode::BackTab) && self.input.is_empty() && self.picker.is_none() + && self.config.is_none() && self.choosing.is_none() && self.reacting.is_none() { @@ -4939,6 +4975,7 @@ impl App { KeyCode::Char('f') if ctrl && self.picker.is_none() + && self.config.is_none() && self.choosing.is_none() && self.reacting.is_none() => { @@ -5330,6 +5367,21 @@ impl App { } return; } + if let Some(view) = &mut self.config { + if let Event::Resize(width, _) = event { + self.width = width as usize; + } else if let config::Key::Close = view.event(&event) { + self.config = None; + } + // The chime and the view as the file now has them. + let settings = settings::Settings::load(); + self.sounds.chime = settings.chime; + if self.verbose != settings.verbose { + self.verbose = settings.verbose; + self.repaint = true; + } + return; + } if let Some(picker) = &mut self.picker { match event { Event::Key(key) if key.kind != KeyEventKind::Release => match key.code { @@ -5594,6 +5646,14 @@ impl App { }); return; } + if matches!(prompt.as_str(), "/config" | "/settings") { + self.input.clear(); + match config::View::open() { + Ok(view) => self.config = Some(view), + Err(error) => self.notice = Some(error), + } + return; + } if prompt == "/model" { self.input.clear(); let (send, receive) = tokio::sync::oneshot::channel(); @@ -6117,26 +6177,51 @@ impl App { /// `/side`: a side chat with a copy of this bot, taken where it is now, /// mid-turn included, and opened in a Sonny of its own on the alternate /// screen. The copy may call no tool, so it answers from what the bot - /// knows; the daemon deletes it once it has been idle a quarter hour. - /// This bot carries on, and its lines wait for the main screen. + /// knows, unless the config's `[sonny] side_chat` is `"fork"`: then it + /// may do anything the bot may. The daemon deletes it once it has been + /// idle a quarter hour. This bot carries on, and its lines wait for the + /// main screen. async fn side(&mut self, question: String, pictures: &[attach::Picture]) { if self.options.side.is_some() { self.notice = Some("this is a side chat already".into()); return; } + let tools = match agent_client::config::load() { + Ok(config) => config.sonny.side_chat, + Err(error) => { + self.notice = Some(format!("no side chat: {error}")); + return; + } + }; let stamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis(); let name = format!("{}.side-{:x}", self.bot, stamp % 0x100_0000); - let forked = self + // Made by the agent it copies, which is how Sonny's own tools know + // the copy for one rather than by its name alone. + let page = self .client - .request( - "fork", - json!({"source": self.bot, "bot": name, "allow": [], - "expire_ms": SIDE_EXPIRE.as_millis() as u64, "request_id": name}), - ) - .await; + .request("bots", json!({"name": self.bot, "limit": 1})); + let source = match page.await { + Ok(page) if page["bots"][0]["name"] == self.bot.as_str() => page["bots"][0].clone(), + Ok(_) => { + self.notice = Some(format!("no side chat: {} is gone", self.bot)); + return; + } + Err(error) => { + self.notice = Some(format!("no side chat: {error}")); + return; + } + }; + let mut fork = json!({"source": self.bot, "bot": name, + "created_by": self.bot, "created_by_id": source["bot_id"], + "expire_ms": SIDE_EXPIRE.as_millis() as u64, "request_id": name}); + // A fork with no list keeps its source's tools. + if tools == agent_client::config::SideChat::Answer { + fork["allow"] = json!([]); + } + let forked = self.client.request("fork", fork).await; let forked = match forked { Ok(forked) => forked, Err(error) => { @@ -6499,6 +6584,11 @@ impl App { let room = area.height.saturating_sub(below); let column = area.width.min(PROSE as u16); let rows = self.screen.height(column as usize).min(room as usize); + // `/config` lists every setting over the rows above. + let rows = match &self.config { + Some(_) => (rows + config::SETTINGS.len()).min(room as usize), + None => rows, + }; let [history, view, _] = Layout::vertical([ Constraint::Length(rows as u16), Constraint::Length(below), @@ -6707,6 +6797,24 @@ impl App { frame.render_widget(Paragraph::new(keys), footer); return; } + if let Some(view) = &self.config { + // Every setting where there is room, over the rows above. + let rows = view.rows(composer.y.saturating_sub(frame.area().y) as usize); + let area = ratatui::layout::Rect { + y: composer.y.saturating_sub(rows.len() as u16), + height: rows.len() as u16, + ..composer + }; + frame.render_widget(ratatui::widgets::Clear, area); + frame.render_widget(Paragraph::new(rows), area); + let (line, cursor) = view.line(); + frame.render_widget(Paragraph::new(line), composer); + if let Some(column) = cursor { + frame.set_cursor_position((composer.x + column, composer.y)); + } + frame.render_widget(Paragraph::new(view.keys()), footer); + return; + } if let Some(picker) = &self.picker { let shown = picker.shown(); let at = picker.at(); @@ -6877,6 +6985,7 @@ impl App { let keep = match self.choosing.is_some() || self.approving.is_some() || self.picker.is_some() + || self.config.is_some() || self.reacting.is_some() || self.finding.is_some() { diff --git a/sonny/src/served.rs b/sonny/src/served.rs index 6e51e824a..5c8949f74 100644 --- a/sonny/src/served.rs +++ b/sonny/src/served.rs @@ -711,6 +711,27 @@ async fn answer(client: &Client, call: &Value, me: &Path, store: &Path, socket: .get(0) .filter(|record| record["name"] == bot) .ok_or(("caller_unknown", format!("{bot} is gone")))?; + // A side chat forked with its agent's tools calls them as that agent: + // its tasks, panes and projects are the agent's, not the copy's. + let caller = match crate::side_copy(record) { + Some(source) => { + let page = client + .request("bots", json!({"name": source, "limit": 1})) + .await + .map_err(|e| ("caller_unknown", e.to_string()))?; + page["bots"] + .get(0) + .filter(|found| { + found["name"] == source && found["bot_id"] == record["created_by_id"] + }) + .cloned() + .ok_or(( + "caller_unknown", + format!("{source}, which {bot} copies, is gone"), + ))? + } + None => record.clone(), + }; // As the calling turn runs, which keeps its folder and model when the // bot's own change for a turn after it. let turn = call["turn"].as_i64().unwrap_or(0); @@ -745,8 +766,8 @@ async fn answer(client: &Client, call: &Value, me: &Path, store: &Path, socket: .arg(call["name"].as_str().unwrap_or_default()) .current_dir(workspace) .env("PWD", workspace) - .env("AGENT_BOT", bot) - .env("AGENT_BOT_ID", record["bot_id"].to_string()) + .env("AGENT_BOT", str_of(&caller["name"])) + .env("AGENT_BOT_ID", caller["bot_id"].to_string()) .env("AGENT_TURN", call["turn"].to_string()) .env("AGENT_STORE", store) .env("AGENT_SOCKET", socket) @@ -907,6 +928,22 @@ mod tests { } assert_eq!(project_of("shop.web.lead"), "shop.web"); assert_eq!(project_of("shop.fix"), "shop"); + // A lead's side chat acts as the lead; a task named side-… is no copy. + assert_eq!( + crate::side_source("shop.web.lead.side-1a2b3c"), + Some("shop.web.lead") + ); + assert_eq!(crate::side_source("shop.side-panel"), None); + assert_eq!(crate::side_source("shop.side-"), None); + assert_eq!(crate::side_source("shop.side-1A2B"), None); + let copy = json!({"name": "shop.web.lead.side-1a2b3c", "created_by": "shop.web.lead", + "expire_ms": 900_000}); + assert_eq!(crate::side_copy(©), Some("shop.web.lead")); + // `feed` is hex: a task under that name is still its own caller. + let task = json!({"name": "shop.side-feed", "created_by": "shop.lead"}); + assert_eq!(crate::side_copy(&task), None); + let named = json!({"name": "shop.side-feed", "created_by": "shop", "expire_ms": null}); + assert_eq!(crate::side_copy(&named), None); } #[test] diff --git a/sonny/src/settings.rs b/sonny/src/settings.rs index 3f493d8f1..5d0c9e8f3 100644 --- a/sonny/src/settings.rs +++ b/sonny/src/settings.rs @@ -1,6 +1,7 @@ -//! What the person set in Sonny, kept for every folder it opens: -//! `~/.agent/sonny/settings.json`. A missing or unreadable file is the -//! defaults. +//! What the person set in Sonny, kept for every folder it opens: the chime +//! and the view in `~/.agent/config.toml`'s `[sonny]`, as `/config` shows +//! them, and the screen in `~/.agent/sonny/settings.json`. A missing or +//! unreadable file is the defaults. use serde_json::{Value, json}; use std::path::PathBuf; @@ -19,9 +20,10 @@ pub struct Settings { impl Default for Settings { fn default() -> Self { + let sonny = agent_client::config::Sonny::default(); Settings { - chime: true, - verbose: false, + chime: sonny.chime, + verbose: sonny.verbose, fullscreen: true, } } @@ -34,31 +36,47 @@ fn path() -> Option { impl Settings { pub fn load() -> Self { let text = path().and_then(|path| std::fs::read_to_string(path).ok()); - text.and_then(|text| serde_json::from_str::(&text).ok()) - .map(|saved| Self::from(&saved)) - .unwrap_or_default() + let saved = text.and_then(|text| serde_json::from_str::(&text).ok()); + let sonny = agent_client::config::load().map(|config| config.sonny); + Self::from(&saved.unwrap_or_default(), sonny.unwrap_or_default()) } - fn from(saved: &Value) -> Self { + fn from(saved: &Value, sonny: agent_client::config::Sonny) -> Self { let defaults = Settings::default(); Settings { - chime: saved["chime"].as_bool().unwrap_or(defaults.chime), - verbose: saved["verbose"].as_bool().unwrap_or(defaults.verbose), + chime: sonny.chime, + verbose: sonny.verbose, fullscreen: (saved["fullscreen"].as_bool()).unwrap_or(defaults.fullscreen), } } + /// The chime and the view into the config, where one changed: its + /// default as no line at all. The screen beside. pub fn save(&self) -> Result<(), String> { + let was = Self::load(); + let defaults = Settings::default(); + let mut pairs = Vec::new(); + for (key, now, then, default) in [ + ("sonny.chime", self.chime, was.chime, defaults.chime), + ("sonny.verbose", self.verbose, was.verbose, defaults.verbose), + ] { + if now != then { + pairs.push((key, (now != default).then(|| now.into()))); + } + } + if !pairs.is_empty() { + let config = agent_client::config::path().ok_or("HOME is not set")?; + agent_client::config::write(&config, &pairs)?; + } + if self.fullscreen == was.fullscreen { + return Ok(()); + } let path = path().ok_or("HOME is not set")?; if let Some(dir) = path.parent() { std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; } - let text = serde_json::to_string_pretty(&json!({ - "chime": self.chime, - "verbose": self.verbose, - "fullscreen": self.fullscreen, - })) - .map_err(|e| e.to_string())?; + let text = serde_json::to_string_pretty(&json!({"fullscreen": self.fullscreen})) + .map_err(|e| e.to_string())?; std::fs::write(&path, text + "\n").map_err(|e| format!("{}: {e}", path.display())) } } @@ -69,11 +87,16 @@ mod tests { #[test] fn what_is_not_saved_keeps_its_default() { - assert_eq!(Settings::from(&json!({})), Settings::default()); - assert!(!Settings::from(&json!({"chime": false})).chime); - assert!(Settings::from(&json!({"chime": "no"})).chime); - assert!(Settings::from(&json!({"verbose": true})).verbose); - assert!(!Settings::from(&json!({"verbose": "yes"})).verbose); - assert!(!Settings::from(&json!({"fullscreen": false})).fullscreen); + let sonny = agent_client::config::Sonny::default; + assert_eq!(Settings::from(&json!({}), sonny()), Settings::default()); + assert!(!Settings::from(&json!({"fullscreen": false}), sonny()).fullscreen); + assert!(Settings::from(&json!({"fullscreen": "no"}), sonny()).fullscreen); + let quiet = agent_client::config::Sonny { + chime: false, + verbose: true, + ..sonny() + }; + let settings = Settings::from(&json!({}), quiet); + assert!(!settings.chime && settings.verbose); } }