aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src/watcher.rs
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-27 14:12:28 +0200
committerIgor Soarez <igor@soarez.org>2026-10-27 14:12:28 +0200
commit50c433e82c368b9861a89deecb74e5f29fa50353 (patch)
treefe6d87b79387f6c53ef9bf615b7b530daf9a8d3a /crates/dinggerd/src/watcher.rs
parent9f3a567d4bb1b7b6e42a744936f993bcd437882d (diff)
mark as unread: make an idle agent done again, to come back to it
like "mark as unread" in mail: an agent you have seen (idle) becomes done again, last among done agents of its importance, with no sound or notification. it stays done until you come back to it: leave its tmux window and return, or jump to it. marked while you look at it, it holds until you look away (`held`); a new turn clears it. only idle agents can be marked. - daemon: `pane.mark_unread` on the hook socket; the watcher applies it after the tick's observations (mark_unread, advance's hold rule). - `dingger unread [--pane]`: refuses done, working, blocked, and panes without an agent; returns once the agent shows as done. - pi: /unread (refused while the agent works or is blocked). - panel: right-click an idle agent in the full list, "Mark as unread". - search: ⌘U or right-click marks the selected idle agent; the footer offers ⌘U when it applies; search stays open. tests: unit (hold, jump, new turn, idle only), e2e step 22 (queue position, hold, look away and back, jump, refusals, no sound, menu, the extension), pi-live in real pi 1.4 (a mark, not a turn: an unknown command would also end done), swift selftest (menu only for idle, footer).
Diffstat (limited to 'crates/dinggerd/src/watcher.rs')
-rw-r--r--crates/dinggerd/src/watcher.rs79
1 files changed, 76 insertions, 3 deletions
diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs
index f9c987e..b76f53c 100644
--- a/crates/dinggerd/src/watcher.rs
+++ b/crates/dinggerd/src/watcher.rs
@@ -105,6 +105,9 @@ pub struct PaneTrack {
/// serves done and blocked oldest first; working and idle are listed
/// most recent first.
pub since: Option<u64>,
+ /// 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 {
@@ -113,6 +116,7 @@ impl PaneTrack {
state: AgentState::Unknown,
seen: true,
since: None,
+ held: false,
};
pub fn status(&self) -> Status {
@@ -135,7 +139,7 @@ impl PaneTrack {
"blocked" => (AgentState::Blocked, true),
_ => return PaneTrack::NEW,
};
- PaneTrack { state, seen, since }
+ PaneTrack { state, seen, since, held: false }
}
}
@@ -146,7 +150,9 @@ impl PaneTrack {
/// - 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.
+/// - 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,
@@ -157,14 +163,18 @@ pub fn advance(
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 attended || acked {
+ 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);
@@ -172,6 +182,16 @@ pub fn advance(
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;
@@ -298,6 +318,16 @@ impl Watcher {
}
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.
@@ -561,6 +591,49 @@ mod tests {
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() {