aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src/watcher.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/dinggerd/src/watcher.rs')
-rw-r--r--crates/dinggerd/src/watcher.rs634
1 files changed, 389 insertions, 245 deletions
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]