Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
899074f
Sonny: host calls give up after 10 s; Herdr's small split above or le…
claude Oct 31, 2026
c17e859
FEATURES: host call limit and Herdr split (#489)
claude Oct 31, 2026
e594649
Sonny: the layout runs on its own thread, so no host call holds up th…
claude Oct 31, 2026
f7286bb
Merge the merged #489 branch tip (its content is already on main)
claude Oct 31, 2026
aebcd30
FEATURES: layout worker row (#492)
claude Oct 31, 2026
48dd8ad
Sonny: hosts say what changed, so the layout reads a home again only …
claude Oct 31, 2026
de629ab
Rex news, as checked on 0.1.0 preview.24: ownership moves no block, s…
claude Oct 31, 2026
6ecf658
Merge the merged #492 branch tip (its content is already on main)
claude Oct 31, 2026
b3b7a98
FEATURES: hosts' news row
claude Oct 31, 2026
0cc9447
FEATURES: #493
claude Oct 31, 2026
aa209e5
Sonny: a failed home read is tried again, not kept; a host's news ope…
claude Oct 31, 2026
ac74424
Sonny: a close or rename reads the home again rather than guessing it…
claude Oct 31, 2026
3c533ab
Sonny: out of every multiplexer, an agent's window opens nowhere unseen
claude Oct 31, 2026
add98a7
README: out of every multiplexer, nothing is opened unseen
claude Oct 31, 2026
df1be3c
Merge the merged branch's tip, keeping this branch's tree
claude Oct 31, 2026
7e73981
FEATURES: #500
claude Oct 31, 2026
b997e46
Sonny: out of every multiplexer, layout skills are left as they are
claude Oct 31, 2026
3a8a18a
Merge main into #500
claude Oct 31, 2026
4112028
Sonny: an agent's command or window can float over its window, in Rex
claude Oct 31, 2026
d641ec6
README: float, a split over the agent's window in Rex
claude Oct 31, 2026
b136eae
Merge the merged branch's tip, keeping this branch's tree
claude Oct 31, 2026
1452f77
FEATURES: #523
claude Oct 31, 2026
39907ba
Sonny: an agent's name in the side pane goes to its own tab, as Ctrl+…
claude Oct 31, 2026
e40ace4
Sonny: a laid out agent's name keeps its room, its tab key going first
claude Oct 31, 2026
f996559
Sonny: a click on an agent's name goes to its tab, a right click besi…
claude Oct 31, 2026
e0c03db
Sonny: /config counts agent_opens
claude Oct 31, 2026
1d55e65
README: the pane's title, laid out and not
claude Oct 31, 2026
d313931
FEATURES: a click on an agent's name goes to its tab
claude Oct 31, 2026
2a86234
Merge the merged branch's tip, keeping this branch's tree
claude Oct 31, 2026
716c79b
FEATURES: #533
claude Oct 31, 2026
0a1ef5b
Sonny: a task's window goes to an agent's tab too, its project's layo…
claude Oct 31, 2026
66e011a
Merge main into the tab branch
claude Nov 1, 2026
413b2ae
Sonny: the lead looks for a clicked agent's tab on the layout worker,…
claude Nov 1, 2026
ff499dd
Sonny: the ungated run in the gate test isn't timed on 300 ms another…
claude Nov 1, 2026
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
31 changes: 31 additions & 0 deletions client/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
}
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -658,6 +674,7 @@ fn parse(text: &str) -> Result<Config, String> {
"chime",
"verbose",
"margin_opens",
"agent_opens",
"suggest",
"start_at_boot",
"commons",
Expand Down Expand Up @@ -806,6 +823,16 @@ fn parse(text: &str) -> Result<Config, String> {
};
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,
Expand Down Expand Up @@ -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);
Expand Down
Binary file added docs/sonny/agent-pane-title.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion sonny/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | ? |
Expand Down
21 changes: 14 additions & 7 deletions sonny/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | |

Expand Down Expand Up @@ -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; ⌫
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions sonny/src/cmux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Watch> {
Expand Down
8 changes: 7 additions & 1 deletion sonny/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 5 additions & 2 deletions sonny/src/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
4 changes: 4 additions & 0 deletions sonny/src/herdr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Watch> {
Expand Down
Loading
Loading