From 50c433e82c368b9861a89deecb74e5f29fa50353 Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Tue, 27 Oct 2026 14:12:28 +0200 Subject: mark as unread: make an idle agent done again, to come back to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- crates/dinggerd/assets/pi/pi-agent-state.ts | 39 +++++++++++--- crates/dinggerd/src/hooks.rs | 27 +++++++++- crates/dinggerd/src/main.rs | 51 +++++++++++++++++++ crates/dinggerd/src/watcher.rs | 79 +++++++++++++++++++++++++++-- 4 files changed, 186 insertions(+), 10 deletions(-) (limited to 'crates/dinggerd') diff --git a/crates/dinggerd/assets/pi/pi-agent-state.ts b/crates/dinggerd/assets/pi/pi-agent-state.ts index 4c21afe..0719265 100644 --- a/crates/dinggerd/assets/pi/pi-agent-state.ts +++ b/crates/dinggerd/assets/pi/pi-agent-state.ts @@ -47,11 +47,12 @@ function sendRequestAttempt(request: unknown, timeoutMs: number): Promise { +// Whether the daemon got it. +async function sendRequest(request: unknown): Promise { if (await sendRequestAttempt(request, 500)) { - return; + return true; } - await sendRequestAttempt(request, 1500); + return sendRequestAttempt(request, 1500); } type AgentState = "working" | "blocked" | "idle"; @@ -96,9 +97,13 @@ function savedImportance(ctx: any): Importance { return "normal"; } -function report(state: AgentState | undefined, message?: string): Promise { - return sendRequest({ - id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`, +function requestId(): string { + return `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`; +} + +async function report(state: AgentState | undefined, message?: string): Promise { + await sendRequest({ + id: requestId(), method: "pane.report_agent", params: { pane_id: paneId!, @@ -226,6 +231,28 @@ export default function (pi) { }, }); + // Like "mark as unread" in mail: dingger shows the agent as done again + // (a pane option; nothing is saved in the session). + pi.registerCommand("unread", { + description: "Mark this agent unread in dingger: it shows as done until you come back to it", + handler: async (_args: string, ctx: any) => { + if (agentActive || blockedCount > 0) { + ctx.ui.notify("only an idle agent can be marked unread", "error"); + return; + } + const delivered = await sendRequest({ + id: requestId(), + method: "pane.mark_unread", + params: { pane_id: paneId! }, + }); + if (delivered) { + ctx.ui.notify("marked unread: dingger shows it as done until you come back to it", "info"); + } else { + ctx.ui.notify("dingger is not running", "error"); + } + }, + }); + pi.on("session_start", async (event, ctx) => { // TUI only: RPC/JSON/print modes are headless. if (ctx?.mode !== "tui") { 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> { .unwrap_or_else(|poisoned| poisoned.into_inner()) } +/// Panes marked unread, not yet applied by the watcher. +fn unread_marks() -> MutexGuard<'static, HashSet> { + static MARKS: OnceLock>> = 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, Condvar) { static WAKE: OnceLock<(Mutex, 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, }, + /// 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, + }, /// 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) -> 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) -> 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::>()[..] { + [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, + /// 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() { -- cgit v1.2.3