Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions client/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
//! task_effort = "high" # each new task's
//! lead_profile = "my-lead" # the role a new lead starts in, not lead
//! task_profile = "worker" # what every other agent is told, not task
//! suggest = false # no guess at the next message, on by default
//!
//! [sonny.mcp_servers.linear] # an MCP server Sonny's agents reach
//! url = "https://mcp.linear.app/mcp"
Expand Down Expand Up @@ -70,7 +71,7 @@ pub struct Judge {
}

/// What Sonny starts its agents with.
#[derive(Clone, Debug, Default, PartialEq)]
#[derive(Clone, Debug, PartialEq)]
pub struct Sonny {
pub lead_effort: Option<String>,
pub task_effort: Option<String>,
Expand All @@ -81,6 +82,22 @@ pub struct Sonny {
pub task_profile: Option<String>,
/// The MCP servers Sonny's agents reach, by name.
pub mcp_servers: BTreeMap<String, McpServer>,
/// 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,
}

impl Default for Sonny {
fn default() -> Self {
Sonny {
lead_effort: None,
task_effort: None,
lead_profile: None,
task_profile: None,
mcp_servers: BTreeMap::new(),
suggest: true,
}
}
}

/// An MCP server, under Codex's key names for `[mcp_servers.NAME]`: a
Expand Down Expand Up @@ -289,7 +306,13 @@ fn parse(text: &str) -> Result<Config, String> {
.ok_or_else(|| format!("unknown key {name}"))?;
let keys: &[&str] = match name.as_str() {
"judge" => &["model", "effort"],
"sonny" => &["lead_effort", "task_effort", "lead_profile", "task_profile"],
"sonny" => &[
"lead_effort",
"task_effort",
"lead_profile",
"task_profile",
"suggest",
],
_ => return Err(format!("unknown table [{name}]")),
};
for (key, value) in section {
Expand All @@ -306,6 +329,12 @@ fn parse(text: &str) -> Result<Config, String> {
if !keys.contains(&key.as_str()) {
return Err(format!("unknown key {name}.{key}"));
}
if key == "suggest" {
config.sonny.suggest = value
.as_bool()
.ok_or_else(|| format!("{name}.{key} must be true or false"))?;
continue;
}
let value = string(&format!("{name}.{key}"), value)?;
// The daemon judges models and levels when the agents are made.
*match (name.as_str(), key.as_str()) {
Expand Down Expand Up @@ -477,6 +506,12 @@ mod tests {
parse("[sonny]\nlead_profile = \"my-lead\"\ntask_profile = \"worker\"").unwrap();
assert_eq!(roles.sonny.lead_profile.as_deref(), Some("my-lead"));
assert_eq!(roles.sonny.task_profile.as_deref(), Some("worker"));
assert!(config.sonny.suggest);
assert!(!parse("[sonny]\nsuggest = false").unwrap().sonny.suggest);
assert_eq!(
parse("[sonny]\nsuggest = \"no\"").unwrap_err(),
"sonny.suggest must be true or false"
);
let off = parse("web_search = \"disabled\"").unwrap();
assert_eq!(off.web_search, WebSearch::Disabled);
assert!(!config.hide_senders);
Expand Down
Binary file added docs/sonny/suggest-shown.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/sonny/suggest-side.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/sonny/suggest-taken.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions sonny/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ 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` | |
| A guess at your next message | `[sonny] suggest` (true, the default, or false) | |

`start_task` applies them in code, so the lead's prompt says nothing about
effort; the lead names a `model` or `effort` only for one the person asks
Expand Down Expand Up @@ -259,6 +260,27 @@ out, Ctrl-W, Ctrl-U or Ctrl-K cut it for Ctrl-Y, and Esc lets go of it.

![A selection in the composer](../docs/sonny/composer-selection.png)

After a turn you started from this window ends well, a copy of the agent
guesses what you will type next, as Claude Code's prompt suggestions and
Codex's composer predictions do, and the empty composer shows it dim for
Tab to take. Tab puts it in the composer to edit or send; it never sends
itself. The copy is forked where the turn ended, with the agent's tools and
model so its prompt cache is warm, but allowed no call; it is asked for 2 to
12 words in your style, or nothing when the next step is not obvious, and is
deleted once it answers. That is one small model call per turn of yours
(a steer counts for the turn it joins): none follows a turn the lead, the
lookout or another window started, one that failed or was stopped, or one
that ended on a question, so watching a task costs nothing.
`[sonny] suggest = false` turns it off. Typing hides the guess, a new turn
drops it, and Tab does what it does otherwise while none shows. Every Sonny
has it, so a side chat or a task opened in the pane does too.

![The guess, dim in the empty composer after a reply](../docs/sonny/suggest-shown.png)

![Tab took it into the composer, unsent](../docs/sonny/suggest-taken.png)

![The same in a side chat in the pane](../docs/sonny/suggest-side.png)

Pictures go with a message: Ctrl-V takes the one on the clipboard, as in
Codex and Claude Code (terminals paste only text), and an image file
dropped on the terminal, or its path pasted, is attached rather than typed.
Expand Down
Loading