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(), + }), + } + } +}