//! 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, //! the raw agent state (working/blocked/idle, from hooks or scraping) and a //! `seen` flag: //! //! | raw | seen | display | priority | //! |----------|--------|----------|----------| //! | working | any | working | 2 | //! | blocked | any | blocked | 4 | //! | idle | false | done | 3 | //! | idle | true | idle | 1 | //! | none | any | unknown | 0 | //! //! `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 entered its current status //! - `@dingger_hook` last state an agent extension reported //! - `@dingger_ack` one-shot "the user jumped here" event from `dingger focus` //! - `@agent_session` the agent's session reference, for restore //! - `@dingger_extension`, `@dingger_control` the version of pi's extension //! and its control socket, for reloading agents on an old one use std::collections::HashMap; use detect_core::{detect_agent_with_osc, Agent, AgentState}; use tracing::{debug, info, warn}; use crate::hooks; use crate::importance::Importance; use crate::notify::{play_sound, send_notification, NotificationKind}; use crate::tmux; /// herdr's display statuses. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Status { Unknown, Idle, Working, Done, Blocked, } impl Status { pub fn label(self) -> &'static str { match self { Status::Unknown => "unknown", Status::Idle => "idle", Status::Working => "working", Status::Done => "done", Status::Blocked => "blocked", } } pub fn from_label(label: &str) -> Status { match label { "idle" => Status::Idle, "working" => Status::Working, "done" => Status::Done, "blocked" => Status::Blocked, _ => Status::Unknown, } } /// herdr's priority: blocked > done > working > idle > unknown. pub fn priority(self) -> u8 { match self { Status::Blocked => 4, Status::Done => 3, Status::Working => 2, Status::Idle => 1, Status::Unknown => 0, } } } /// herdr's notification policy (`notification_toast_for_state_change`), /// driven by raw state transitions. pub fn notification_kind_for_transition( prev_state: AgentState, new_state: AgentState, ) -> Option { if new_state == prev_state { return None; } match new_state { AgentState::Blocked => Some(NotificationKind::NeedsAttention), 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)] pub struct PaneTrack { pub state: AgentState, pub seen: bool, /// Unix millisecond the pane entered its current status: the queue /// serves done and blocked oldest first; working and idle are listed /// most recent first. pub since: Option, /// Marked unread while the user looked at it: it stays done until they /// look away, and is seen when they come back. pub held: bool, } 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, held: false, }; 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::().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, }; PaneTrack { state, seen, since, held: false } } } /// 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. A pane /// marked unread while looked at (`held`) is seen only once the user /// looks away and back, or jumps to it. 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; next.held = false; if observed != AgentState::Idle { next.seen = true; } else if matches!(prev.state, AgentState::Working | AgentState::Blocked) { next.seen = attended; } } if acked || (attended && !next.held) { next.seen = true; next.held = false; } else if !attended { next.held = false; } if next.status() != prev.status() || next.since.is_none() { next.since = Some(now); } next } /// Mark an idle agent unread: done again, listed last among done agents of /// its importance (from now). If the user is looking at it, it stays done /// until they look away. Any other status is left alone. pub fn mark_unread(track: PaneTrack, attended: bool, now: u64) -> PaneTrack { if track.status() != Status::Idle { return track; } PaneTrack { seen: false, held: attended, since: Some(now), ..track } } /// The order agents are listed in: herdr's status priority; within a /// status, the more important first; then done and blocked oldest first /// (the queue: nothing waits forever), working and idle most recent first; /// then by place. pub fn list_order( status: Status, importance: Importance, since: Option, ) -> (std::cmp::Reverse, std::cmp::Reverse, i128) { let since = since.map(i128::from); let key = match status { Status::Done | Status::Blocked => since.unwrap_or(i128::MAX), _ => since.map(|since| -since).unwrap_or(i128::MAX), }; (std::cmp::Reverse(status.priority()), std::cmp::Reverse(importance), key) } /// A pane's importance: as its agent last reported it, else as written /// (it survives a daemon restart); normal without an agent. pub fn importance_of(pane: &tmux::Pane) -> Importance { if pane.agent.is_none() { return Importance::Normal; } hooks::importance(&pane.pane_id).unwrap_or_else(|| Importance::from_option(&pane.written_importance)) } /// An agent on an old dingger extension: dingger can reload it (its /// extension takes requests), or only the user can (`/reload`). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Outdated { Reloadable, ByHand, } /// Whether a pane runs an old extension: a pi that reports through one /// (its hook state) with a version other than `current`, or none (an /// extension from before versions, which takes no requests either). pub fn outdated(agent: Option, reports: bool, extension: &str, control: &str, current: &str) -> Option { if agent != Some(Agent::Pi) || !reports || extension == current { return None; } Some(if control.is_empty() { Outdated::ByHand } else { Outdated::Reloadable }) } /// Whether to ask an outdated agent to reload now: it is idle (done /// included: a reload keeps it done) and not on screen, where the user may /// be typing into it. The extension itself refuses while busy or with a /// draft in its input box. pub fn reload_now(track: PaneTrack, attended: bool) -> bool { track.state == AgentState::Idle && !attended } /// How long before asking an agent that refused (or did not reload) again. const RELOAD_RETRY: std::time::Duration = std::time::Duration::from_secs(10); /// The pane's extension version and control socket: as last reported, /// else as written (they survive a daemon restart). fn extension_of(pane: &tmux::Pane) -> (String, String) { let (extension, control) = hooks::extension(&pane.pane_id); ( extension.unwrap_or_else(|| pane.written_extension.clone()), control.unwrap_or_else(|| pane.written_control.clone()), ) } fn outdated_of(pane: &tmux::Pane) -> Option { let reports = hooks::last_state(&pane.pane_id).is_some() || !pane.written_hook.is_empty(); let (extension, control) = extension_of(pane); outdated(pane.agent, reports, &extension, &control, current_extension()) } fn current_extension() -> &'static str { static CURRENT: std::sync::OnceLock = std::sync::OnceLock::new(); CURRENT.get_or_init(crate::integrations::pi_extension_version) } pub struct Watcher { tracks: HashMap, /// When each outdated agent was last asked to reload. reload_asked: HashMap, snapshots: crate::snapshot::Recorder, offer: crate::restore::OfferPublisher, /// Last published (queue, blocked, done, working, outdated reloadable, /// outdated by hand); avoids rewriting the /// global options every tick. last_published: Option<(String, usize, usize, usize, usize, usize)>, interval_secs: u64, sounds: bool, notifications: bool, } impl Watcher { pub fn new(interval_secs: u64, sounds: bool, notifications: bool) -> Self { Self { tracks: HashMap::new(), reload_asked: HashMap::new(), snapshots: Default::default(), offer: Default::default(), 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: // 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 { info!("tmux server available"); had_server = true; } } Err(err) => { if had_server { warn!(%err, "tmux server unavailable; waiting for it to return"); } had_server = false; } } // After the tick, so snapshots carry the states it just wrote. self.snapshots.tick(unix_now()); self.offer.tick(); 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 listed = std::time::Instant::now(); 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::>()); for pane in &panes { let Some(observations) = observe(pane, listed) else { debug!(target = %pane.display_target, "observation skipped (viewer screen)"); continue; }; // 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"; // 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; } if hooks::take_unread(&pane.pane_id) { let next = mark_unread(track, pane.attended, now); info!( target = %pane.display_target, applied = next != track, status = track.status().label(), "mark unread" ); track = next; } self.tracks.insert(pane.pane_id.clone(), track); // 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); } } self.reload_outdated(&panes); self.publish(&panes); Ok(()) } /// While reloading is wanted: ask each outdated agent that can reload /// now, at most every `RELOAD_RETRY`; done when none is left. fn reload_outdated(&mut self, panes: &[tmux::Pane]) { self.reload_asked.retain(|pane_id, _| panes.iter().any(|pane| pane.pane_id == *pane_id)); if !hooks::reload_wanted() { return; } if !crate::integrations::pi_extension_installed_is_current() { warn!("reload agents: the installed pi extension is not this dingger's; `dingger install pi` first"); hooks::reload_done(); return; } let mut left = 0; for pane in panes { if outdated_of(pane) != Some(Outdated::Reloadable) { continue; } left += 1; let Some(track) = self.tracks.get(&pane.pane_id).copied() else { continue }; let asked = self.reload_asked.get(&pane.pane_id).is_some_and(|at| at.elapsed() < RELOAD_RETRY); if asked || !reload_now(track, pane.attended) { continue; } self.reload_asked.insert(pane.pane_id.clone(), std::time::Instant::now()); let (_, control) = extension_of(pane); let target = pane.display_target.clone(); // The extension answers at once; off the tick all the same. std::thread::spawn(move || match hooks::request_reload(&control) { Ok(()) => info!(%target, "reload: reloading"), Err(reason) => info!(%target, %reason, "reload: not now"), }); } if left == 0 { info!("reload agents on an old extension: none left"); hooks::reload_done(); } } /// Write the pane's options that changed, in one tmux command list: /// tmux applies it as a unit, so a daemon killed mid-write never leaves /// a display state that disagrees with the hook state it came from. fn write_pane_options(&self, pane: &tmux::Pane, track: PaneTrack) { let mut changes: Vec<(&str, Option)> = Vec::new(); let mut change = |name: &'static str, written: &str, value: String| { if written != value { changes.push((name, (!value.is_empty()).then_some(value))); } }; // The authoritative hook state, so a restart keeps it; it goes with // the agent. let hook = match pane.agent { Some(_) => hooks::last_state(&pane.pane_id).map(state_label).unwrap_or_default(), None => "", }; if pane.agent.is_none() || !hook.is_empty() { change("@dingger_hook", &pane.written_hook, hook.to_string()); } change("@agent_state", &pane.written_state, track.status().label().to_string()); change("@dingger_since", &pane.written_since, track.since.map(|s| s.to_string()).unwrap_or_default()); change("@agent", &pane.written_agent, pane.agent.map(detect_core::agent_label).unwrap_or("").to_string()); let name = crate::names::display_name(pane.agent, &pane.pane_title, &pane.cwd, host_name()) .unwrap_or_default(); change("@agent_name", &pane.written_name, name); // The session reference, for restore; it goes with the agent. match (pane.agent, hooks::session(&pane.pane_id)) { (Some(_), Some(session)) => change("@agent_session", &pane.written_session, session), (None, _) => change("@agent_session", &pane.written_session, String::new()), _ => {} } // The importance goes with the agent too. match (pane.agent, hooks::importance(&pane.pane_id)) { (Some(_), Some(level)) => { change("@dingger_importance", &pane.written_importance, level.option_value().to_string()) } (None, _) => change("@dingger_importance", &pane.written_importance, String::new()), _ => {} } // The extension's version and control socket go with the agent too. match (pane.agent, hooks::extension(&pane.pane_id)) { (Some(_), (extension, control)) => { if let Some(extension) = extension { change("@dingger_extension", &pane.written_extension, extension); } if let Some(control) = control { change("@dingger_control", &pane.written_control, control); } } (None, _) => { change("@dingger_extension", &pane.written_extension, String::new()); change("@dingger_control", &pane.written_control, String::new()); } } if let Err(err) = tmux::set_pane_options(&pane.pane_id, &changes) { warn!(target = %pane.display_target, %err, "failed to write pane options"); } } 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)); } // A low agent makes no sound; it still notifies and waits in line. if self.sounds && importance_of(pane) != Importance::Low { 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 queue = Vec::new(); let (mut blocked, mut done, mut working) = (0, 0, 0); let (mut reloadable, mut by_hand) = (0, 0); for pane in panes { match outdated_of(pane) { Some(Outdated::Reloadable) => reloadable += 1, Some(Outdated::ByHand) => by_hand += 1, None => {} } let Some(track) = self.tracks.get(&pane.pane_id) else { continue; }; match track.status() { Status::Blocked => blocked += 1, Status::Done => done += 1, Status::Working => working += 1, Status::Idle | Status::Unknown => {} } // The queue: what needs the user, in list order. if matches!(track.status(), Status::Blocked | Status::Done) { queue.push((list_order(track.status(), importance_of(pane), track.since), pane.pane_id.as_str())); } } queue.sort(); let queue = queue .iter() .map(|(_, pane_id)| *pane_id) .collect::>() .join(","); let next = (queue, blocked, done, working, reloadable, by_hand); 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()), ("@dingger_outdated", next.4.to_string()), ("@dingger_outdated_by_hand", next.5.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, listed: std::time::Instant) -> Option> { 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, listed); 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]) } /// The host name tmux uses as a pane's default title. fn host_name() -> &'static str { static HOST: std::sync::OnceLock = std::sync::OnceLock::new(); HOST.get_or_init(|| { std::process::Command::new("hostname") .output() .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string()) .unwrap_or_default() }) } fn state_label(state: AgentState) -> &'static str { match state { AgentState::Idle => "idle", AgentState::Working => "working", AgentState::Blocked => "blocked", AgentState::Unknown => "unknown", } } fn parse_state(label: &str) -> Option { 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_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, Some(100)); } // 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 = advance(working, Idle, true, false, 150); assert_eq!(idle.status(), Status::Idle); assert_eq!(idle.since, Some(150), "idle since the turn ended"); } // 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 = advance(still_done, Idle, true, false, 170); assert_eq!(seen.status(), Status::Idle); assert_eq!(seen.since, Some(170), "idle since the user looked"); } fn seen_while_looking() -> PaneTrack { step(step(PaneTrack::NEW, Working, true), Idle, true) } #[test] fn marking_unread_while_away_waits_for_the_user_to_look() { let idle = seen_while_looking(); let marked = mark_unread(idle, false, 300); assert_eq!(marked.status(), Status::Done); assert_eq!(marked.since, Some(300), "last among done agents"); assert_eq!(step(marked, Idle, false).status(), Status::Done); assert_eq!(step(marked, Idle, true).status(), Status::Idle, "seen when looked at"); } #[test] fn marking_unread_while_looking_holds_until_the_user_looks_away_and_back() { let marked = mark_unread(seen_while_looking(), true, 300); let still = step(step(marked, Idle, true), Idle, true); assert_eq!(still.status(), Status::Done, "still looking: stays done"); let away = step(still, Idle, false); assert_eq!(away.status(), Status::Done); assert_eq!(step(away, Idle, true).status(), Status::Idle, "back: seen"); } #[test] fn a_held_mark_clears_on_a_jump_or_a_new_turn() { let marked = mark_unread(seen_while_looking(), true, 300); assert_eq!(advance(marked, Idle, true, true, 310).status(), Status::Idle, "jumped to it"); let working = step(marked, Working, true); assert_eq!(working.status(), Status::Working); assert_eq!(step(working, Idle, true).status(), Status::Idle, "finished while looking"); } #[test] fn only_an_idle_agent_can_be_marked_unread() { let working = step(PaneTrack::NEW, Working, false); assert_eq!(mark_unread(working, false, 300), working); let done = step(working, Idle, false); assert_eq!(mark_unread(done, false, 300), done, "done keeps its place"); let blocked = step(PaneTrack::NEW, Blocked, false); assert_eq!(mark_unread(blocked, true, 300), blocked); } // 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 = advance(done, Working, false, false, 250); assert_eq!(working.status(), Status::Working); assert_eq!(working.since, Some(250)); 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 the_stamp_survives_unchanged_ticks() { let done = advance(step(PaneTrack::NEW, Working, false), Idle, false, false, 50); assert_eq!(advance(done, Idle, false, false, 999).since, Some(50)); let idle = advance(done, Idle, true, false, 60); assert_eq!(advance(idle, Idle, true, false, 999).since, Some(60)); } #[test] fn waiting_oldest_first_the_rest_most_recent_first() { let mut rows = [ ("idle old", Status::Idle, Some(10)), ("done new", Status::Done, Some(90)), ("idle unknown", Status::Idle, None), ("working old", Status::Working, Some(20)), ("idle new", Status::Idle, Some(80)), ("done old", Status::Done, Some(30)), ("blocked", Status::Blocked, Some(99)), ("working new", Status::Working, Some(70)), ]; rows.sort_by_key(|(_, status, since)| list_order(*status, Importance::Normal, *since)); let names: Vec<_> = rows.iter().map(|(name, ..)| *name).collect(); assert_eq!( names, ["blocked", "done old", "done new", "working new", "working old", "idle new", "idle old", "idle unknown"] ); } #[test] fn importance_orders_within_a_status_before_time() { use Importance::*; let mut rows = [ ("done old", Status::Done, Normal, Some(10)), ("done new high", Status::Done, High, Some(90)), ("done older low", Status::Done, Low, Some(5)), ("blocked low", Status::Blocked, Low, Some(99)), ("working high", Status::Working, High, Some(1)), ("working new", Status::Working, Normal, Some(80)), ]; rows.sort_by_key(|(_, status, importance, since)| list_order(*status, *importance, *since)); let names: Vec<_> = rows.iter().map(|(name, ..)| *name).collect(); assert_eq!( names, ["blocked low", "done new high", "done old", "done older low", "working high", "working new"], "status first, then importance, then time" ); } #[test] 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 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, Some(42), "recency survives a restart"); } #[test] fn an_old_extension_is_a_pi_reporting_another_version_or_none() { let pi = Some(Agent::Pi); assert_eq!(outdated(pi, true, "v2", "/s.sock", "v2"), None, "current"); assert_eq!(outdated(pi, true, "v1", "/s.sock", "v2"), Some(Outdated::Reloadable)); assert_eq!(outdated(pi, true, "", "", "v2"), Some(Outdated::ByHand), "from before versions"); assert_eq!(outdated(pi, false, "", "", "v2"), None, "no extension: nothing to reload"); assert_eq!(outdated(Some(Agent::Claude), true, "", "", "v2"), None, "Claude's hooks run dingger itself"); assert_eq!(outdated(None, true, "v1", "/s.sock", "v2"), None); } #[test] fn an_agent_reloads_when_idle_and_off_screen() { let track = |state, seen| PaneTrack { state, seen, since: Some(1), held: false }; assert!(reload_now(track(Idle, true), false)); assert!(reload_now(track(Idle, false), false), "done too: it stays done"); assert!(!reload_now(track(Idle, true), true), "on screen: the user may be typing"); assert!(!reload_now(track(Working, true), false)); assert!(!reload_now(track(Blocked, true), false)); assert!(!reload_now(PaneTrack::NEW, false)); } #[test] fn priority_matches_herdr() { assert!(Status::Blocked.priority() > Status::Done.priority()); assert!(Status::Done.priority() > Status::Working.priority()); assert!(Status::Working.priority() > Status::Idle.priority()); assert!(Status::Idle.priority() > Status::Unknown.priority()); } }