all repos — dingger @ 12ce9f3f8edc84064458b649e8615247641dad36

Watches the AI coding agents in your tmux panes on macOS and tells you which need you, from the menu bar, a floating panel, the status line and notifications; jumps to them, and restores tmux sessions and agents after a crash

list working and idle agents most recent first

within a status, the menu, panel, search, and `dingger status` listed done
and blocked agents oldest first (the queue), and the rest by place. now
working and idle agents are most recent first: the first idle row is the
agent that finished, or that the user looked at, last.

@dingger_since now means when the pane entered its current status, for
every status (it was set only while the pane needed the user), and
survives a daemon restart. the queue selects done and blocked panes by
status, no longer by having a stamp. one ordering rule each in Rust
(watcher::list_order) and Swift (listedBefore), with tests that fail on
the old order; e2e step 11 makes an agent finish while watched and checks
it moves to the top of the idle rows.

agents idle since before this change share one stamp and keep their place
order until they next change.
Igor Soarez igor@soarez.org
Fri, 23 Oct 2026 17:58:24 +0300
commit

12ce9f3f8edc84064458b649e8615247641dad36

parent

7520141a55e8876824dcb4e2cf5e8e3027efa59d

M crates/dinggerd/src/main.rs → crates/dinggerd/src/main.rs

@@ -253,8 +253,7 @@ return Ok(());

} 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()) + (watcher::list_order(status, pane.written_since.parse().ok()), pane.display_target.clone()) }); let place = |pane: &tmux::Pane| pane.display_target.replacen(':', " · ", 1); let width = panes.iter().map(|pane| place(pane).chars().count()).max().unwrap_or(5).max(5);
M crates/dinggerd/src/watcher.rs → crates/dinggerd/src/watcher.rs

@@ -20,7 +20,7 @@ //! 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_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

@@ -100,8 +100,9 @@ #[derive(Debug, Clone, Copy, PartialEq, Eq)]

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. + /// 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<u64>, }

@@ -133,12 +134,7 @@ "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 - } + PaneTrack { state, seen, since } } }

@@ -165,18 +161,26 @@ 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, - }; + if next.status() != prev.status() || next.since.is_none() { + next.since = Some(now); + } next +} + +/// The order agents are listed in: herdr's status priority; within a +/// status, 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, since: Option<u64>) -> (std::cmp::Reverse<u8>, 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()), key) } pub struct Watcher {

@@ -363,7 +367,9 @@ Status::Done => done += 1,

Status::Working => working += 1, Status::Idle | Status::Unknown => {} } - if let Some(since) = track.since { + // The queue: what needs the user, oldest first. + if matches!(track.status(), Status::Blocked | Status::Done) { + let since = track.since.unwrap_or(u64::MAX); queue.push((std::cmp::Reverse(track.status().priority()), since, pane.pane_id.as_str())); } }

@@ -498,7 +504,7 @@ #[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); + assert_eq!(track.since, Some(100)); } // herdr: state_changed_idle_in_background_marks_unseen

@@ -515,9 +521,9 @@ // 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); + let idle = advance(working, Idle, true, false, 150); assert_eq!(idle.status(), Status::Idle); - assert_eq!(idle.since, None); + assert_eq!(idle.since, Some(150), "idle since the turn ended"); } // herdr: switch_workspace_marks_panes_seen (mark_active_tab_seen)

@@ -527,9 +533,9 @@ 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); + let seen = advance(still_done, Idle, true, false, 170); assert_eq!(seen.status(), Status::Idle); - assert_eq!(seen.since, None); + assert_eq!(seen.since, Some(170), "idle since the user looked"); } // herdr: agent_focus_marks_already_focused_done_agent_seen

@@ -543,9 +549,9 @@

#[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); + let working = advance(done, Working, false, false, 250); assert_eq!(working.status(), Status::Working); - assert_eq!(working.since, None); + 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));

@@ -562,10 +568,31 @@ assert_eq!(done.status(), Status::Done);

} #[test] - fn fifo_stamp_survives_unchanged_ticks() { + fn the_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)); + 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, *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]

@@ -583,7 +610,7 @@ // 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); + assert_eq!(PaneTrack::restore("idle", "42").since, Some(42), "recency survives a restart"); } #[test]
M tests/e2e.sh → tests/e2e.sh

@@ -248,7 +248,11 @@ expect "client back on wb1" "client_window" wb1

hook "$A1" working; hook "$A1" idle expect "A1 done" "opt $A1 @agent_state" done expect "menu title" "mb --print | head -1" "title: ×1 ✓1" - expect "menu rows: herdr priority, names as titles, harness when unnamed" \ + expect "menu rows: herdr priority; idle most recent first (C1 since its jump in step 4, B1 since the start)" \ + "mb --print | tail -n +2 | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|pi;○ idle|Review PR 42;" + hook "$B1" working; hook "$B1" idle + expect "B1 finishes a turn while watched: idle" "opt $B1 @agent_state" idle + expect "and is now the most recent idle agent: listed first" \ "mb --print | tail -n +2 | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|Review PR 42;○ idle|pi;" expect "subtitle: location, harness, waiting time" "mb --print | grep 'Deploy the widget' | cut -f3" "a · wa1 · pi · done now" expect "idle subtitle has no waiting time" "mb --print | grep 'Review PR 42' | cut -f3" "b · wb1 · pi"

@@ -302,9 +306,11 @@ expect "panel summarizes the rest" "mb --print-panel | tail -1" "○ 3 idle"

DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus --client "$CLIENT" --pane "$A2" expect "A2 idle after the jump" "opt $A2 @agent_state" idle expect "panel with nothing waiting" "mb --print-panel" "Nothing waiting · ○ 4 idle" + # Most recent first: A2 (just jumped to), A1 (clicked in step 11), B1 (finished + # in step 11, before that click), C1. expect "full list: every agent, then the summary with the toggle" \ "mb --print-panel --full | cut -f1,2 | tr '\t\n' '|;'" \ - "○ idle|Deploy the widget;○ idle|pi;○ idle|Review PR 42;○ idle|pi;Nothing waiting · ○ 4 idle|[Show less ▴];" + "○ idle|pi;○ idle|Deploy the widget;○ idle|Review PR 42;○ idle|pi;Nothing waiting · ○ 4 idle|[Show less ▴];" expect "compact in Always mode offers 'Show all'" "mb --print-panel --toggle" "Nothing waiting · ○ 4 idle [Show all ▾]" hook "$A1" working; hook "$A1" idle expect "A1 done" "opt $A1 @agent_state" done