diff options
Diffstat (limited to 'crates/dinggerd/src')
| -rw-r--r-- | crates/dinggerd/src/hooks.rs | 27 | ||||
| -rw-r--r-- | crates/dinggerd/src/main.rs | 51 | ||||
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 79 |
3 files changed, 153 insertions, 4 deletions
diff --git a/crates/dinggerd/src/hooks.rs b/crates/dinggerd/src/hooks.rs index a649fa0..b2f9e97 100644 --- a/crates/dinggerd/src/hooks.rs +++ b/crates/dinggerd/src/hooks.rs @@ -18,6 +18,9 @@ //! order, so a turn that starts and finishes between two polls is still a //! completion, and each report wakes the watcher immediately. //! +//! `{"method":"pane.mark_unread","params":{"pane_id":"%3"}}` marks an idle +//! agent unread (`dingger unread`, pi's /unread): it shows as done again. +//! //! Each line is answered with `{"ok":true}`. //! //! `{"method":"poke"}` only wakes the watcher; `dingger focus` and tmux's @@ -26,7 +29,7 @@ //! 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::collections::{HashMap, HashSet}; use std::io::{BufRead, BufReader}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::PathBuf; @@ -68,6 +71,20 @@ fn reports() -> MutexGuard<'static, HashMap<String, PaneReports>> { .unwrap_or_else(|poisoned| poisoned.into_inner()) } +/// Panes marked unread, not yet applied by the watcher. +fn unread_marks() -> MutexGuard<'static, HashSet<String>> { + static MARKS: OnceLock<Mutex<HashSet<String>>> = OnceLock::new(); + MARKS + .get_or_init(|| Mutex::new(HashSet::new())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// Whether the pane was marked unread since the last call. +pub fn take_unread(pane_id: &str) -> bool { + unread_marks().remove(pane_id) +} + fn wake_signal() -> &'static (Mutex<bool>, Condvar) { static WAKE: OnceLock<(Mutex<bool>, Condvar)> = OnceLock::new(); WAKE.get_or_init(|| (Mutex::new(false), Condvar::new())) @@ -145,6 +162,14 @@ fn handle_stream(stream: UnixStream) { wake(); continue; } + if message.method == "pane.mark_unread" { + if let Some(params) = message.params { + info!(pane = %params.pane_id, "marked unread"); + unread_marks().insert(params.pane_id); + wake(); + } + continue; + } let (true, Some(params)) = (message.method == "pane.report_agent", message.params) else { continue; }; diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs index c50fb80..2c36198 100644 --- a/crates/dinggerd/src/main.rs +++ b/crates/dinggerd/src/main.rs @@ -82,6 +82,14 @@ enum Command { #[arg(long)] pane: Option<String>, }, + /// Mark an idle agent unread: it shows as done again, last among done + /// agents of its importance, until you come back to it. In pi, /unread + /// does the same. + Unread { + /// The agent's pane (default: the pane this runs in). + #[arg(long)] + pane: Option<String>, + }, /// Restore tmux sessions saved before tmux last stopped (a crash, /// shutdown, or kill-server): windows, panes, layouts, directories. /// Only adds what is missing; a bare session of the same name (what a @@ -194,6 +202,12 @@ fn main() { std::process::exit(1); } } + Command::Unread { pane } => { + if let Err(err) = mark_unread(pane) { + eprintln!("dingger: {err}"); + std::process::exit(1); + } + } Command::Poke => { // Best effort: a stopped watcher just means nothing to wake. let _ = hooks::poke(); @@ -341,6 +355,43 @@ fn set_importance(level: Option<&str>, pane: Option<String>) -> Result<(), Strin Ok(()) } +/// `dingger unread`: ask the daemon to mark the pane's idle agent unread, +/// and wait until it shows as done. +fn mark_unread(pane: Option<String>) -> Result<(), String> { + let target = pane + .or_else(|| std::env::var("TMUX_PANE").ok()) + .ok_or("not in a tmux pane; pass --pane")?; + let read = || -> Result<(String, String, String), String> { + let info = tmux::run(&["display-message", "-p", "-t", &target, "#{pane_id}\t#{@agent}\t#{@agent_state}"]) + .map_err(|_| format!("no pane {target}"))?; + match info.trim_end_matches('\n').split('\t').collect::<Vec<_>>()[..] { + [id, agent, state] => Ok((id.to_string(), agent.to_string(), state.to_string())), + _ => Err(format!("no pane {target}")), + } + }; + let (pane_id, agent, state) = read()?; + match (agent.as_str(), state.as_str()) { + ("", _) => return Err(format!("no agent in pane {target}")), + (_, "idle") => {} + (_, "done") => return Err("it is unread already (done)".into()), + (_, state) => return Err(format!("only an idle agent can be marked unread; this one is {state}")), + } + let message = serde_json::json!({ "method": "pane.mark_unread", "params": { "pane_id": pane_id } }); + hooks::send(&message.to_string())?; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + while std::time::Instant::now() < deadline { + match read()?.2.as_str() { + "done" => { + println!("marked unread"); + return Ok(()); + } + "idle" => std::thread::sleep(std::time::Duration::from_millis(50)), + other => return Err(format!("not marked: it turned {other}")), + } + } + Err("not marked: the daemon did not apply it".into()) +} + /// Every agent pane in herdr's priority order (blocked, done, working, /// idle), oldest-waiting first within a status. fn status() -> Result<(), String> { 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() { |
