From 164146bcb0fde8441b185fd252f475f126fc60fc Mon Sep 17 00:00:00 2001 From: Jacob Atchley <10537213+JacobAtchley@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:44:01 -0500 Subject: [PATCH] feat(agent-inbox): add popup queue of agents that need you List blocked agents, then done ones, oldest state change first, with working agents underneath. Each row shows the blocked agent's question or the done agent's last message, falling back to the session title. Quick replies answer from the popup: y/n press Enter/Escape on a blocked dialog, c sends "continue" and r sends a typed reply to a done agent. The list refreshes every second. Agent model gains state_change_seq and the stripped terminal title. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015nTj3MJJCLb6JoXjRYeoxq --- Cargo.lock | 10 + Cargo.toml | 1 + README.md | 41 ++++ crates/herdr-client/src/models.rs | 7 + plugins/agent-inbox/Cargo.toml | 12 ++ plugins/agent-inbox/herdr-plugin.toml | 24 +++ plugins/agent-inbox/src/app.rs | 272 ++++++++++++++++++++++++++ plugins/agent-inbox/src/inbox.rs | 210 ++++++++++++++++++++ plugins/agent-inbox/src/log.rs | 34 ++++ plugins/agent-inbox/src/main.rs | 214 ++++++++++++++++++++ plugins/agent-inbox/src/render.rs | 219 +++++++++++++++++++++ plugins/agent-inbox/src/testing.rs | 51 +++++ 12 files changed, 1095 insertions(+) create mode 100644 plugins/agent-inbox/Cargo.toml create mode 100644 plugins/agent-inbox/herdr-plugin.toml create mode 100644 plugins/agent-inbox/src/app.rs create mode 100644 plugins/agent-inbox/src/inbox.rs create mode 100644 plugins/agent-inbox/src/log.rs create mode 100644 plugins/agent-inbox/src/main.rs create mode 100644 plugins/agent-inbox/src/render.rs create mode 100644 plugins/agent-inbox/src/testing.rs diff --git a/Cargo.lock b/Cargo.lock index 82aa3b9..55631d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,16 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "agent-inbox" +version = "0.1.0" +dependencies = [ + "herdr-client", + "ratatui", + "serde_json", + "tempfile", +] + [[package]] name = "aho-corasick" version = "1.1.5" diff --git a/Cargo.toml b/Cargo.toml index e386e05..e475e6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ resolver = "3" # plugins/ too but are not cargo members. members = [ "crates/herdr-client", + "plugins/agent-inbox", "plugins/command-palette", "plugins/git-glance", "plugins/project-runner", diff --git a/README.md b/README.md index 7c689a8..4ff5533 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,47 @@ description = "run project script" `↑`/`↓` or `ctrl-p`/`ctrl-n` move · `enter` runs in a new tab · `ctrl-v` split right · `ctrl-x` split down · `esc` closes · `ctrl-u` clears. +## Agent inbox (`plugins/agent-inbox`) + +Popup queue of agents that need you. Blocked agents come first, then done +ones; within each group the agent that changed state longest ago is on top. +Working agents sit underneath so you can see they are busy. The list refreshes +every second while the popup is open. + +Each agent shows one line of context: a blocked agent's question, or the +first line of a done agent's last message. When neither can be found in the +pane, the agent's session title shows instead. + +### Keybinding + +```toml +[[keys.command]] +key = "cmd+i" +type = "plugin_action" +command = "jacob.agent-inbox.open" +description = "agent inbox" +``` + +### Keys + +`↑`/`↓`, `j`/`k` or `ctrl-p`/`ctrl-n` move · `enter` jumps to the agent and +closes · `esc`/`q` close. + +| Selected agent | Keys | +|---|---| +| Blocked | `y` presses Enter (the dialog's highlighted "yes") · `n` presses Escape | +| Done | `c` sends "continue" · `r` opens a reply prompt; `enter` sends it, `esc` cancels | + +After a quick reply the agent leaves the list until herdr reports a new state +for it, and the cursor moves to the next one. + +### Notes + +- herdr does not report when a state started, so rows have no wait time; the + order comes from herdr's state change counter. +- Errors show in the footer and go to + `~/.local/state/herdr/plugins/jacob.agent-inbox/inbox.log`. + ## Zen mode (`plugins/zen-mode`) Switcher shortlist for focused work: pick up to **two workspaces** and **three diff --git a/crates/herdr-client/src/models.rs b/crates/herdr-client/src/models.rs index 156894d..7ad2bf1 100644 --- a/crates/herdr-client/src/models.rs +++ b/crates/herdr-client/src/models.rs @@ -65,6 +65,13 @@ pub struct Agent { #[serde(default)] pub focused: bool, pub cwd: Option, + /// Increases each time this agent's status changes; lower means it has + /// been in its current state longer. + #[serde(default)] + pub state_change_seq: Option, + /// The agent's terminal title without the leading status glyph. + #[serde(default, rename = "terminal_title_stripped")] + pub title: Option, } #[derive(Debug, Clone, PartialEq, Deserialize)] diff --git a/plugins/agent-inbox/Cargo.toml b/plugins/agent-inbox/Cargo.toml new file mode 100644 index 0000000..fed5dd7 --- /dev/null +++ b/plugins/agent-inbox/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "agent-inbox" +version = "0.1.0" +edition.workspace = true + +[dependencies] +herdr-client.workspace = true +ratatui.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/plugins/agent-inbox/herdr-plugin.toml b/plugins/agent-inbox/herdr-plugin.toml new file mode 100644 index 0000000..1922204 --- /dev/null +++ b/plugins/agent-inbox/herdr-plugin.toml @@ -0,0 +1,24 @@ +id = "jacob.agent-inbox" +name = "Agent Inbox" +version = "0.1.0" +min_herdr_version = "0.9.0" +description = "Popup queue of agents that are blocked or done, with quick replies" +platforms = ["macos", "linux"] + +[[build]] +command = ["cargo", "build", "--release", "--target-dir", "target"] + +# Keybindings target actions, not panes, so this action opens the popup. +[[actions]] +id = "open" +title = "Open agent inbox" +contexts = ["workspace"] +command = ["sh", "-c", "exec \"$HERDR_BIN_PATH\" plugin pane open --plugin jacob.agent-inbox --entrypoint inbox"] + +[[panes]] +id = "inbox" +title = "Agent Inbox" +placement = "popup" +width = "60%" +height = "60%" +command = ["target/release/agent-inbox", "ui"] diff --git a/plugins/agent-inbox/src/app.rs b/plugins/agent-inbox/src/app.rs new file mode 100644 index 0000000..7ebfa0d --- /dev/null +++ b/plugins/agent-inbox/src/app.rs @@ -0,0 +1,272 @@ +//! Inbox state and key handling, independent of the terminal. + +use std::collections::HashMap; + +use herdr_client::models::AgentStatus; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::inbox::Inbox; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Mode { + List, + Reply { pane_id: String, input: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Command { + Continue, + Quit, + Focus { + pane_id: String, + }, + /// Key presses for a blocked agent's approval dialog. + Keys { + pane_id: String, + keys: Vec<&'static str>, + }, + /// A prompt for an agent that is ready for input. + Prompt { + pane_id: String, + text: String, + }, +} + +pub struct App { + pub inbox: Inbox, + /// Index into the inbox's display order. + pub selected: usize, + pub mode: Mode, + pub status: Option, + /// Agents answered from the inbox, with the state they were answered in. + /// They stay hidden until herdr reports a newer state for them. + answered: HashMap, +} + +impl App { + pub fn new(inbox: Inbox) -> Self { + let mut app = + Self { inbox: Inbox::default(), selected: 0, mode: Mode::List, status: None, answered: HashMap::new() }; + app.set_inbox(inbox); + app + } + + /// Replaces the rows, keeping the cursor on the same agent when it is still listed. + pub fn set_inbox(&mut self, mut inbox: Inbox) { + let current = self.inbox.get(self.selected).map(|entry| entry.pane_id.clone()); + self.answered.retain(|pane_id, seq| inbox.needs.iter().any(|e| e.pane_id == *pane_id && e.seq == *seq)); + inbox.needs.retain(|entry| self.answered.get(&entry.pane_id) != Some(&entry.seq)); + self.inbox = inbox; + self.selected = current + .and_then(|pane_id| self.inbox.position(&pane_id)) + .unwrap_or(self.selected) + .min(self.inbox.len().saturating_sub(1)); + } + + /// Hides an agent after a reply was sent, so the cursor moves on to the next one. + pub fn answered(&mut self, pane_id: &str) { + if let Some(entry) = self.inbox.needs.iter().find(|entry| entry.pane_id == pane_id) { + self.answered.insert(pane_id.to_string(), entry.seq); + } + let inbox = std::mem::take(&mut self.inbox); + let selected = self.selected; + self.set_inbox(inbox); + self.selected = selected.min(self.inbox.len().saturating_sub(1)); + } + + pub fn selected_status(&self) -> Option { + self.inbox.get(self.selected).map(|entry| entry.status) + } + + pub fn handle_key(&mut self, key: KeyEvent) -> Command { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + if ctrl && key.code == KeyCode::Char('c') { + return Command::Quit; + } + match self.mode { + Mode::List => self.list_key(key, ctrl), + Mode::Reply { .. } => self.reply_key(key, ctrl), + } + } + + fn list_key(&mut self, key: KeyEvent, ctrl: bool) -> Command { + let Some(entry) = self.inbox.get(self.selected) else { + return match key.code { + KeyCode::Esc | KeyCode::Char('q') => Command::Quit, + _ => Command::Continue, + }; + }; + let pane_id = entry.pane_id.clone(); + match (key.code, entry.status) { + (KeyCode::Esc | KeyCode::Char('q'), _) => return Command::Quit, + (KeyCode::Enter, _) => return Command::Focus { pane_id }, + (KeyCode::Up | KeyCode::Char('k'), _) => self.move_selection(-1), + (KeyCode::Down | KeyCode::Char('j'), _) => self.move_selection(1), + (KeyCode::Char('p'), _) if ctrl => self.move_selection(-1), + (KeyCode::Char('n'), _) if ctrl => self.move_selection(1), + // Approval dialogs highlight "yes" by default and treat escape as "no". + (KeyCode::Char('y'), AgentStatus::Blocked) => return Command::Keys { pane_id, keys: vec!["enter"] }, + (KeyCode::Char('n'), AgentStatus::Blocked) => return Command::Keys { pane_id, keys: vec!["esc"] }, + (KeyCode::Char('c'), AgentStatus::Done) => return Command::Prompt { pane_id, text: "continue".into() }, + (KeyCode::Char('r'), AgentStatus::Done) => { + self.mode = Mode::Reply { pane_id, input: String::new() }; + self.status = None; + } + _ => {} + } + Command::Continue + } + + fn reply_key(&mut self, key: KeyEvent, ctrl: bool) -> Command { + let Mode::Reply { pane_id, input } = &mut self.mode else { + return Command::Continue; + }; + match key.code { + KeyCode::Esc => self.mode = Mode::List, + KeyCode::Enter if !input.trim().is_empty() => { + let command = Command::Prompt { pane_id: pane_id.clone(), text: input.clone() }; + self.mode = Mode::List; + return command; + } + KeyCode::Char('u') if ctrl => input.clear(), + KeyCode::Backspace => { + input.pop(); + } + KeyCode::Char(c) if !ctrl => input.push(c), + _ => {} + } + Command::Continue + } + + fn move_selection(&mut self, delta: isize) { + let len = self.inbox.len(); + if len > 0 { + self.selected = self.selected.saturating_add_signed(delta).min(len - 1); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::inbox::Entry; + + fn entry(pane: &str, status: AgentStatus, seq: u64) -> Entry { + Entry { + pane_id: pane.into(), + agent: "claude".into(), + status, + location: "api › t1".into(), + seq, + title: None, + preview: None, + } + } + + fn inbox() -> Inbox { + Inbox { + needs: vec![entry("p1", AgentStatus::Blocked, 1), entry("p2", AgentStatus::Done, 2)], + working: vec![entry("p3", AgentStatus::Working, 3)], + } + } + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn type_text(app: &mut App, text: &str) { + for c in text.chars() { + app.handle_key(key(KeyCode::Char(c))); + } + } + + #[test] + fn quick_replies_match_the_selected_agent_state() { + let mut app = App::new(inbox()); + assert_eq!( + app.handle_key(key(KeyCode::Char('y'))), + Command::Keys { pane_id: "p1".into(), keys: vec!["enter"] } + ); + assert_eq!(app.handle_key(key(KeyCode::Char('n'))), Command::Keys { pane_id: "p1".into(), keys: vec!["esc"] }); + assert_eq!(app.handle_key(key(KeyCode::Char('c'))), Command::Continue); + + app.handle_key(key(KeyCode::Down)); + assert_eq!(app.handle_key(key(KeyCode::Char('y'))), Command::Continue); + assert_eq!( + app.handle_key(key(KeyCode::Char('c'))), + Command::Prompt { pane_id: "p2".into(), text: "continue".into() } + ); + + app.handle_key(key(KeyCode::Down)); + assert_eq!(app.handle_key(key(KeyCode::Char('c'))), Command::Continue); + assert_eq!(app.handle_key(key(KeyCode::Enter)), Command::Focus { pane_id: "p3".into() }); + } + + #[test] + fn reply_mode_collects_text_and_sends_it() { + let mut app = App::new(inbox()); + app.handle_key(key(KeyCode::Down)); + app.handle_key(key(KeyCode::Char('r'))); + type_text(&mut app, "yes do it"); + app.handle_key(key(KeyCode::Backspace)); + assert_eq!(app.mode, Mode::Reply { pane_id: "p2".into(), input: "yes do i".into() }); + assert_eq!( + app.handle_key(key(KeyCode::Enter)), + Command::Prompt { pane_id: "p2".into(), text: "yes do i".into() } + ); + assert_eq!(app.mode, Mode::List); + } + + #[test] + fn empty_reply_is_not_sent_and_esc_cancels() { + let mut app = App::new(inbox()); + app.handle_key(key(KeyCode::Down)); + app.handle_key(key(KeyCode::Char('r'))); + assert_eq!(app.handle_key(key(KeyCode::Enter)), Command::Continue); + app.handle_key(key(KeyCode::Esc)); + assert_eq!(app.mode, Mode::List); + assert_eq!(app.handle_key(key(KeyCode::Esc)), Command::Quit); + } + + #[test] + fn answered_agent_hides_until_its_state_changes() { + let mut app = App::new(inbox()); + app.answered("p1"); + assert_eq!(app.inbox.needs.len(), 1); + assert_eq!(app.inbox.get(app.selected).unwrap().pane_id, "p2"); + + app.set_inbox(inbox()); + assert_eq!(app.inbox.needs.len(), 1, "same state stays hidden"); + + let mut changed = inbox(); + changed.needs[0].seq = 7; + app.set_inbox(changed); + assert_eq!(app.inbox.needs.len(), 2, "newer state shows again"); + } + + #[test] + fn refresh_keeps_the_cursor_on_the_same_agent() { + let mut app = App::new(inbox()); + app.handle_key(key(KeyCode::Down)); + let mut reordered = inbox(); + reordered.needs.reverse(); + app.set_inbox(reordered); + assert_eq!(app.inbox.get(app.selected).unwrap().pane_id, "p2"); + assert_eq!(app.selected_status(), Some(AgentStatus::Done)); + } + + #[test] + fn selection_stays_in_bounds_and_empty_inbox_quits() { + let mut app = App::new(inbox()); + app.handle_key(key(KeyCode::Up)); + assert_eq!(app.selected, 0); + for _ in 0..5 { + app.handle_key(key(KeyCode::Char('j'))); + } + assert_eq!(app.selected, 2); + + let mut empty = App::new(Inbox::default()); + assert_eq!(empty.handle_key(key(KeyCode::Enter)), Command::Continue); + assert_eq!(empty.handle_key(key(KeyCode::Char('q'))), Command::Quit); + } +} diff --git a/plugins/agent-inbox/src/inbox.rs b/plugins/agent-inbox/src/inbox.rs new file mode 100644 index 0000000..ddd8613 --- /dev/null +++ b/plugins/agent-inbox/src/inbox.rs @@ -0,0 +1,210 @@ +//! Turns herdr's agent, workspace and tab lists into the inbox rows. + +use herdr_client::models::{Agent, AgentStatus, Tab, Workspace}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + pub pane_id: String, + pub agent: String, + pub status: AgentStatus, + /// `workspace › tab`. + pub location: String, + pub seq: u64, + pub title: Option, + /// One line of the agent's output; filled in by the caller after reading the pane. + pub preview: Option, +} + +/// Agents waiting on the user, and agents still working. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Inbox { + /// Blocked first, then done; within each, the longest waiting first. + pub needs: Vec, + pub working: Vec, +} + +impl Inbox { + pub fn build(agents: &[Agent], workspaces: &[Workspace], tabs: &[Tab]) -> Self { + let mut inbox = Inbox::default(); + for agent in agents { + let entry = Entry { + pane_id: agent.pane_id.clone(), + agent: agent_name(agent), + status: agent.agent_status, + location: location(agent, workspaces, tabs), + seq: agent.state_change_seq.unwrap_or(0), + title: agent.title.clone().filter(|title| !title.trim().is_empty()), + preview: None, + }; + match agent.agent_status { + AgentStatus::Blocked | AgentStatus::Done => inbox.needs.push(entry), + AgentStatus::Working => inbox.working.push(entry), + AgentStatus::Idle | AgentStatus::Unknown => {} + } + } + inbox.needs.sort_by_key(|entry| (entry.status != AgentStatus::Blocked, entry.seq)); + inbox.working.sort_by(|a, b| a.location.cmp(&b.location)); + inbox + } + + pub fn len(&self) -> usize { + self.needs.len() + self.working.len() + } + + /// Rows in display order: needs first, then working. + pub fn get(&self, index: usize) -> Option<&Entry> { + self.needs.get(index).or_else(|| self.working.get(index.checked_sub(self.needs.len())?)) + } + + pub fn position(&self, pane_id: &str) -> Option { + self.needs.iter().chain(&self.working).position(|entry| entry.pane_id == pane_id) + } +} + +fn agent_name(agent: &Agent) -> String { + agent.display_agent.clone().or_else(|| agent.agent.clone()).unwrap_or_else(|| "agent".into()) +} + +fn location(agent: &Agent, workspaces: &[Workspace], tabs: &[Tab]) -> String { + let workspace = workspaces.iter().find(|w| w.workspace_id == agent.workspace_id).map_or_else( + || agent.workspace_id.clone(), + |w| if w.label.is_empty() { format!("Workspace {}", w.number) } else { w.label.clone() }, + ); + let tab = tabs.iter().find(|t| t.tab_id == agent.tab_id).map_or_else( + || agent.tab_id.clone(), + |t| if t.label.is_empty() { format!("Tab {}", t.number) } else { t.label.clone() }, + ); + format!("{workspace} › {tab}") +} + +/// Picks the line worth showing under an agent from its terminal snapshot. +/// +/// Blocked agents show their question (the last line ending in `?`). Done +/// agents show the first line of their last message (the last line starting +/// with a message bullet such as `⏺` or `•`). Returns `None` when neither is +/// found, so the caller can fall back to the terminal title. +pub fn preview(status: AgentStatus, snapshot: &str) -> Option { + let lines: Vec = snapshot.lines().map(clean).filter(|line| !line.is_empty()).collect(); + match status { + AgentStatus::Blocked => lines.iter().rev().find(|line| line.ends_with('?')).cloned(), + AgentStatus::Done => lines.iter().rev().find_map(|line| { + let rest = line.strip_prefix('⏺').or_else(|| line.strip_prefix('•'))?; + let rest = rest.trim(); + (!rest.is_empty()).then(|| rest.to_string()) + }), + _ => None, + } +} + +/// Strips box-drawing borders and prompt markers that agent TUIs draw around text. +fn clean(line: &str) -> String { + const FRAME: &[char] = &['│', '┃', '╭', '╮', '╰', '╯', '─', '━', '❯', '>', ' ', '\t']; + line.trim_matches(FRAME).to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn agent(pane: &str, tab: &str, status: AgentStatus, seq: u64) -> Agent { + Agent { + pane_id: pane.into(), + workspace_id: "w1".into(), + tab_id: tab.into(), + agent: Some("claude".into()), + name: None, + display_agent: None, + agent_status: status, + focused: false, + cwd: None, + state_change_seq: Some(seq), + title: Some("Fix auth".into()), + } + } + + fn workspace() -> Workspace { + Workspace { + workspace_id: "w1".into(), + number: 1, + label: "api".into(), + focused: false, + agent_status: AgentStatus::Unknown, + } + } + + fn tab(id: &str, number: u32, label: &str) -> Tab { + Tab { + tab_id: id.into(), + workspace_id: "w1".into(), + number, + label: label.into(), + focused: false, + agent_status: AgentStatus::Unknown, + } + } + + #[test] + fn blocked_comes_before_done_and_older_changes_first() { + let agents = vec![ + agent("w1:p1", "w1:t1", AgentStatus::Done, 5), + agent("w1:p2", "w1:t1", AgentStatus::Blocked, 9), + agent("w1:p3", "w1:t1", AgentStatus::Blocked, 3), + agent("w1:p4", "w1:t1", AgentStatus::Done, 2), + agent("w1:p5", "w1:t1", AgentStatus::Idle, 1), + agent("w1:p6", "w1:t1", AgentStatus::Working, 1), + ]; + let inbox = Inbox::build(&agents, &[workspace()], &[tab("w1:t1", 1, "")]); + let order: Vec<_> = inbox.needs.iter().map(|e| e.pane_id.as_str()).collect(); + assert_eq!(order, ["w1:p3", "w1:p2", "w1:p4", "w1:p1"]); + assert_eq!(inbox.working.len(), 1); + assert_eq!(inbox.len(), 5); + assert_eq!(inbox.get(4).unwrap().pane_id, "w1:p6"); + assert_eq!(inbox.position("w1:p6"), Some(4)); + } + + #[test] + fn location_uses_labels_and_falls_back_to_numbers_or_ids() { + let agents = vec![agent("w1:p1", "w1:t1", AgentStatus::Done, 1), agent("w1:p2", "w1:t9", AgentStatus::Done, 2)]; + let inbox = Inbox::build(&agents, &[workspace()], &[tab("w1:t1", 2, "")]); + assert_eq!(inbox.needs[0].location, "api › Tab 2"); + assert_eq!(inbox.needs[1].location, "api › w1:t9"); + } + + #[test] + fn blocked_preview_is_the_last_question() { + let snapshot = "\ +⏺ Update(src/auth/token.rs) +╭──────────────────────────────────────────╮ +│ Do you want to make this edit to token.rs? │ +│ ❯ 1. Yes │ +│ 2. No, and tell Claude what to do (esc) │ +╰──────────────────────────────────────────╯ +"; + assert_eq!( + preview(AgentStatus::Blocked, snapshot).as_deref(), + Some("Do you want to make this edit to token.rs?") + ); + } + + #[test] + fn done_preview_is_the_last_message_bullet() { + let snapshot = "\ +⏺ Looking at the tests. + Ran 2 shell commands +⏺ Fixed. Ran just ci, all green. + - details +──────────── +❯ +──────────── +"; + assert_eq!(preview(AgentStatus::Done, snapshot).as_deref(), Some("Fixed. Ran just ci, all green.")); + assert_eq!(preview(AgentStatus::Done, "• Codex finished.\n").as_deref(), Some("Codex finished.")); + } + + #[test] + fn preview_is_none_without_a_match() { + assert_eq!(preview(AgentStatus::Blocked, "no question here\n"), None); + assert_eq!(preview(AgentStatus::Done, "plain output\n"), None); + assert_eq!(preview(AgentStatus::Working, "⏺ busy\n"), None); + } +} diff --git a/plugins/agent-inbox/src/log.rs b/plugins/agent-inbox/src/log.rs new file mode 100644 index 0000000..da1f985 --- /dev/null +++ b/plugins/agent-inbox/src/log.rs @@ -0,0 +1,34 @@ +//! Diagnostics go to a file: the inbox UI owns the terminal while it runs. + +use std::io::Write; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Best effort: logging must never break the plugin. +pub fn append(dir: &Path, message: &str) { + let write = || -> std::io::Result<()> { + std::fs::create_dir_all(dir)?; + let mut file = std::fs::OpenOptions::new().create(true).append(true).open(dir.join("inbox.log"))?; + let now = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()); + writeln!(file, "{now} {message}") + }; + let _ = write(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn appends_timestamped_lines_creating_the_directory() { + let dir = tempfile::tempdir().unwrap(); + let state = dir.path().join("state"); + append(&state, "first"); + append(&state, "second"); + let log = std::fs::read_to_string(state.join("inbox.log")).unwrap(); + let lines: Vec<_> = log.lines().collect(); + assert_eq!(lines.len(), 2); + assert!(lines[0].ends_with(" first")); + assert!(lines[1].ends_with(" second")); + } +} diff --git a/plugins/agent-inbox/src/main.rs b/plugins/agent-inbox/src/main.rs new file mode 100644 index 0000000..5925fc8 --- /dev/null +++ b/plugins/agent-inbox/src/main.rs @@ -0,0 +1,214 @@ +mod app; +mod inbox; +mod log; +mod render; +#[cfg(test)] +mod testing; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::time::{Duration, Instant}; + +use herdr_client::{Api, Client}; +use ratatui::DefaultTerminal; +use ratatui::crossterm::event::{self, Event, KeyEventKind}; +use serde_json::json; + +use app::{App, Command}; +use inbox::Inbox; + +/// How often the open inbox re-reads agent state from herdr. +const REFRESH: Duration = Duration::from_secs(1); + +fn main() -> ExitCode { + match std::env::args().nth(1).as_deref() { + Some("ui") => match run_ui() { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("agent-inbox: {err}"); + ExitCode::FAILURE + } + }, + _ => { + eprintln!("usage: agent-inbox ui"); + ExitCode::from(2) + } + } +} + +fn state_dir() -> PathBuf { + std::env::var_os("HERDR_PLUGIN_STATE_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| std::env::temp_dir().join("herdr-agent-inbox")) +} + +/// Previews keyed by pane and state, so an agent's pane is read once per state change. +type Previews = HashMap<(String, u64), Option>; + +fn load(api: &impl Api, previews: &mut Previews) -> Result { + let agents = api.agent_list().map_err(|err| err.to_string())?; + let workspaces = api.workspace_list().map_err(|err| err.to_string())?; + let tabs = api.tab_list().map_err(|err| err.to_string())?; + let mut inbox = Inbox::build(&agents, &workspaces, &tabs); + previews.retain(|(pane_id, seq), _| inbox.needs.iter().any(|e| e.pane_id == *pane_id && e.seq == *seq)); + for entry in &mut inbox.needs { + entry.preview = previews + .entry((entry.pane_id.clone(), entry.seq)) + .or_insert_with(|| read_snapshot(api, &entry.pane_id).and_then(|text| inbox::preview(entry.status, &text))) + .clone(); + } + Ok(inbox) +} + +/// A failed read only costs the preview line, so it is not reported. +fn read_snapshot(api: &impl Api, pane_id: &str) -> Option { + let result = api.request("agent.read", json!({ "target": pane_id, "source": "detection" })).ok()?; + result["read"]["text"].as_str().map(String::from) +} + +fn run_ui() -> std::io::Result<()> { + let dir = state_dir(); + let mut previews = Previews::new(); + let loaded = Client::from_env().map_err(|err| err.to_string()).and_then(|client| { + let inbox = load(&client, &mut previews)?; + Ok((client, inbox)) + }); + + let mut terminal = ratatui::init(); + let result = match loaded { + Ok((client, inbox)) => event_loop(&mut terminal, &mut App::new(inbox), &client, &mut previews, &dir), + Err(err) => { + log::append(&dir, &err); + message_screen(&mut terminal, &err) + } + }; + ratatui::restore(); + result +} + +fn event_loop( + terminal: &mut DefaultTerminal, + app: &mut App, + api: &impl Api, + previews: &mut Previews, + dir: &Path, +) -> std::io::Result<()> { + let mut last_refresh = Instant::now(); + loop { + if last_refresh.elapsed() >= REFRESH { + last_refresh = Instant::now(); + match load(api, previews) { + Ok(inbox) => app.set_inbox(inbox), + Err(err) => { + log::append(dir, &err); + app.status = Some(err); + } + } + } + terminal.draw(|frame| render::render(frame, app))?; + if !event::poll(REFRESH.saturating_sub(last_refresh.elapsed()))? { + continue; + } + let Event::Key(key) = event::read()? else { continue }; + if key.kind != KeyEventKind::Press { + continue; + } + match app.handle_key(key) { + Command::Continue => {} + Command::Quit => return Ok(()), + Command::Focus { pane_id } => match api.request("agent.focus", json!({ "target": pane_id })) { + Ok(_) => return Ok(()), + Err(err) => fail(app, dir, err.to_string()), + }, + Command::Keys { pane_id, keys } => { + match api.request("agent.send_keys", json!({ "target": pane_id, "keys": keys })) { + Ok(_) => app.answered(&pane_id), + Err(err) => fail(app, dir, err.to_string()), + } + } + Command::Prompt { pane_id, text } => { + match api.request("agent.prompt", json!({ "target": pane_id, "text": text })) { + Ok(_) => app.answered(&pane_id), + Err(err) => fail(app, dir, err.to_string()), + } + } + } + } +} + +fn fail(app: &mut App, dir: &Path, message: String) { + log::append(dir, &message); + app.status = Some(message); +} + +fn message_screen(terminal: &mut DefaultTerminal, message: &str) -> std::io::Result<()> { + use ratatui::text::{Line, Text}; + use ratatui::widgets::{Paragraph, Wrap}; + loop { + terminal.draw(|frame| { + let text = Text::from(vec![Line::from(message), Line::from(""), Line::from("press any key")]); + frame.render_widget(Paragraph::new(text).wrap(Wrap { trim: false }), frame.area()); + })?; + if let Event::Key(key) = event::read()? + && key.kind == KeyEventKind::Press + { + return Ok(()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::FakeApi; + + fn api() -> FakeApi { + FakeApi::new() + .ok( + "agent.list", + json!({ "agents": [ + { "pane_id": "w1:p1", "workspace_id": "w1", "tab_id": "w1:t1", "agent": "claude", + "agent_status": "blocked", "state_change_seq": 4, "terminal_title_stripped": "Fix auth" }, + { "pane_id": "w1:p2", "workspace_id": "w1", "tab_id": "w1:t1", "agent": "claude", + "agent_status": "idle", "state_change_seq": 2 }, + ]}), + ) + .ok("workspace.list", json!({ "workspaces": [{ "workspace_id": "w1", "number": 1, "label": "api" }] })) + .ok( + "tab.list", + json!({ "tabs": [{ "tab_id": "w1:t1", "workspace_id": "w1", "number": 1, "label": "dev" }] }), + ) + .ok("agent.read", json!({ "read": { "text": "│ Run npm install? │\n│ ❯ 1. Yes │\n" } })) + } + + #[test] + fn load_builds_rows_with_previews_read_once_per_state() { + let api = api(); + let mut previews = Previews::new(); + let inbox = load(&api, &mut previews).unwrap(); + assert_eq!(inbox.needs.len(), 1); + assert_eq!(inbox.needs[0].location, "api › dev"); + assert_eq!(inbox.needs[0].title.as_deref(), Some("Fix auth")); + assert_eq!(inbox.needs[0].preview.as_deref(), Some("Run npm install?")); + + load(&api, &mut previews).unwrap(); + let reads = api.calls().iter().filter(|(method, _)| method == "agent.read").count(); + assert_eq!(reads, 1); + let (_, params) = api.calls().into_iter().find(|(method, _)| method == "agent.read").unwrap(); + assert_eq!(params, json!({ "target": "w1:p1", "source": "detection" })); + } + + #[test] + fn failed_read_leaves_the_preview_empty() { + let api = api().err("agent.read", "agent_not_found", "gone"); + let inbox = load(&api, &mut Previews::new()).unwrap(); + assert_eq!(inbox.needs[0].preview, None); + } + + #[test] + fn list_failure_is_an_error() { + let api = api().err("agent.list", "server_error", "boom"); + assert!(load(&api, &mut Previews::new()).unwrap_err().contains("boom")); + } +} diff --git a/plugins/agent-inbox/src/render.rs b/plugins/agent-inbox/src/render.rs new file mode 100644 index 0000000..e73d3dc --- /dev/null +++ b/plugins/agent-inbox/src/render.rs @@ -0,0 +1,219 @@ +//! Draws the inbox popup. + +use herdr_client::models::AgentStatus; +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Style, Stylize}; +use ratatui::text::{Line, Span}; + +use crate::app::{App, Mode}; +use crate::inbox::Entry; + +pub fn render(frame: &mut Frame, app: &App) { + let [header, rule, body, footer] = + Layout::vertical([Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), Constraint::Length(1)]) + .areas(frame.area()); + render_header(frame, app, header); + frame.render_widget(Line::from("─".repeat(usize::from(rule.width))).dim(), rule); + render_body(frame, app, body); + render_footer(frame, app, footer); +} + +fn render_header(frame: &mut Frame, app: &App, area: Rect) { + frame.render_widget(Line::from("Agent Inbox").cyan().bold(), area); + let count = match app.inbox.needs.len() { + 0 => "all quiet".to_string(), + n => format!("{n} need you"), + }; + frame.render_widget(Line::from(count).dim().right_aligned(), area); +} + +/// One rendered row, tagged with the inbox row it belongs to. +struct Row { + line: Line<'static>, + item: Option, +} + +fn render_body(frame: &mut Frame, app: &App, area: Rect) { + if area.height == 0 { + return; + } + let rows = layout_rows(app, usize::from(area.width)); + let height = usize::from(area.height); + let first = rows.iter().position(|row| row.item == Some(app.selected)).unwrap_or(0); + let last = rows.iter().rposition(|row| row.item == Some(app.selected)).unwrap_or(0); + // Scroll just enough to keep every line of the selected agent in view. + let offset = (last + 1).saturating_sub(height).min(first); + for (y, row) in rows.into_iter().skip(offset).take(height).enumerate() { + frame.render_widget(row.line, Rect { y: area.y + y as u16, height: 1, ..area }); + } +} + +fn layout_rows(app: &App, width: usize) -> Vec { + let inbox = &app.inbox; + let agent_width = inbox.needs.iter().chain(&inbox.working).map(|e| e.agent.chars().count()).max().unwrap_or(0); + let mut rows = Vec::new(); + if inbox.needs.is_empty() { + rows.push(Row { line: Line::from(" No agents need you.").dim(), item: None }); + } + for (index, entry) in inbox.needs.iter().enumerate() { + if index > 0 { + rows.push(Row { line: Line::from("┄".repeat(width)).dark_gray(), item: None }); + } + let selected = index == app.selected; + rows.push(Row { line: highlight(entry_line(entry, agent_width, selected), selected), item: Some(index) }); + let preview = entry.preview.as_deref().or(entry.title.as_deref()).unwrap_or(""); + let preview = Line::from(format!(" {}", quote(preview))).dim(); + rows.push(Row { line: highlight(preview, selected), item: Some(index) }); + } + if !inbox.working.is_empty() { + rows.push(Row { line: Line::from(""), item: None }); + let label = format!("── working ({}) ", inbox.working.len()); + let fill = width.saturating_sub(label.chars().count()); + rows.push(Row { line: Line::from(format!("{label}{}", "─".repeat(fill))).dim(), item: None }); + for (offset, entry) in inbox.working.iter().enumerate() { + let index = inbox.needs.len() + offset; + let selected = index == app.selected; + rows.push(Row { line: highlight(entry_line(entry, agent_width, selected), selected), item: Some(index) }); + } + } + rows +} + +fn entry_line(entry: &Entry, agent_width: usize, selected: bool) -> Line<'static> { + let marker = if selected { "▸ " } else { " " }; + Line::from(vec![ + Span::raw(marker), + Span::styled(format!("{:<9}", entry.status.as_str()), status_style(entry.status)), + Span::raw(format!("{: String { + if text.is_empty() { String::new() } else { format!("\"{text}\"") } +} + +fn highlight(line: Line<'static>, selected: bool) -> Line<'static> { + if selected { line.style(Style::new().bg(Color::DarkGray)) } else { line } +} + +fn status_style(status: AgentStatus) -> Style { + Style::new().fg(match status { + AgentStatus::Working => Color::Yellow, + AgentStatus::Blocked => Color::Red, + AgentStatus::Done => Color::Green, + AgentStatus::Idle => Color::Blue, + AgentStatus::Unknown => Color::DarkGray, + }) +} + +fn render_footer(frame: &mut Frame, app: &App, area: Rect) { + if let Mode::Reply { input, .. } = &app.mode { + let line = + Line::from(vec![Span::raw("reply › ").cyan(), Span::raw(format!("{input}▏")), " ⏎ send esc".dim()]); + frame.render_widget(line, area); + return; + } + let hints = match app.selected_status() { + Some(AgentStatus::Blocked) => "⏎ jump y yes n no esc", + Some(AgentStatus::Done) => "⏎ jump c continue r reply esc", + Some(_) => "⏎ jump esc", + None => "esc close", + }; + let left = Line::from(hints).dim(); + let hints_width = u16::try_from(left.width() + 2).unwrap_or(u16::MAX); + let [left_area, right_area] = Layout::horizontal([Constraint::Length(hints_width), Constraint::Min(0)]).areas(area); + frame.render_widget(left, left_area); + if let Some(message) = &app.status { + frame.render_widget(Line::from(message.clone()).yellow().right_aligned(), right_area); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::inbox::Inbox; + use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use ratatui::{Terminal, backend::TestBackend}; + + fn entry(pane: &str, agent: &str, status: AgentStatus, preview: Option<&str>) -> Entry { + Entry { + pane_id: pane.into(), + agent: agent.into(), + status, + location: format!("api › {pane}"), + seq: 0, + title: Some("session title".into()), + preview: preview.map(String::from), + } + } + + fn draw(app: &App, width: u16, height: u16) -> Vec { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal.draw(|frame| render(frame, app)).unwrap(); + let buffer = terminal.backend().buffer(); + (0..buffer.area.height) + .map(|y| { + let line: String = (0..buffer.area.width).map(|x| buffer[(x, y)].symbol()).collect(); + line.trim_end().to_string() + }) + .collect() + } + + fn sample() -> App { + App::new(Inbox { + needs: vec![ + entry("t1", "claude", AgentStatus::Blocked, Some("Allow edit?")), + entry("t2", "codex", AgentStatus::Done, None), + ], + working: vec![entry("t3", "claude", AgentStatus::Working, None)], + }) + } + + #[test] + fn needs_rows_have_previews_and_dotted_separators() { + let lines = draw(&sample(), 50, 14); + assert!(lines[0].starts_with("Agent Inbox") && lines[0].ends_with("2 need you")); + assert!(lines[2].starts_with("▸ blocked claude api › t1")); + assert_eq!(lines[3], " \"Allow edit?\""); + assert!(lines[4].starts_with("┄┄┄")); + assert!(lines[5].starts_with(" done codex api › t2")); + assert_eq!(lines[6], " \"session title\"", "falls back to the title"); + assert_eq!(lines[7], "", "blank row above working"); + assert!(lines[8].starts_with("── working (1) ──")); + assert!(lines[9].starts_with(" working claude api › t3")); + assert!(lines[13].contains("y yes n no")); + } + + #[test] + fn footer_follows_the_selected_state() { + let mut app = sample(); + app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + assert!(draw(&app, 50, 14)[13].contains("c continue r reply")); + app.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + app.handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); + assert!(draw(&app, 50, 14)[13].starts_with("reply › o▏")); + } + + #[test] + fn empty_inbox_says_all_quiet() { + let lines = draw(&App::new(Inbox::default()), 40, 6); + assert!(lines[0].ends_with("all quiet")); + assert_eq!(lines[2], " No agents need you."); + } + + #[test] + fn scrolls_to_keep_the_selected_agent_visible() { + let mut app = App::new(Inbox { + needs: (1..=5).map(|n| entry(&format!("t{n}"), "claude", AgentStatus::Done, Some("ok"))).collect(), + working: Vec::new(), + }); + for _ in 0..4 { + app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + } + let lines = draw(&app, 40, 8); + assert!(lines.iter().any(|line| line.starts_with("▸ done") && line.contains("t5"))); + assert!(lines.iter().any(|line| line.contains("\"ok\""))); + } +} diff --git a/plugins/agent-inbox/src/testing.rs b/plugins/agent-inbox/src/testing.rs new file mode 100644 index 0000000..cae2801 --- /dev/null +++ b/plugins/agent-inbox/src/testing.rs @@ -0,0 +1,51 @@ +//! Test double for the herdr socket API. + +use std::collections::HashMap; +use std::sync::Mutex; + +use herdr_client::{Api, Error}; +use serde_json::Value; + +#[derive(Default)] +pub struct FakeApi { + responses: HashMap>, + calls: Mutex>, +} + +impl FakeApi { + pub fn new() -> Self { + Self::default() + } + + pub fn ok(mut self, method: &str, result: Value) -> Self { + self.responses.insert(method.to_string(), Ok(result)); + self + } + + #[allow(dead_code)] + pub fn err(mut self, method: &str, code: &str, message: &str) -> Self { + self.responses.insert(method.to_string(), Err((code.to_string(), message.to_string()))); + self + } + + pub fn calls(&self) -> Vec<(String, Value)> { + self.calls.lock().unwrap().clone() + } +} + +impl Api for FakeApi { + fn request(&self, method: &str, params: Value) -> Result { + self.calls.lock().unwrap().push((method.to_string(), params)); + match self.responses.get(method) { + Some(Ok(value)) => Ok(value.clone()), + Some(Err((code, message))) => { + Err(Error::Api { method: method.to_string(), code: code.clone(), message: message.clone() }) + } + None => Err(Error::Api { + method: method.to_string(), + code: "unknown_method".into(), + message: "not faked".into(), + }), + } + } +}