aboutsummaryrefslogtreecommitdiff
path: root/crates
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
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')
-rw-r--r--crates/dinggerd/assets/pi/pi-agent-state.ts39
-rw-r--r--crates/dinggerd/src/hooks.rs27
-rw-r--r--crates/dinggerd/src/main.rs51
-rw-r--r--crates/dinggerd/src/watcher.rs79
4 files changed, 186 insertions, 10 deletions
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<boolea
});
}
-async function sendRequest(request: unknown): Promise<void> {
+// Whether the daemon got it.
+async function sendRequest(request: unknown): Promise<boolean> {
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<void> {
- 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<void> {
+ 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<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() {