diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-20 12:43:07 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-20 12:43:07 +0300 |
| commit | 4af6a4f625b1965b783c9efb857f8ac665adb8eb (patch) | |
| tree | dd2dac9627735fd02b3372a4f569125a702ba57a /crates | |
| parent | 943fc940d9c387916708cb3fdc912323c92ef6dc (diff) | |
rebuild the status model as herdr's state machine, event-driven and tested
the seen logic was inverted: first observations were marked done and real
completions idle, so the menu showed teal for agents that did nothing and
green for agents that finished, and attending changed nothing visible.
- watcher::advance is herdr's apply_pane_state_change + mark_active_tab_seen
as a pure function, unit-tested against herdr's own cases; attendance is
window-level like herdr's active tab
- hook reports are events applied in order, so a turn shorter than a poll is
still a completion
- event-driven: hook reports, dingger focus, and tmux focus hooks wake the
watcher; notifications are delivered off-thread (the macos call blocked
ticks for 2+ seconds) after the pane options are written
- all per-pane state persists in tmux pane options, so restarts and deploys
lose nothing, including the authoritative hook state
- every click goes through dingger focus: the menu bar shells out to it and
the status-line binding runs it; it picks the right client, skips the
window already shown, and activates the terminal app hosting the client
- the ready queue is ordered like the menu (blocked first, then oldest done)
- menu bar and tmux segment render herdr's symbols in herdr's palette
- tests/e2e.sh: 60 assertions against an isolated tmux server with a real
client, a 30s poll interval, and a slow fake notifier
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/dinggerd/src/hooks.rs | 144 | ||||
| -rw-r--r-- | crates/dinggerd/src/main.rs | 144 | ||||
| -rw-r--r-- | crates/dinggerd/src/notify.rs | 31 | ||||
| -rw-r--r-- | crates/dinggerd/src/tmux.rs | 137 | ||||
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 634 |
5 files changed, 682 insertions, 408 deletions
diff --git a/crates/dinggerd/src/hooks.rs b/crates/dinggerd/src/hooks.rs index da1121e..40f0f20 100644 --- a/crates/dinggerd/src/hooks.rs +++ b/crates/dinggerd/src/hooks.rs @@ -6,23 +6,27 @@ //! {"method":"pane.report_agent","params":{"pane_id":"%3","agent":"pi","state":"working"}} //! ``` //! -//! Reports are authoritative for their pane until the next report or until -//! the pane stops running the agent; screen detection is the fallback for -//! panes that never report. +//! Reports are state-change *events*: the watcher applies every one in +//! order, so a turn that starts and finishes between two polls is still a +//! completion, and each report wakes the watcher immediately. +//! +//! `{"method":"poke"}` only wakes the watcher; `dingger focus` and tmux's +//! focus-change hooks send it so attendance updates without waiting for the +//! next poll. The last reported state stays authoritative for its pane +//! until the next report or until the pane stops running the agent; screen +//! detection is the fallback for panes that never report. use std::collections::HashMap; use std::io::{BufRead, BufReader}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::PathBuf; -use std::sync::{Mutex, OnceLock}; -use std::time::Instant; +use std::sync::{Condvar, Mutex, MutexGuard, OnceLock}; +use std::time::Duration; use detect_core::AgentState; use serde::Deserialize; use tracing::{debug, info, warn}; -pub const SOCKET_FILENAME: &str = "agent.sock"; - pub fn socket_path() -> PathBuf { if let Some(path) = std::env::var_os("DINGGER_AGENT_SOCKET") { return PathBuf::from(path); @@ -30,54 +34,88 @@ pub fn socket_path() -> PathBuf { let state = std::env::var_os("DINGGER_STATE_DIR") .map(PathBuf::from) .or_else(|| { - std::env::var_os("HOME").map(|home| { - PathBuf::from(home).join(".local").join("state").join("dingger") - }) + std::env::var_os("HOME") + .map(|home| PathBuf::from(home).join(".local").join("state").join("dingger")) }) .unwrap_or_else(|| PathBuf::from(".dingger")); - state.join(SOCKET_FILENAME) + state.join("agent.sock") +} + +#[derive(Debug, Default)] +struct PaneReports { + /// Last reported state: authoritative until the next report. + last: Option<AgentState>, + /// Reports not yet applied by the watcher, oldest first. + pending: Vec<AgentState>, +} + +fn reports() -> MutexGuard<'static, HashMap<String, PaneReports>> { + static REPORTS: OnceLock<Mutex<HashMap<String, PaneReports>>> = OnceLock::new(); + REPORTS + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn wake_signal() -> &'static (Mutex<bool>, Condvar) { + static WAKE: OnceLock<(Mutex<bool>, Condvar)> = OnceLock::new(); + WAKE.get_or_init(|| (Mutex::new(false), Condvar::new())) +} + +fn wake() { + let (flag, condvar) = wake_signal(); + *flag.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) = true; + condvar.notify_all(); } -#[derive(Debug, Clone)] -struct HookReport { - state: AgentState, - #[allow(dead_code)] // kept for future hook diagnostics - agent: Option<String>, - #[allow(dead_code)] // kept for future staleness policy - seen: Instant, +/// Sleep until woken by a report or poke, or until `timeout` passes. +pub fn wait_for_wake(timeout: Duration) { + let (flag, condvar) = wake_signal(); + let guard = flag.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let (mut guard, _) = condvar + .wait_timeout_while(guard, timeout, |woken| !*woken) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *guard = false; } -fn reports() -> &'static Mutex<HashMap<String, HookReport>> { - static REPORTS: OnceLock<Mutex<HashMap<String, HookReport>>> = OnceLock::new(); - REPORTS.get_or_init(|| Mutex::new(HashMap::new())) +/// Wake a running watcher (used by `dingger focus` and tmux focus hooks). +pub fn poke() -> Result<(), String> { + use std::io::Write; + let mut stream = UnixStream::connect(socket_path()) + .map_err(|err| format!("dingger watch is not running: {err}"))?; + stream + .write_all(b"{\"method\":\"poke\"}\n") + .map_err(|err| format!("poke failed: {err}")) } #[derive(Deserialize)] struct HookMessage { method: String, - params: HookParams, + #[serde(default)] + params: Option<HookParams>, } #[derive(Deserialize)] struct HookParams { pane_id: String, state: String, - #[serde(default)] - agent: Option<String>, } fn handle_stream(stream: UnixStream) { - let reader = BufReader::new(stream); - for line in reader.lines() { + for line in BufReader::new(stream).lines() { let Ok(line) = line else { return }; let Ok(message) = serde_json::from_str::<HookMessage>(&line) else { warn!(%line, "invalid hook message"); continue; }; - if message.method != "pane.report_agent" { + if message.method == "poke" { + wake(); continue; } - let state = match message.params.state.as_str() { + let (true, Some(params)) = (message.method == "pane.report_agent", message.params) else { + continue; + }; + let state = match params.state.as_str() { "working" => AgentState::Working, "blocked" => AgentState::Blocked, "idle" => AgentState::Idle, @@ -86,17 +124,14 @@ fn handle_stream(stream: UnixStream) { continue; } }; - let pane_id = message.params.pane_id; - debug!(pane = %pane_id, ?state, "hook report"); - let mut reports = reports().lock().unwrap_or_else(|poisoned| poisoned.into_inner()); - reports.insert( - pane_id, - HookReport { - state, - agent: message.params.agent, - seen: Instant::now(), - }, - ); + debug!(pane = %params.pane_id, ?state, "hook report"); + { + let mut reports = reports(); + let pane = reports.entry(params.pane_id).or_default(); + pane.last = Some(state); + pane.pending.push(state); + } + wake(); } } @@ -123,20 +158,29 @@ pub fn spawn_listener() -> Result<(), String> { Ok(()) } -/// The authoritative state for a pane, if an extension reported one. -pub fn hook_state(pane_id: &str) -> Option<AgentState> { - let reports = reports().lock().unwrap_or_else(|poisoned| poisoned.into_inner()); - reports.get(pane_id).map(|report| report.state) +/// The pane's hook observations for this tick: every report since the last +/// tick, in order; otherwise the last reported state; `None` if the pane +/// never reported to this process. +pub fn take_observations(pane_id: &str) -> Option<Vec<AgentState>> { + let mut reports = reports(); + let pane = reports.get_mut(pane_id)?; + if pane.pending.is_empty() { + return pane.last.map(|state| vec![state]); + } + Some(std::mem::take(&mut pane.pending)) +} + +/// The last state the pane's extension reported, if any. +pub fn last_state(pane_id: &str) -> Option<AgentState> { + reports().get(pane_id).and_then(|pane| pane.last) } -/// Drop a single pane's report (the agent exited). -pub fn drop_report(pane_id: &str) { - let mut reports = reports().lock().unwrap_or_else(|poisoned| poisoned.into_inner()); - reports.remove(pane_id); +/// Drop a pane's reports (the agent exited). +pub fn drop_reports(pane_id: &str) { + reports().remove(pane_id); } -/// Drop reports for panes that no longer exist or no longer run the agent. +/// Drop reports for panes that no longer exist. pub fn forget(pane_ids_to_keep: &[String]) { - let mut reports = reports().lock().unwrap_or_else(|poisoned| poisoned.into_inner()); - reports.retain(|pane_id, _| pane_ids_to_keep.contains(pane_id)); + reports().retain(|pane_id, _| pane_ids_to_keep.contains(pane_id)); } diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs index 7ef601f..f458e18 100644 --- a/crates/dinggerd/src/main.rs +++ b/crates/dinggerd/src/main.rs @@ -27,6 +27,9 @@ enum Command { /// Disable notification sounds. #[arg(long)] no_sounds: bool, + /// Disable macOS notifications (sounds still play unless --no-sounds). + #[arg(long)] + no_notifications: bool, }, /// Print the current agent state of every tmux pane. Status, @@ -35,12 +38,19 @@ enum Command { #[command(subcommand)] command: ManifestCommand, }, - /// Switch to the agent that has been waiting the longest (FIFO). Bound to - /// a mouse click on the tmux status line by the packaged config. + /// Show an agent pane and mark it attended. Without --pane, jumps to the + /// agent that has waited longest (FIFO), skipping the window the client + /// already shows. Used by the tmux status-line click and the menu bar. Focus { - /// Name of the tmux client that clicked, passed as #{client_name}. + /// tmux client to switch (default: the most recently active one). + #[arg(long)] client: Option<String>, + /// Pane to show (default: the head of the ready queue). + #[arg(long)] + pane: Option<String>, }, + /// Wake the watcher so focus changes show up immediately (tmux hooks). + Poke, /// Install agent integrations (hook extensions that report state). Install { /// Agent to install the integration for. @@ -65,12 +75,13 @@ fn main() { Command::Watch { interval_secs, no_sounds, + no_notifications, } => { if let Err(err) = hooks::spawn_listener() { eprintln!("dingger: {err}"); std::process::exit(1); } - let mut watcher = watcher::Watcher::new(interval_secs, !no_sounds); + let mut watcher = watcher::Watcher::new(interval_secs, !no_sounds, !no_notifications); if let Err(err) = watcher.run() { eprintln!("dingger: {err}"); std::process::exit(1); @@ -90,12 +101,16 @@ fn main() { } } }, - Command::Focus { client } => { - if let Err(err) = focus(client) { + Command::Focus { client, pane } => { + if let Err(err) = focus(client, pane) { eprintln!("dingger: {err}"); std::process::exit(1); } } + Command::Poke => { + // Best effort: a stopped watcher just means nothing to wake. + let _ = hooks::poke(); + } Command::Install { agent } => { if let Err(err) = integrations::install(&agent) { eprintln!("dingger: {err}"); @@ -105,55 +120,92 @@ fn main() { } } -/// Jump to the head of the ready queue (oldest waiting agent) and ack it, so -/// the next focus moves on to the following one. -fn focus(client: Option<String>) -> Result<(), String> { - let queue = tmux::show_global("@dingger_queue") - .unwrap_or_default() - .split(',') - .filter(|entry| !entry.is_empty()) - .map(|entry| entry.split(':').next().unwrap_or("").to_string()) - .collect::<Vec<_>>(); - let Some(pane_id) = queue.first() else { - return Ok(()); // nothing waiting +/// Show a pane in a client and mark it attended (`@dingger_ack`, consumed +/// by the watcher on its next tick). This is the single code path behind +/// every click: the tmux status line and the menu bar rows. +fn focus(client: Option<String>, pane: Option<String>) -> Result<(), String> { + let clients = tmux::clients(); + // The named client if it is attached; otherwise the most recently active + // one (a click on the status line makes the clicking client the most + // recent, so this is right even if the name did not expand). + let chosen = client + .and_then(|name| clients.iter().find(|(n, _)| *n == name).cloned()) + .or_else(|| clients.first().cloned()); + let Some((client, client_pid)) = chosen else { + return Err("no tmux client attached".to_string()); }; - let target = tmux::show_pane_format(pane_id, "#{session_name}:#{window_index}.#{pane_index}") - .unwrap_or_default(); - if target.is_empty() { - return Err("ready pane no longer exists".to_string()); - } - tmux::ack(pane_id)?; - let mut args = vec!["switch-client".to_string()]; - if let Some(client) = client { - args.push("-c".to_string()); - args.push(client); + + let pane_id = match pane { + Some(pane) => pane, + None => { + let current_window = tmux::client_format(&client, "#{window_id}"); + let queue = tmux::show_global("@dingger_queue").unwrap_or_default(); + let next = queue.split(',').filter(|id| !id.is_empty()).find(|id| { + tmux::pane_format(id, "#{window_id}").as_deref() != current_window.as_deref() + }); + match next { + Some(id) => id.to_string(), + None => return Ok(()), // nothing waiting elsewhere + } + } + }; + + tmux::set_pane_option(&pane_id, "@dingger_ack", "1")?; + tmux::show_pane(&client, &pane_id)?; + let _ = hooks::poke(); + activate_terminal_app(client_pid); + Ok(()) +} + +/// Bring the terminal app hosting a tmux client to the front: walk the +/// client's process ancestry up to the first `*.app` bundle (Ghostty, +/// Alacritty, ...) and `open -a` it. +fn activate_terminal_app(client_pid: u32) { + let mut pid = client_pid; + for _ in 0..16 { + let Ok(out) = std::process::Command::new("ps") + .args(["-o", "ppid=,comm=", "-p", &pid.to_string()]) + .output() + else { + return; + }; + let line = String::from_utf8_lossy(&out.stdout).trim().to_string(); + let Some((ppid, comm)) = line.split_once(char::is_whitespace) else { + return; + }; + if let Some(end) = comm.find(".app/") { + let bundle = &comm[..end + 4]; + let _ = std::process::Command::new("open").args(["-a", bundle]).status(); + return; + } + match ppid.trim().parse::<u32>() { + Ok(parent) if parent > 1 => pid = parent, + _ => return, + } } - args.push("-t".to_string()); - args.push(target); - tmux::run_tmux_client( - &args.iter().map(String::as_str).collect::<Vec<_>>(), - ) } +/// Every agent pane in herdr's priority order (blocked, done, working, +/// idle), oldest-waiting first within a status. fn status() -> Result<(), String> { - let panes = tmux::list_panes()?; + let mut panes: Vec<_> = tmux::list_panes()? + .into_iter() + .filter(|pane| pane.agent.is_some()) + .collect(); if panes.is_empty() { - println!("no tmux panes"); + println!("no agent panes"); return Ok(()); } - println!("{:<22} {:<10} {:<12} COMMAND", "TARGET", "AGENT", "STATE"); + panes.sort_by_key(|pane| { + let status = watcher::Status::from_label(&pane.written_state); + let since = pane.written_since.parse::<u64>().unwrap_or(u64::MAX); + (std::cmp::Reverse(status.priority()), since, pane.display_target.clone()) + }); + println!("{:<8} {:<10} TARGET", "STATUS", "AGENT"); for pane in panes { - println!( - "{:<22} {:<10} {:<12} {}", - pane.display_target, - pane.agent.map(detect_core::agent_label).unwrap_or("-"), - if pane.written_state.is_empty() { - "-" - } else { - &pane.written_state - }, - pane.current_command, - ); + let state = if pane.written_state.is_empty() { "-" } else { &pane.written_state }; + let agent = pane.agent.map(detect_core::agent_label).unwrap_or("-"); + println!("{state:<8} {agent:<10} {}", pane.display_target); } Ok(()) } diff --git a/crates/dinggerd/src/notify.rs b/crates/dinggerd/src/notify.rs index 9fcfcde..e619d6d 100644 --- a/crates/dinggerd/src/notify.rs +++ b/crates/dinggerd/src/notify.rs @@ -3,7 +3,7 @@ use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; -use tracing::{debug, warn}; +use tracing::{info, warn}; pub enum NotificationKind { NeedsAttention, @@ -63,14 +63,27 @@ pub fn play_sound(kind: &NotificationKind) { } } +/// Deliver a macOS notification without blocking the caller: the delivery +/// call waits on Notification Center for seconds, and the watcher must keep +/// updating the UI meanwhile. pub fn send_notification(agent_label: &str, kind: &NotificationKind, context: &str) { - debug!(agent = agent_label, kind = kind.title(), context, "notifying"); + info!(agent = agent_label, kind = kind.title(), context, "notifying"); let title = format!("{agent_label} {}", kind.title()); - let notification = mac_notification_sys::Notification::new() - .title(title.as_str()) - .message(context) - .send(); - if let Err(err) = notification { - warn!(%err, "failed to deliver macOS notification"); - } + let context = context.to_string(); + std::thread::spawn(move || { + // Test seam: deliver through a command instead (tests/e2e.sh). + if let Ok(command) = std::env::var("DINGGER_NOTIFIER") { + let _ = std::process::Command::new("sh") + .args(["-c", &command, &title, &context]) + .status(); + return; + } + let notification = mac_notification_sys::Notification::new() + .title(title.as_str()) + .message(context.as_str()) + .send(); + if let Err(err) = notification { + warn!(%err, "failed to deliver macOS notification"); + } + }); } diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs index f6c2f17..326eda9 100644 --- a/crates/dinggerd/src/tmux.rs +++ b/crates/dinggerd/src/tmux.rs @@ -2,7 +2,7 @@ use std::process::Command; -use detect_core::{identify_agent, Agent, AgentState}; +use detect_core::{identify_agent, Agent}; #[derive(Debug, Clone)] pub struct Pane { @@ -10,38 +10,27 @@ pub struct Pane { pub pane_id: String, /// Human-readable target, e.g. `main:work` (`.pane` suffix when split). pub display_target: String, - /// Foreground command name in the pane, from `#{pane_current_command}`. - pub current_command: String, /// Last OSC title the pane's application set, from `#{pane_title}`. pub pane_title: String, pub dead: bool, - /// The pane's window is the active window of its session. + /// The pane's window is the current window of its session. pub window_active: bool, /// The pane's session has at least one attached client. pub session_attached: bool, pub agent: Option<Agent>, - /// The `@agent_state` user option previously written for this pane, - /// read straight from the pane format. + /// Pane options written by dingger (see the watcher module docs). pub written_state: String, - /// The `@agent` user option (agent label) previously written, if any. pub written_agent: String, - /// The `@dingger_ack` user option: "1" when the user already jumped to - /// this pane in its current ready episode. pub written_ack: String, - /// The pane is the active pane of the active window. - pub pane_active: bool, + pub written_since: String, + pub written_hook: String, } impl Pane { - /// Panes the user is actively looking at suppress finished notifications, - /// mirroring herdr's active-tab suppression. - pub fn suppresses_notifications(&self) -> bool { - self.window_active && self.session_attached - } - - /// The pane is fully focused: the user is looking at this exact pane. + /// The user is looking at this pane's window. herdr marks every pane of + /// the active tab seen (`mark_active_tab_seen`); a tmux window is a tab. pub fn attended(&self) -> bool { - self.pane_active && self.suppresses_notifications() + self.window_active && self.session_attached } } @@ -80,42 +69,42 @@ pub fn list_panes() -> Result<Vec<Pane>, String> { "#{@agent_state}\t", "#{@agent}\t", "#{@dingger_ack}\t", - "#{pane_active}\t", + "#{@dingger_since}\t", + "#{@dingger_hook}\t", "#{session_name}:#{window_name}\t", "#{window_panes}\t", "#{pane_index}", ); let out = run_tmux(&["list-panes", "-a", "-F", format])?; let mut panes = Vec::new(); + let mut process_tree = None; for line in out.lines() { let fields: Vec<&str> = line.split('\t').collect(); - if fields.len() < 15 { + if fields.len() < 16 { continue; } - let current_command = fields[1].to_string(); - let start_command = fields[6].to_string(); + let current_command = fields[1]; + let start_command = fields[6]; let pane_pid: Option<u32> = fields[7].trim().parse().ok(); // An agent may be started via a wrapper or still be initializing // behind the shell; `pane_start_command` covers launch-command cases. let mut agent = - identify_agent(¤t_command).or_else(|| identify_agent(&start_command)); + identify_agent(current_command).or_else(|| identify_agent(start_command)); // Some agents rename their process (claude sets its title to the // version string), so `pane_current_command` alone is not enough. - let mut process_tree = None; if agent.is_none() { let tree = process_tree.get_or_insert_with(fetch_process_tree); agent = resolve_agent_in_process_tree(pane_pid, tree); } // Display target: session:window-name, pane index only when split. - let mut display_target = fields[12].to_string(); - if fields[13].parse::<u32>().map(|n| n > 1).unwrap_or(false) { + let mut display_target = fields[13].to_string(); + if fields[14].parse::<u32>().map(|n| n > 1).unwrap_or(false) { display_target.push('.'); - display_target.push_str(fields[14]); + display_target.push_str(fields[15]); } panes.push(Pane { pane_id: fields[0].to_string(), display_target, - current_command, pane_title: fields[2].to_string(), dead: fields[3] == "1", window_active: fields[4] == "1", @@ -124,7 +113,8 @@ pub fn list_panes() -> Result<Vec<Pane>, String> { written_state: fields[8].to_string(), written_agent: fields[9].to_string(), written_ack: fields[10].to_string(), - pane_active: fields[11] == "1", + written_since: fields[11].to_string(), + written_hook: fields[12].to_string(), }); } Ok(panes) @@ -187,35 +177,18 @@ fn fetch_process_tree() -> ProcessTree { (children, comm) } -/// Run a tmux command whose effect is an action (e.g. switch-client), not -/// output to parse. -pub fn run_tmux_client(args: &[&str]) -> Result<(), String> { - run_tmux(args).map(|_| ()) -} - -/// Read a format expansion for one pane. -pub fn show_pane_format(pane_id: &str, format: &str) -> Option<String> { - run_tmux(&["display-message", "-p", "-t", pane_id, format]) - .ok() - .map(|value| value.trim().to_string()) -} - -/// Capture the pane's visible screen as plain text — the exact input shape +/// Capture the pane's visible screen as plain text: the exact input shape /// the detection manifests expect. pub fn capture_pane(pane_id: &str) -> Result<String, String> { run_tmux(&["capture-pane", "-p", "-q", "-t", pane_id]) } -/// Write the pane's display status (herdr's vocabulary: done/idle/...). -pub fn set_agent_state(pane_id: &str, label: &str) -> Result<(), String> { - run_tmux(&["set-option", "-p", "-t", pane_id, "@agent_state", label]).map(|_| ()) +pub fn set_pane_option(pane_id: &str, name: &str, value: &str) -> Result<(), String> { + run_tmux(&["set-option", "-p", "-t", pane_id, name, value]).map(|_| ()) } -/// Label the pane with the agent running in it, for tmux status-line and -/// menu-bar rendering; clears the label when the pane stops running one. -pub fn set_agent(pane_id: &str, agent: Option<Agent>) -> Result<(), String> { - let label = agent.map(detect_core::agent_label).unwrap_or(""); - run_tmux(&["set-option", "-p", "-t", pane_id, "@agent", label]).map(|_| ()) +pub fn unset_pane_option(pane_id: &str, name: &str) -> Result<(), String> { + run_tmux(&["set-option", "-p", "-u", "-t", pane_id, name]).map(|_| ()) } /// Write a server-global option (readable from any client and from formats). @@ -230,12 +203,60 @@ pub fn show_global(name: &str) -> Option<String> { .filter(|value| !value.is_empty()) } -/// Mark a pane as seen by the user after `dingger focus`: taken out of the -/// ready queue until its next state transition. -pub fn ack(pane_id: &str) -> Result<(), String> { - run_tmux(&["set-option", "-p", "-t", pane_id, "@dingger_ack", "1"]).map(|_| ()) +/// Expand a format in the context of a pane. +pub fn pane_format(pane_id: &str, format: &str) -> Option<String> { + run_tmux(&["display-message", "-p", "-t", pane_id, format]) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) } -pub fn clear_ack(pane_id: &str) -> Result<(), String> { - run_tmux(&["set-option", "-p", "-t", pane_id, "-u", "@dingger_ack"]).map(|_| ()) +/// Expand a format in the context of a client (its current pane/window). +pub fn client_format(client: &str, format: &str) -> Option<String> { + run_tmux(&["display-message", "-p", "-c", client, format]) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +/// Attached clients as (name, pid), most recently active first. +pub fn clients() -> Vec<(String, u32)> { + let Ok(out) = run_tmux(&[ + "list-clients", + "-F", + "#{client_activity}\t#{client_name}\t#{client_pid}", + ]) else { + return Vec::new(); + }; + let mut clients: Vec<(u64, String, u32)> = out + .lines() + .filter_map(|line| { + let mut fields = line.split('\t'); + let activity = fields.next()?.parse().ok()?; + let name = fields.next()?.to_string(); + let pid = fields.next()?.parse().ok()?; + Some((activity, name, pid)) + }) + .collect(); + clients.sort_by_key(|client| std::cmp::Reverse(client.0)); + clients.into_iter().map(|(_, name, pid)| (name, pid)).collect() +} + +/// Show a pane in a client: switch the client to the pane's window and make +/// the pane the active one. +pub fn show_pane(client: &str, pane_id: &str) -> Result<(), String> { + run_tmux(&["switch-client", "-c", client, "-t", pane_id])?; + run_tmux(&["select-pane", "-t", pane_id]).map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_ps_tree() { + let (children, comm) = fetch_process_tree(); + assert!(comm.contains_key(&1u32), "launchd should be present"); + assert!(!children.is_empty()); + } } diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs index f1cb90e..2455407 100644 --- a/crates/dinggerd/src/watcher.rs +++ b/crates/dinggerd/src/watcher.rs @@ -1,21 +1,28 @@ //! Poll-and-detect loop: tmux pane capture → detection engine → herdr's //! status model → notifications and pane-option write-back. //! -//! Status model (herdr's, verbatim): five display statuses over two inputs — +//! Status model (herdr's, verbatim): five display statuses over two inputs, //! the raw agent state (working/blocked/idle, from hooks or scraping) and a -//! `seen` flag (attended since the last state change): +//! `seen` flag: //! //! | raw | seen | display | priority | //! |----------|--------|----------|----------| -//! | working | — | working | 2 | -//! | blocked | — | blocked | 4 | +//! | working | any | working | 2 | +//! | blocked | any | blocked | 4 | //! | idle | false | done | 3 | //! | idle | true | idle | 1 | -//! | none | — | unknown | 0 | +//! | none | any | unknown | 0 | //! -//! "Seen" is maintained three ways: the pane is fully focused in an attached -//! client (the daemon watches tmux focus), the user jumped to it via -//! `dingger focus` / menu click, or its next transition. +//! `seen` follows herdr's `apply_pane_state_change` and +//! `mark_active_tab_seen` (src/app/actions.rs); see [`advance`]. +//! +//! All per-pane state lives in tmux pane options, so a daemon restart picks +//! up exactly where it left off: +//! +//! - `@agent_state` display status (what every surface renders) +//! - `@dingger_since` unix ms the pane started needing the user (FIFO) +//! - `@dingger_hook` last state an agent extension reported +//! - `@dingger_ack` one-shot "the user jumped here" event from `dingger focus` use std::collections::HashMap; @@ -26,8 +33,8 @@ use crate::hooks; use crate::notify::{play_sound, send_notification, NotificationKind}; use crate::tmux; -/// herdr's display statuses, in priority order (highest first). -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +/// herdr's display statuses. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Status { Unknown, Idle, @@ -37,8 +44,7 @@ pub enum Status { } impl Status { - #[allow(dead_code)] // herdr parity: the full vocabulary is part of the model - pub fn label(&self) -> &'static str { + pub fn label(self) -> &'static str { match self { Status::Unknown => "unknown", Status::Idle => "idle", @@ -48,21 +54,18 @@ impl Status { } } - #[allow(dead_code)] // herdr parity - pub fn parse(label: &str) -> Option<Status> { + pub fn from_label(label: &str) -> Status { match label { - "working" => Some(Status::Working), - "blocked" => Some(Status::Blocked), - "done" => Some(Status::Done), - "idle" => Some(Status::Idle), - "unknown" => Some(Status::Unknown), - _ => None, + "idle" => Status::Idle, + "working" => Status::Working, + "done" => Status::Done, + "blocked" => Status::Blocked, + _ => Status::Unknown, } } /// herdr's priority: blocked > done > working > idle > unknown. - #[allow(dead_code)] // herdr parity; used once the menu bar consumes statuses from the daemon - pub fn priority(&self) -> u8 { + pub fn priority(self) -> u8 { match self { Status::Blocked => 4, Status::Done => 3, @@ -84,49 +87,126 @@ pub fn notification_kind_for_transition( } match new_state { AgentState::Blocked => Some(NotificationKind::NeedsAttention), - AgentState::Idle - if matches!(prev_state, AgentState::Working | AgentState::Blocked) => - { + AgentState::Idle if matches!(prev_state, AgentState::Working | AgentState::Blocked) => { Some(NotificationKind::Finished) } _ => None, } } +/// herdr's per-pane attendance record. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct PaneTrack { - state: AgentState, - seen: bool, - /// Unix second the pane entered its current done/blocked episode; drives - /// the FIFO ready queue. - ready_since: Option<u64>, +pub struct PaneTrack { + pub state: AgentState, + pub seen: bool, + /// Unix millisecond the pane started needing the user (done or blocked); + /// orders the FIFO queue within a status. + pub since: Option<u64>, +} + +impl PaneTrack { + /// A pane not observed yet. herdr: `PaneState::new` starts `seen = true`. + pub const NEW: PaneTrack = PaneTrack { + state: AgentState::Unknown, + seen: true, + since: None, + }; + + pub fn status(&self) -> Status { + match (self.state, self.seen) { + (AgentState::Working, _) => Status::Working, + (AgentState::Blocked, _) => Status::Blocked, + (AgentState::Idle, false) => Status::Done, + (AgentState::Idle, true) => Status::Idle, + (AgentState::Unknown, _) => Status::Unknown, + } + } + + /// Rebuild a track from the pane options a previous daemon wrote. + fn restore(written_state: &str, written_since: &str) -> PaneTrack { + let since = written_since.trim().parse::<u64>().ok(); + let (state, seen) = match written_state { + "done" => (AgentState::Idle, false), + "idle" => (AgentState::Idle, true), + "working" => (AgentState::Working, true), + "blocked" => (AgentState::Blocked, true), + _ => return PaneTrack::NEW, + }; + let track = PaneTrack { state, seen, since }; + let needs_user = matches!(track.status(), Status::Done | Status::Blocked); + PaneTrack { + since: if needs_user { since } else { None }, + ..track + } + } +} + +/// One observation step for a pane: herdr's `apply_pane_state_change` +/// followed by `mark_active_tab_seen`, as a pure function. +/// +/// - Transition to any non-idle state: seen. +/// - Completion (working/blocked to idle): seen only if the user is looking +/// at the pane's window; otherwise the pane is done. +/// - Any other transition to idle (first observation): seen unchanged. +/// - Looking at the pane's window, or jumping to it (`acked`): seen. +pub fn advance( + prev: PaneTrack, + observed: AgentState, + attended: bool, + acked: bool, + now: u64, +) -> PaneTrack { + let mut next = prev; + if observed != prev.state { + next.state = observed; + if observed != AgentState::Idle { + next.seen = true; + } else if matches!(prev.state, AgentState::Working | AgentState::Blocked) { + next.seen = attended; + } + next.since = None; // a new episode + } + if attended || acked { + next.seen = true; + } + let needs_user = matches!(next.status(), Status::Done | Status::Blocked); + next.since = match (needs_user, next.since) { + (true, Some(since)) => Some(since), + (true, None) => Some(now), + (false, _) => None, + }; + next } pub struct Watcher { tracks: HashMap<String, PaneTrack>, - /// Last published (queue, counts); avoids rewriting options every tick. - last_published: Option<(String, (String, String, String, String))>, + /// Last published (queue, blocked, done, working); avoids rewriting the + /// global options every tick. + last_published: Option<(String, usize, usize, usize)>, interval_secs: u64, sounds: bool, + notifications: bool, } impl Watcher { - pub fn new(interval_secs: u64, sounds: bool) -> Self { + pub fn new(interval_secs: u64, sounds: bool, notifications: bool) -> Self { Self { tracks: HashMap::new(), last_published: None, interval_secs: interval_secs.max(1), sounds, + notifications, } } pub fn run(&mut self) -> Result<(), String> { info!(interval = self.interval_secs, "watching tmux panes"); - // dingger is a client of the tmux server, which may not exist yet — + // dingger is a client of the tmux server, which may not exist yet: // it starts when the first terminal opens one. Wait for it, then // keep watching across server restarts. let mut had_server = false; loop { + let started = std::time::Instant::now(); match self.tick() { Ok(()) => { if !had_server { @@ -141,145 +221,64 @@ impl Watcher { had_server = false; } } - std::thread::sleep(std::time::Duration::from_secs(self.interval_secs)); + debug!(ms = started.elapsed().as_millis() as u64, "tick"); + hooks::wait_for_wake(std::time::Duration::from_secs(self.interval_secs)); } } fn tick(&mut self) -> Result<(), String> { let panes = tmux::list_panes()?; + let now = unix_now(); // Forget panes that went away. self.tracks .retain(|pane_id, _| panes.iter().any(|pane| pane.pane_id == *pane_id)); - hooks::forget( - &panes - .iter() - .map(|pane| pane.pane_id.clone()) - .collect::<Vec<_>>(), - ); + hooks::forget(&panes.iter().map(|pane| pane.pane_id.clone()).collect::<Vec<_>>()); for pane in &panes { - let prev_track = self.tracks.get(&pane.pane_id).copied(); - let (prev_state, prev_seen) = prev_track - .map(|track| (track.state, track.seen)) - .unwrap_or((AgentState::Unknown, true)); - - // Raw state: hook reports are authoritative while the pane runs - // the agent; screen detection is the fallback for panes that - // never report. Viewer screens (transcripts, pickers) return - // `skip_state_update: true` — they say nothing about state and - // leave the previous one standing (herdr's publishing contract). - let raw = if pane.dead { - Some(AgentState::Idle) - } else if let Some(agent) = pane.agent { - if let Some(state) = hooks::hook_state(&pane.pane_id) { - Some(state) - } else { - let screen = match tmux::capture_pane(&pane.pane_id) { - Ok(screen) => screen, - Err(err) => { - warn!(target = %pane.display_target, %err, "capture failed"); - continue; - } - }; - // tmux pane_title carries the last OSC title the application - // set; tmux does not re-expose OSC progress sequences. - let detection = - detect_agent_with_osc(Some(agent), &screen, &pane.pane_title, ""); - (!detection.skip_state_update).then_some(detection.state) - } - } else { - // A pane that stops running an agent (agent quit into a - // shell) is no longer an observation; a stale hook report - // from the dead agent is not authoritative. - Some(AgentState::Unknown) - }; - - let Some(new_state) = raw else { + let Some(observations) = observe(pane) else { debug!(target = %pane.display_target, "observation skipped (viewer screen)"); continue; }; - // Seen: attended since the last state change. Attended means the - // pane is fully focused in an attached client, the user jumped to - // it (`@dingger_ack`), or it was just first observed. A first - // observation of idle is seen (nothing has happened yet); a - // working/blocked → idle transition is an unseen completion. - let transition = new_state != prev_state; - // Attended also via `dingger focus` / menu click (@dingger_ack). - let mut seen = if transition { - !(new_state == AgentState::Idle && prev_state == AgentState::Unknown) - } else { - prev_seen || pane.written_ack == "1" - }; - if !seen && pane.attended() { - seen = true; - } - if transition || seen != prev_seen { - let ready_since = if is_ready_to_attend(new_state, seen) { - Some(unix_now()) - } else { - None - }; - self.tracks.insert( - pane.pane_id.clone(), - PaneTrack { - state: new_state, - seen, - ready_since, - }, - ); - } - if transition { - debug!(target = %pane.display_target, ?prev_state, ?new_state, seen, "state change"); - let _ = tmux::clear_ack(&pane.pane_id); - } else if seen != prev_seen { - let _ = tmux::ack(&pane.pane_id); - } - let display = display_status( - self.tracks - .get(&pane.pane_id) - .unwrap_or(&PaneTrack { state: new_state, seen, ready_since: None }), - ); - // @agent_state mirrors the display status on both transitions and - // attendance changes (done → idle when the user focuses the pane). - if pane.written_state != display.label() { - if let Err(err) = tmux::set_agent_state(&pane.pane_id, display.label()) { - warn!(target = %pane.display_target, %err, "failed to write @agent_state"); - } - } - if new_state == AgentState::Unknown { - // The agent is gone; its last hook report must not outlive it. - hooks::drop_report(&pane.pane_id); - } + // First sight of a pane in this process: resume from its options. + let restored = !self.tracks.contains_key(&pane.pane_id); + let mut track = self + .tracks + .get(&pane.pane_id) + .copied() + .unwrap_or_else(|| PaneTrack::restore(&pane.written_state, &pane.written_since)); + let acked = pane.written_ack == "1"; - // Keep the pane's agent label current even when the state is - // unchanged (e.g. an agent swap between two idle panes). - let agent_label = pane - .agent - .map(detect_core::agent_label) - .unwrap_or("") - .to_string(); - if pane.written_agent != agent_label { - if let Err(err) = tmux::set_agent(&pane.pane_id, pane.agent) { - warn!(target = %pane.display_target, %err, "failed to write @agent"); + // Apply every observation in order: a turn that started and + // finished between two ticks is still a completion. + let mut alerts = Vec::new(); + for observed in observations { + let next = advance(track, observed, pane.attended(), acked, now); + if next.state != track.state { + info!( + target = %pane.display_target, + prev = track.status().label(), + next = next.status().label(), + "state change" + ); + // Notify on transitions observed by this process, not + // when resuming from options written before a restart. + if !restored { + alerts.extend(notification_kind_for_transition(track.state, next.state)); + } } + track = next; } + self.tracks.insert(pane.pane_id.clone(), track); - if transition { - if let Some(kind) = notification_kind_for_transition(prev_state, new_state) { - // herdr: a blocked pane plays the alert sound even when - // the user is watching; a finished pane stays silent when - // they are already looking at it. - let suppressed = pane.suppresses_notifications(); - if !suppressed { - let context = format!("{} ({})", pane.display_target, agent_label); - send_notification(&agent_label, &kind, &context); - } - if self.sounds { - play_sound(&kind); - } - } + // Update what the user sees first, then alert. + if acked { + let _ = tmux::unset_pane_option(&pane.pane_id, "@dingger_ack"); + } + self.write_pane_options(pane, track); + for kind in alerts { + self.notify(pane, kind); } } @@ -287,134 +286,279 @@ impl Watcher { Ok(()) } - /// Publish the FIFO queue (done + blocked panes, oldest transition first) - /// and counts as server-global options for the tmux status line and - /// `dingger focus`. + fn write_pane_options(&self, pane: &tmux::Pane, track: PaneTrack) { + let label = track.status().label(); + if pane.written_state != label { + if let Err(err) = tmux::set_pane_option(&pane.pane_id, "@agent_state", label) { + warn!(target = %pane.display_target, %err, "failed to write @agent_state"); + } + } + let since = track.since.map(|s| s.to_string()).unwrap_or_default(); + if pane.written_since != since { + let _ = if since.is_empty() { + tmux::unset_pane_option(&pane.pane_id, "@dingger_since") + } else { + tmux::set_pane_option(&pane.pane_id, "@dingger_since", &since) + }; + } + let agent = pane.agent.map(detect_core::agent_label).unwrap_or(""); + if pane.written_agent != agent { + let _ = tmux::set_pane_option(&pane.pane_id, "@agent", agent); + } + // Persist the authoritative hook state so a restart keeps it. + let hook = hooks::last_state(&pane.pane_id) + .map(state_label) + .unwrap_or_default(); + if pane.agent.is_some() && !hook.is_empty() && pane.written_hook != hook { + let _ = tmux::set_pane_option(&pane.pane_id, "@dingger_hook", hook); + } + if pane.agent.is_none() && !pane.written_hook.is_empty() { + let _ = tmux::unset_pane_option(&pane.pane_id, "@dingger_hook"); + } + } + + fn notify(&self, pane: &tmux::Pane, kind: NotificationKind) { + // herdr: a blocked pane plays the alert even when the user is looking + // at it; a finished pane stays silent when they already are. + let watching = pane.attended(); + if matches!(kind, NotificationKind::Finished) && watching { + return; + } + let agent = pane.agent.map(detect_core::agent_label).unwrap_or("agent"); + if self.notifications && !watching { + send_notification(agent, &kind, &format!("{} ({agent})", pane.display_target)); + } + if self.sounds { + play_sound(&kind); + } + } + + /// Publish the ready queue and counts as server-global options for the + /// status line, menu bar, and focus. The queue is ordered like the menu: + /// herdr's priority (blocked before done), oldest first within it. fn publish(&mut self, panes: &[tmux::Pane]) { - let mut ready: Vec<(&str, String, u64)> = Vec::new(); // (pane_id, target, ready_since) - let mut blocked = 0usize; - let mut done = 0usize; - let mut working = 0usize; + let mut queue: Vec<(std::cmp::Reverse<u8>, u64, &str)> = Vec::new(); + let (mut blocked, mut done, mut working) = (0, 0, 0); for pane in panes { let Some(track) = self.tracks.get(&pane.pane_id) else { continue; }; - let status = display_status(track); - match status { + match track.status() { + Status::Blocked => blocked += 1, + Status::Done => done += 1, Status::Working => working += 1, - Status::Done | Status::Blocked => { - if track.ready_since.is_some() { - if status == Status::Blocked { - blocked += 1; - } else { - done += 1; - } - if let Some(since) = track.ready_since { - ready.push((pane.pane_id.as_str(), pane.display_target.clone(), since)); - } - } - } Status::Idle | Status::Unknown => {} } + if let Some(since) = track.since { + queue.push((std::cmp::Reverse(track.status().priority()), since, pane.pane_id.as_str())); + } } - ready.sort_by_key(|(_, _, since)| *since); - - let queue = ready + queue.sort(); + let queue = queue .iter() - .map(|(pane_id, _, since)| format!("{pane_id}:{since}")) + .map(|(_, _, pane_id)| *pane_id) .collect::<Vec<_>>() .join(","); - let counts = ( - ready.len().to_string(), - blocked.to_string(), - working.to_string(), - done.to_string(), - ); - let next = (queue.clone(), counts.clone()); - if self.last_published.as_ref() != Some(&next) { - self.last_published = Some(next); - for (name, value) in [ - ("@dingger_queue", queue.as_str()), - ("@dingger_ready", counts.0.as_str()), - ("@dingger_blocked", counts.1.as_str()), - ("@dingger_working", counts.2.as_str()), - ("@dingger_done", counts.3.as_str()), - ] { - if let Err(err) = tmux::set_global(name, value) { - warn!(%err, "failed to write {name}"); - } + + let next = (queue, blocked, done, working); + if self.last_published.as_ref() == Some(&next) { + return; + } + for (name, value) in [ + ("@dingger_queue", next.0.clone()), + ("@dingger_blocked", next.1.to_string()), + ("@dingger_done", next.2.to_string()), + ("@dingger_working", next.3.to_string()), + ] { + if let Err(err) = tmux::set_global(name, &value) { + warn!(%err, "failed to write {name}"); } } + self.last_published = Some(next); + } +} + +/// The pane's raw agent states this tick, in order, or `None` when the +/// screen is a viewer (transcript, picker) that says nothing about state. +fn observe(pane: &tmux::Pane) -> Option<Vec<AgentState>> { + if pane.dead { + return Some(vec![AgentState::Idle]); + } + let Some(agent) = pane.agent else { + // The agent is gone; its last hook report must not outlive it. + hooks::drop_reports(&pane.pane_id); + return Some(vec![AgentState::Unknown]); + }; + // Agent-reported state is authoritative; it survives restarts through + // the `@dingger_hook` option until the extension reports again. + if let Some(states) = hooks::take_observations(&pane.pane_id) { + return Some(states); + } + if let Some(state) = parse_state(&pane.written_hook) { + return Some(vec![state]); } + let screen = match tmux::capture_pane(&pane.pane_id) { + Ok(screen) => screen, + Err(err) => { + warn!(target = %pane.display_target, %err, "capture failed"); + return None; + } + }; + // tmux pane_title carries the last OSC title the application set; tmux + // does not re-expose OSC progress sequences. + let detection = detect_agent_with_osc(Some(agent), &screen, &pane.pane_title, ""); + (!detection.skip_state_update).then(|| vec![detection.state]) } -/// When a pane became ready for the user, as unix seconds. Ready means the -/// agent is waiting on the user: done (finished, unseen) or blocked. -fn is_ready_to_attend(state: AgentState, seen: bool) -> bool { +fn state_label(state: AgentState) -> &'static str { match state { - AgentState::Blocked => true, - AgentState::Idle => !seen, - _ => false, + AgentState::Idle => "idle", + AgentState::Working => "working", + AgentState::Blocked => "blocked", + AgentState::Unknown => "unknown", } } -fn display_status(track: &PaneTrack) -> Status { - match track.state { - AgentState::Working => Status::Working, - AgentState::Blocked => Status::Blocked, - AgentState::Idle => { - if track.seen { - Status::Idle - } else { - Status::Done - } - } - AgentState::Unknown => Status::Unknown, +fn parse_state(label: &str) -> Option<AgentState> { + match label { + "idle" => Some(AgentState::Idle), + "working" => Some(AgentState::Working), + "blocked" => Some(AgentState::Blocked), + _ => None, } } fn unix_now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) + .map(|d| d.as_millis() as u64) .unwrap_or(0) } #[cfg(test)] mod tests { use super::*; + use AgentState::{Blocked, Idle, Unknown, Working}; + + fn step(prev: PaneTrack, observed: AgentState, attended: bool) -> PaneTrack { + advance(prev, observed, attended, false, 100) + } + + #[test] + fn notification_policy_matches_herdr() { + assert!(matches!( + notification_kind_for_transition(Working, Blocked), + Some(NotificationKind::NeedsAttention) + )); + assert!(matches!( + notification_kind_for_transition(Unknown, Blocked), + Some(NotificationKind::NeedsAttention) + )); + assert!(matches!( + notification_kind_for_transition(Working, Idle), + Some(NotificationKind::Finished) + )); + assert!(matches!( + notification_kind_for_transition(Blocked, Idle), + Some(NotificationKind::Finished) + )); + assert!(notification_kind_for_transition(Blocked, Blocked).is_none()); + assert!(notification_kind_for_transition(Unknown, Idle).is_none()); + assert!(notification_kind_for_transition(Idle, Working).is_none()); + assert!(notification_kind_for_transition(Idle, Unknown).is_none()); + } + + // herdr: initial_idle_in_background_stays_seen + #[test] + fn first_observation_of_idle_is_idle() { + let track = step(PaneTrack::NEW, Idle, false); + assert_eq!(track.status(), Status::Idle); + assert_eq!(track.since, None); + } + + // herdr: state_changed_idle_in_background_marks_unseen + #[test] + fn completion_away_from_the_window_is_done() { + let working = step(step(PaneTrack::NEW, Idle, false), Working, false); + assert_eq!(working.status(), Status::Working); + let done = step(working, Idle, false); + assert_eq!(done.status(), Status::Done); + assert_eq!(done.since, Some(100)); + } + + // herdr: active_tab_completion_marks_pane_seen + #[test] + fn completion_while_looking_is_idle() { + let working = step(PaneTrack::NEW, Working, true); + let idle = step(working, Idle, true); + assert_eq!(idle.status(), Status::Idle); + assert_eq!(idle.since, None); + } + + // herdr: switch_workspace_marks_panes_seen (mark_active_tab_seen) + #[test] + fn looking_at_the_window_turns_done_into_idle() { + let done = step(step(PaneTrack::NEW, Working, false), Idle, false); + assert_eq!(done.status(), Status::Done); + let still_done = step(done, Idle, false); + assert_eq!(still_done.status(), Status::Done); + let seen = step(still_done, Idle, true); + assert_eq!(seen.status(), Status::Idle); + assert_eq!(seen.since, None); + } + + // herdr: agent_focus_marks_already_focused_done_agent_seen + #[test] + fn jumping_to_a_pane_turns_done_into_idle() { + let done = step(step(PaneTrack::NEW, Working, false), Idle, false); + let seen = advance(done, Idle, false, true, 200); + assert_eq!(seen.status(), Status::Idle); + } + + #[test] + fn working_again_clears_done_and_the_next_completion_is_done_again() { + let done = step(step(PaneTrack::NEW, Working, false), Idle, false); + let working = step(done, Working, false); + assert_eq!(working.status(), Status::Working); + assert_eq!(working.since, None); + let done_again = advance(working, Idle, false, false, 300); + assert_eq!(done_again.status(), Status::Done); + assert_eq!(done_again.since, Some(300)); + } + + #[test] + fn blocked_needs_the_user_even_while_looking() { + let blocked = step(step(PaneTrack::NEW, Working, false), Blocked, true); + assert_eq!(blocked.status(), Status::Blocked); + assert_eq!(blocked.since, Some(100)); + // answering the prompt: back to work, then a completion away → done + let done = step(step(blocked, Working, false), Idle, false); + assert_eq!(done.status(), Status::Done); + } + + #[test] + fn fifo_stamp_survives_unchanged_ticks() { + let done = advance(step(PaneTrack::NEW, Working, false), Idle, false, false, 50); + let later = advance(done, Idle, false, false, 999); + assert_eq!(later.since, Some(50)); + } #[test] - fn policy_matches_herdr() { - assert!(notification_kind_for_transition(AgentState::Working, AgentState::Blocked) - .is_some_and(|kind| matches!(kind, NotificationKind::NeedsAttention))); - assert!(notification_kind_for_transition(AgentState::Blocked, AgentState::Blocked).is_none()); - assert!(notification_kind_for_transition(AgentState::Unknown, AgentState::Blocked) - .is_some_and(|kind| matches!(kind, NotificationKind::NeedsAttention))); - assert!(notification_kind_for_transition(AgentState::Working, AgentState::Idle) - .is_some_and(|kind| matches!(kind, NotificationKind::Finished))); - assert!(notification_kind_for_transition(AgentState::Blocked, AgentState::Idle) - .is_some_and(|kind| matches!(kind, NotificationKind::Finished))); - // Unknown → Idle is a first observation, not a completion. - assert!(notification_kind_for_transition(AgentState::Unknown, AgentState::Idle).is_none()); - assert!(notification_kind_for_transition(AgentState::Idle, AgentState::Working).is_none()); - assert!(notification_kind_for_transition(AgentState::Idle, AgentState::Unknown).is_none()); + fn agent_exit_is_unknown() { + let done = step(step(PaneTrack::NEW, Working, false), Idle, false); + assert_eq!(step(done, Unknown, false).status(), Status::Unknown); } #[test] - fn display_status_maps_herdr() { - let unseen = PaneTrack { state: AgentState::Idle, seen: false, ready_since: Some(1) }; - let seen = PaneTrack { state: AgentState::Idle, seen: true, ready_since: None }; - assert_eq!(display_status(&unseen), Status::Done); - assert_eq!(display_status(&seen), Status::Idle); - assert_eq!( - display_status(&PaneTrack { state: AgentState::Working, seen: false, ready_since: None }), - Status::Working - ); - assert_eq!( - display_status(&PaneTrack { state: AgentState::Blocked, seen: false, ready_since: Some(1) }), - Status::Blocked - ); + fn restore_resumes_where_the_previous_daemon_left_off() { + let done = PaneTrack::restore("done", "42"); + assert_eq!(done.status(), Status::Done); + assert_eq!(done.since, Some(42)); + // the same observation after a restart keeps it done + assert_eq!(step(done, Idle, false).status(), Status::Done); + assert_eq!(PaneTrack::restore("idle", "").status(), Status::Idle); + assert_eq!(PaneTrack::restore("", "").status(), Status::Unknown); + assert_eq!(PaneTrack::restore("idle", "42").since, None); } #[test] |
