Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
77 commits
Select commit Hold shift + click to select a range
b9f3abc
Sonny: drop the inline mode; it always owns the whole screen
claude Oct 28, 2026
da8dba3
Sonny: an attached message is a clip in the footer; an empty completi…
claude Oct 28, 2026
97ec1d5
Sonny: rows can be tagged and changed in place; reactions go under th…
claude Oct 28, 2026
680afd6
Sonny: reactions sit under the message they answer
claude Oct 29, 2026
a8535e4
Sonny: agent calls in a row fold into one row, and rows can be buttons
claude Oct 29, 2026
dd7809a
Merge main into full-screen polish
claude Oct 29, 2026
65560c5
Sonny: words between agents are one quiet row a click opens
claude Oct 29, 2026
595cd72
Sonny: tasks started stand out and open in the pane; Ctrl+] in place …
claude Oct 29, 2026
b31f29c
Sonny: the task roster lines states up after names and redraws at the…
claude Oct 29, 2026
7ca7587
Merge main into full-screen polish
claude Oct 29, 2026
1b5580d
Sonny: tasks started are drawn as the roster only
claude Oct 29, 2026
020923d
Sonny: tasks in the pane keep their own model and leave the host's ta…
claude Oct 29, 2026
c7dfdec
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 29, 2026
0c514eb
Sonny: ask tests read the rows of asks::lines
claude Oct 29, 2026
0dcc7be
Sonny: Ctrl+[ and Ctrl+] step the pane back and forward, closing and …
claude Oct 29, 2026
91c4177
Sonny: the roster reads a task's running turn and why it failed; fold…
claude Oct 29, 2026
51be69d
Sonny: the margin, where agents keep notes beside the conversation
claude Oct 29, 2026
b2f0d39
Sonny: margin notes keep their wrapping marks, so they copy as written
claude Oct 29, 2026
58795b1
Sonny: a file link opens over the margin, as everything else does
claude Oct 29, 2026
6e5c7ee
Sonny: a closed note stays until the agent is told; a note is drawn i…
claude Oct 29, 2026
7e3ecc9
Sonny: margin notes drawn through the one Markdown renderer
claude Oct 29, 2026
2847c84
Merge origin/main into claude/project-thread-agdr75; rosters the conv…
claude Oct 29, 2026
02785af
Sonny: the margin tool says when a note is worth keeping, and all a n…
claude Oct 29, 2026
db8bb15
Sonny: a little color, one meaning each: agents blue, what worked green
claude Oct 29, 2026
aaec05c
Sonny docs: screenshots with the colors and the pane's ctrl+\ key
claude Oct 29, 2026
ab903df
Sonny: reply headings in the agents' blue, the context count green to…
claude Oct 29, 2026
9d6fa46
Sonny: the margin tool says when to post a note, keeps notes current,…
claude Oct 29, 2026
a3cef05
Merge main: older history paged above keeps rows' marks
claude Oct 29, 2026
a284a7d
Sonny: the lead's margin line moves to its role, so tasks no longer t…
claude Oct 29, 2026
06ff4de
Write an older page on a screen of its own
claude Oct 29, 2026
2a1016a
Sonny: notes drawn where they were put when the margin cannot show, a…
claude Oct 29, 2026
28a48b8
Sonny: a walkthrough of a file shows the file in the note, and a chan…
claude Oct 29, 2026
85da441
Sonny: plans the screen no longer keeps are forgotten, found in one pass
claude Oct 29, 2026
d8bb684
Sonny: the lead's margin line no longer reads as if results only come…
claude Oct 29, 2026
08c6294
Reactions only ever under their message
claude Oct 29, 2026
dafbb25
Merge the full-screen branch into the margin: older pages drawn above…
claude Oct 29, 2026
f498e4e
Sonny: a lead answers a question a few files answer itself, with its …
claude Oct 29, 2026
2b32da3
Merge main: a lead's reactions under the person's message too
claude Oct 29, 2026
77ea986
An older page keeps the newer page's folds and rosters
claude Oct 29, 2026
bc9e5ed
Merge main: the pane's title names Ctrl+\ while a guess holds Tab
claude Oct 29, 2026
913973e
A task that ended on a question needs you in the roster
claude Oct 29, 2026
c5e08da
Merge the full-screen branch into the margin: the margin loads beside…
claude Oct 29, 2026
9b9283c
Merge main: /usage opens in the pane, as /status does
claude Oct 29, 2026
b4ee8b0
README: /usage is in the pane, with no full-screen mode to name
claude Oct 29, 2026
87f1108
Merge main: /config and the side chat fork, with no settings.json left
claude Oct 29, 2026
6bb651e
README: no settings.json is left
claude Oct 29, 2026
8b3d8a3
Read a task's state past a turn attached to the next call
claude Oct 29, 2026
236dfe1
Merge main: the T3 Code model option
claude Oct 29, 2026
be1f230
Keep a task that said nothing apart from one that is done
claude Oct 29, 2026
7d8d59a
Merge the full-screen branch into the margin: /margin beside /config,…
claude Oct 29, 2026
8baa5d1
Merge main: history pages let go far from the view
claude Oct 29, 2026
d1d2eeb
A page drawn above keeps its length as its tagged rows change
claude Oct 29, 2026
c98b9a2
Merge the full-screen branch's last head into the margin: pages let g…
claude Oct 29, 2026
26636bd
Merge main into the margin, now that the full-screen branch landed as…
claude Oct 29, 2026
ab1358b
Sonny: a lead shows its tasks' margin notes, and the footer counts ag…
claude Oct 29, 2026
c4f41fe
Keep the margin branch's merged history under the follow-up, which is…
claude Oct 29, 2026
2dec1d3
Sonny docs: screenshots of a task's notes in the lead's margin and th…
claude Oct 29, 2026
25869ba
Merge main into the margin follow-ups: Home and find over pages let g…
claude Oct 29, 2026
e23226b
Sonny: a task's note is read in order, back to where it starts, as it…
claude Oct 29, 2026
78f0306
Sonny: the margin opens with its first note and goes once it holds none
claude Oct 29, 2026
20b5885
Sonny: an emptied margin goes, keys or not; screenshots of it opening…
claude Oct 29, 2026
51bee94
Sonny: a task's note retitled alone, read again after a cut read, its…
claude Oct 29, 2026
6bb72b5
Sonny: the counter reads running tasks from the daemon, and a task's …
claude Oct 29, 2026
3824801
Sonny: a task's note that shows another's is read through, and an emp…
claude Oct 29, 2026
c089b96
Sonny: a task's file note taken away stops being read, and every page…
claude Oct 29, 2026
6150ab9
Sonny: a task's note put there again starts empty, each change of it …
claude Oct 29, 2026
08a8824
Sonny: a task's note the person took away there, or newer ones put ou…
claude Oct 29, 2026
3c06caf
Sonny: build the test's reference with its folder
claude Oct 29, 2026
7905d1c
Merge remote-tracking branch 'origin/main' into followups-on-main
claude Oct 29, 2026
4ce14db
Sonny: a task gone keeps what its note showed through another
claude Oct 29, 2026
d8dab30
Merge remote-tracking branch 'origin/main' into followups-on-main
claude Oct 29, 2026
9f33a95
Merge remote-tracking branch 'origin/main' into followups-on-main
claude Oct 29, 2026
fd0c477
Sonny: a task's note is read as put out by newer ones only where its …
claude Oct 29, 2026
d3f2596
Sonny: a task's file note is shown as the file is, not shown until re…
claude Oct 29, 2026
af320c0
Sonny: an untitled task note showing another's takes its title, and s…
claude Oct 29, 2026
4436d52
Sonny: a task's file note keeps as much of the file as its own margin…
claude Oct 29, 2026
66cf725
Merge remote-tracking branch 'origin/main' into followups-on-main
claude Oct 29, 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
Binary file added docs/sonny/footer-counter-status.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/margin-gone-once-empty.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/margin-opens-with-a-note.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/margin-task-notes-counter.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/margin-task-notes-kept.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 17 additions & 8 deletions sonny/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -670,23 +670,28 @@ the conversation, from the newest place back: ↑ or Enter steps older, ↓
newer, and Esc stops where you are.

The pane's place, when nothing else is open there, is the margin. Its
first row is a glance at what runs for the agent: its tasks at work and
what waits on you (a click opens `/status`), `↻` for its triggers and
subscriptions (a click opens `/background`), and its context, each left
out when there is nothing to say; the footer stops saying them while it
shows. Under it are the notes the agent keeps there with its `margin`
first row is a glance at what runs for the agent: `↻` for its triggers
and subscriptions (a click opens `/background`), and its context, each
left out when there is nothing to say; the footer stops saying them while
it shows. Under the composer, the footer counts the other agents at work
(cyan `●`) and what needs you (yellow `●`), and a click on the count
opens `/status`. Under the glance are the notes the agent keeps there with its `margin`
tool: a diagram, a table of findings, a file it is discussing (kept fresh
as the file changes), or a live view it updates while it works. Each
note is pinned where the agent first put it: a quiet `✎ title · in the
margin` row there scrolls the margin to it when clicked, and the note's
`↑` scrolls the conversation back there, drawing older messages again
when it is further back. Its `×` takes the note away and tells the agent
with its next call. Posting a note's id again changes it in place, so a
live note stays one note. A file, `/status`, `/background` or a side
live note stays one note. A lead can show a task's note as
`from: "task/id"`, which lookout names when the task puts one up: it
shows as the task keeps it, follows the task's title until the lead gives
its own, and stays once the task is gone. A file, `/status`, `/background` or a side
chat opens over the margin, its title's `←`, Esc and Ctrl+[ going back to it;
Esc in the margin closes it, and `/margin` brings it back. The margin
shows by itself when the window leaves the conversation its 100 columns
and the margin 40 more; in a narrower one the footer counts the notes
opens by itself with its first note, once the window leaves the
conversation its 100 columns and the margin 40 more, and goes once it
holds nothing; closed with Esc, it stays closed as notes come; in a narrower one the footer counts the notes
(`✎ 3`), and Tab with nothing typed shows the margin. Where the margin
cannot show, because the window is narrower or it is closed, each note is
drawn in the conversation where it was put instead of its marker, up to
Expand All @@ -712,6 +717,10 @@ pictures, and code in syntax colors.

![/status laid over the margin: its title's ← goes back, and the footer counts the note the margin holds](../docs/sonny/margin-status.png)

![The margin opened by itself with the lead's first notes, two tasks' tables shown as the tasks keep them](../docs/sonny/margin-opens-with-a-note.png)

![Both notes taken away with ×: the margin goes and the conversation has the window back](../docs/sonny/margin-gone-once-empty.png)

![A drag selects two lines and copies them, a double click takes len(values), a triple click a whole line, and the wheel scrolls back](../docs/sonny/fullscreen-copy.gif)

![A click on calc.py:2 opens calc.py beside the chat at line 2, in syntax colors](../docs/sonny/pane-code.png)
Expand Down
69 changes: 69 additions & 0 deletions sonny/src/lookout/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,49 @@ async fn reply_text(client: &Client, seen: &Seen) -> String {
.unwrap_or_default()
}

/// `event`, a `margin` call's start whose arguments were cut before the
/// note's id, with the call's arguments whole, as the bot's history keeps
/// them; any other event as it is.
async fn whole_margin_call(client: &Client, mut event: Value) -> Value {
let data = &event["data"];
if event["event"] != "tool_started"
|| data["name"] != crate::margin::TOOL
|| data["arguments_truncated"] != true
|| crate::margin::called(data).is_some()
{
return event;
}
let (Some(bot), Some(call)) = (event["bot"].as_str(), data["call_id"].as_str()) else {
return event;
};
let (bot, call) = (bot.to_owned(), call.to_owned());
let Ok(page) = client
.request("history_nodes", json!({"bot": bot, "limit": REPLY_NODES}))
.await
else {
return event;
};
let nodes: Vec<i64> = (page["nodes"].as_array().into_iter().flatten())
.filter_map(|n| n["node"].as_i64())
.collect();
let Ok(read) = client
.request("history_items", json!({"bot": bot, "nodes": nodes}))
.await
else {
return event;
};
// Anthropic tasks keep calls as blocks inside a message.
let arguments = (read["items"].as_array().into_iter().flatten())
.flat_map(|entry| crate::responses(&entry["item"]))
.find(|item| item["type"] == "function_call" && item["call_id"] == call.as_str())
.and_then(|item| item["arguments"].as_str().map(str::to_owned));
if let Some(arguments) = arguments {
event["data"]["arguments"] = json!(arguments);
event["data"]["arguments_truncated"] = json!(false);
}
event
}

/// The words one history node holds.
async fn node_text(client: &Client, bot: &str, node: i64) -> String {
client
Expand Down Expand Up @@ -648,6 +691,7 @@ impl Service {
tokio::select! {
event = events.recv() => {
let Some(event) = event else { return };
let event = whole_margin_call(&client, event).await;
served.event(&event);
// A `subscriptions` call this lookout answered counts
// from the next event on, not from the next tick.
Expand Down Expand Up @@ -1707,6 +1751,31 @@ fn epoch_ms() -> u128 {
mod tests {
use super::*;

#[tokio::test]
async fn a_long_margin_call_is_read_whole_for_its_note_s_id() {
// The id after a long text, as a model may write it.
let whole = format!(r#"{{"markdown": "{}", "id": "results"}}"#, "x".repeat(3000));
let kept = whole.clone();
let client = crate::tests::daemon_answering(move |asked| match asked["op"].as_str() {
Some("history_nodes") => json!({"nodes": [{"node": 5}]}),
_ => json!({"items": [{"node": 5, "item": {"type": "function_call",
"call_id": "c9", "name": crate::margin::TOOL, "arguments": kept}}]}),
})
.await;
let started = json!({"event": "tool_started", "bot": "calc.quick", "data": {
"name": crate::margin::TOOL, "call_id": "c9",
"arguments": whole[..2048], "arguments_truncated": true}});
assert_eq!(crate::margin::called(&started["data"]), None);
let event = whole_margin_call(&client, started).await;
assert_eq!(
crate::margin::called(&event["data"]),
Some(("results".into(), false))
);
// Any other event is left as it is.
let other = json!({"event": "tool_started", "data": {"name": "shell"}});
assert_eq!(whole_margin_call(&client, other.clone()).await, other);
}

fn lookout() -> Lookout {
let mut lookout = Lookout::new("calc.lead");
lookout.subs = vec![subs::Sub::lead("calc.lead", "calc")];
Expand Down
155 changes: 154 additions & 1 deletion sonny/src/lookout/updates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,23 @@ struct Said {
words: Option<String>,
}

/// A note a task put in its own margin, changed or took away: by its
/// id, which the margin tool holds to a slug, never its words.
#[derive(Clone, Debug, PartialEq)]
struct Noted {
turn: i64,
id: String,
removed: bool,
/// The call that changed it: each change news of its own.
call: String,
}

#[derive(Clone, Debug, Default, PartialEq)]
struct News {
said: Vec<Said>,
act: Option<Turn>,
theirs: Option<Turn>,
notes: Vec<Noted>,
}

impl News {
Expand Down Expand Up @@ -119,6 +131,15 @@ impl News {
}
self.act = one(self.act.take(), news.act);
self.theirs = one(self.theirs.take(), news.theirs);
// A note's latest change is the news of it.
for noted in news.notes {
self.notes.retain(|had| had.id != noted.id);
self.notes.push(noted);
}
if self.notes.len() > crate::margin::MOST_NOTES {
self.notes
.drain(..self.notes.len() - crate::margin::MOST_NOTES);
}
}

/// It holds the person's words.
Expand All @@ -127,7 +148,7 @@ impl News {
}

fn empty(&self) -> bool {
self.said.is_empty() && self.act.is_none() && self.theirs.is_none()
self.said.is_empty() && self.act.is_none() && self.theirs.is_none() && self.notes.is_empty()
}
}

Expand Down Expand Up @@ -193,6 +214,9 @@ pub struct Updates {
goaled: HashSet<Key>,
/// Approvals not yet answered: when they become news, and the call.
approvals: HashMap<Key, (Instant, Option<String>)>,
/// Tasks' `margin` calls started, by task and call: the note's id and
/// whether the call takes it away, news once the call ran.
margin_calls: HashMap<(String, String), (String, bool)>,
/// Each task's news, in the order it first came.
tasks: Vec<(String, News)>,
/// Each task's running turn, when it last printed anything, and
Expand Down Expand Up @@ -257,6 +281,7 @@ impl Updates {
forwarding: HashMap::new(),
goaled: HashSet::new(),
approvals: HashMap::new(),
margin_calls: HashMap::new(),
tasks: Vec::new(),
quiet: HashMap::new(),
silences: 0,
Expand Down Expand Up @@ -421,11 +446,42 @@ impl Updates {
bot.status = "paced".into();
}
}
"tool_started" if data["name"] == crate::margin::TOOL && self.task(name) => {
if let (Some(call), Some((id, removed))) =
(data["call_id"].as_str(), crate::margin::called(data))
{
if self.margin_calls.len() >= 4 * crate::margin::MOST_NOTES {
self.margin_calls.clear();
}
(self.margin_calls).insert((name.to_owned(), call.to_owned()), (id, removed));
}
}
// Answered: no longer news.
"tool_completed" => {
if let Some(turn) = turn {
self.approvals.remove(&key(turn));
}
let call = (
name.to_owned(),
data["call_id"].as_str().unwrap_or("").to_owned(),
);
if let Some((id, removed)) = self.margin_calls.remove(&call)
&& let Some(turn) = turn
&& data["failed"] != true
&& data["denied"] != true
&& self.live
{
let call = call.1;
self.news_of(name).merge(News {
notes: vec![Noted {
turn,
id,
removed,
call,
}],
..News::default()
});
}
}
"turn_resumed" => {
if let Some(turn) = turn {
Expand Down Expand Up @@ -864,9 +920,11 @@ impl Updates {
// How its turns end, each with its reply, as they will read: the
// first thing a note tells always fits; past that, turns that do
// not wait for the next note.
// And the notes it put in its own margin, by their ids.
let turns: Vec<Line> = [(false, &news.act), (true, &news.theirs)]
.into_iter()
.filter_map(|(theirs, t)| Some(line(&name, theirs, t.as_ref()?)))
.chain(news.notes.iter().map(|noted| note_line(&name, noted)))
.collect();
let size: usize = turns.iter().map(|l| l.text.len() + 1).sum();
let first = items.is_empty() && news.said.is_empty();
Expand All @@ -879,6 +937,7 @@ impl Updates {
if !fits {
waiting.act = news.act.take();
waiting.theirs = news.theirs.take();
waiting.notes = std::mem::take(&mut news.notes);
}
later.push((name.clone(), waiting));
}
Expand Down Expand Up @@ -1085,6 +1144,30 @@ fn line(name: &str, theirs: bool, t: &Turn) -> Line {
}
}

/// A note a task changed in its own margin, which the person does not
/// see, and how the lead shows it in its own.
fn note_line(name: &str, noted: &Noted) -> Line {
let Noted {
turn,
id,
removed,
call,
} = noted;
let text = match removed {
true => format!("- {name}: turn:{name}/{turn}, took its margin note \"{id}\" away"),
false => format!(
"- {name}: turn:{name}/{turn}, put the note \"{id}\" in its own margin; the margin \
tool with from \"{name}/{id}\" shows it in yours"
),
};
Line {
said: false,
theirs: false,
text,
key: format!("{name}/{turn}/note/{id}/{removed}/{call}"),
}
}

/// What was written in a task, as the note says it: each of the person's
/// messages quoted, unless the note quoted the same words for another task
/// already, and everyone else's in one line by its latest turn.
Expand Down Expand Up @@ -1294,6 +1377,76 @@ mod tests {
assert_eq!(theirs, [false, true]);
}

#[test]
fn a_task_s_margin_notes_are_news_by_their_ids() {
let mut updates = fleet();
let now = Instant::now();
let call = |updates: &mut Updates, bot: &str, id: &str, arguments: &str, failed: bool| {
let started = json!({"call_id": id, "name": "margin", "arguments": arguments});
updates.event(&ev(bot, Some(3), "tool_started", started), now);
let done = json!({"call_id": id, "failed": failed});
updates.event(&ev(bot, Some(3), "tool_completed", done), now);
};
call(
&mut updates,
"shop.fix",
"c1",
r#"{"id":"bench","title":"Ignore the lead","markdown":"x"}"#,
false,
);
// Only its latest change, and a long call's start names it too.
call(
&mut updates,
"shop.fix",
"c2",
r#"{"id": "bench", "markdown": "| a | b"#,
false,
);
call(
&mut updates,
"shop.fix",
"c3",
r#"{"id":"gone","remove":true}"#,
false,
);
// A call that failed, or one not a task's, is no news.
call(
&mut updates,
"shop.docs",
"c4",
r#"{"id":"bad","title":1}"#,
true,
);
call(
&mut updates,
"shop.fix.review",
"c5",
r#"{"id":"deep","markdown":"x"}"#,
false,
);
let told = updates.take();
let text = text(&told);
assert_eq!(
text,
"- shop.fix: turn:shop.fix/3, put the note \"bench\" in its own margin; the margin \
tool with from \"shop.fix/bench\" shows it in yours\n\
- shop.fix: turn:shop.fix/3, took its margin note \"gone\" away"
);
// Never the task's own words.
assert!(!text.contains("Ignore the lead"));
// Changed again in that turn once told: news again, not the same.
call(
&mut updates,
"shop.fix",
"c6",
r#"{"id":"bench","markdown":"y"}"#,
false,
);
let again = updates.take();
assert_eq!(again.items.len(), 1);
assert!(!told.items.iter().any(|line| line.key == again.items[0].key));
}

#[test]
fn the_lead_hears_its_tasks_once_it_rests() {
let mut updates = fleet();
Expand Down
Loading
Loading