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). --- README.md | 14 ++++- 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 +++++++++++++++++++++++++++-- menubar/main.swift | 12 ++++- menubar/model.swift | 9 ++++ menubar/panel.swift | 59 +++++++++++++++++++-- menubar/search.swift | 40 ++++++++++++--- tests/e2e.sh | 59 +++++++++++++++++++++ tests/pi-live.sh | 11 +++- 11 files changed, 374 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 13a7b9c..c0a4c06 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,8 @@ than a poll is still a completion. - **Floating panel** (`menubar/panel.swift`): an always-on-top HUD that never takes focus, on every Space and over full-screen apps. It lists the agents waiting for you (blocked, done) and summarizes the rest - (`◐ 2 working · ○ 15 idle`); click a row to jump there, drag to move it. + (`◐ 2 working · ○ 15 idle`); click a row to jump there, drag to move it, + right-click an idle agent in the full list to mark it unread. **⌃⌥⌘E** (or "Show Panel" in the menu) turns it on or off; off stays off until you turn it back on. While on, "Panel Shows" picks *Only When Agents Are Waiting* (default: it hides itself while nothing waits; turning it on @@ -53,7 +54,8 @@ than a poll is still a completion. - **Agent search**: **⌃⌥⇧⌘U** opens a Spotlight-style search over every agent. Type to fuzzy-filter (each word must match, letters in order, the name, session, window, harness, status, or directory); ↑↓ or ⌃N ⌃P / ⌃J ⌃K - select, ↩ or a click jumps, esc closes, ⌃U clears. With nothing typed, + select, ↩ or a click jumps, esc closes, ⌃U clears, ⌘U (or a right-click) + marks the selected idle agent unread. With nothing typed, agents are in the menu's order, so ↩ jumps to the top waiting agent. It takes the keyboard without activating dingger, so focus returns where it was. @@ -106,6 +108,13 @@ than a poll is still a completion. run `dingger importance high|normal|low` in the agent's pane (in Claude Code: `! dingger importance high`), or pass `--pane`; it lasts until the agent exits. +- **Mark as unread**: like in mail, an idle agent (one you have seen) can be + made done again, to come back to later: right-click it in the panel's + full list, ⌘U in agent search, `/unread` in pi, or `dingger unread + [--pane]`. It goes last among done agents of its importance, with no + sound or notification, and stays done until you come back to it: you + leave its tmux window and return, or jump to it. Only idle agents can be + marked: a working, blocked, or done one is left alone. ## Where state comes from @@ -135,6 +144,7 @@ dingger watch # the daemon (run by launchd, see below) dingger status # agents in priority order dingger focus [--pane ID] # jump to an agent (default: the top waiter) dingger importance [LEVEL] # an agent's importance: high, normal, low +dingger unread [--pane ID] # mark an idle agent unread (done again) dingger install pi # install the pi state-reporting extension dingger install claude # report Claude Code sessions, for restore dingger install zsh # record typed command lines, for restore 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() { diff --git a/menubar/main.swift b/menubar/main.swift index 1b6ce4f..135c099 100644 --- a/menubar/main.swift +++ b/menubar/main.swift @@ -88,10 +88,12 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { item.menu = menu panel = PanelController( onClick: { [weak self] paneId in self?.jump(to: paneId) }, + onMarkUnread: { [weak self] paneId in self?.markUnread(paneId) }, onToggleFull: { [weak self] in self?.toggleFull() }, onRestore: { [weak self] in self?.restore(sessions: []) }, onDismissRestore: { [weak self] in self?.dismissRestore() }) - searchWindow = SearchController(onJump: { [weak self] paneId in self?.jump(to: paneId) }) + searchWindow = SearchController(onJump: { [weak self] paneId in self?.jump(to: paneId) }, + onMarkUnread: { [weak self] paneId in self?.markUnread(paneId) }) Settings.ensureFile() reloadSettings() refresh() @@ -258,6 +260,14 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { } } + private func markUnread(_ paneId: String) { + log("unread: \(paneId)") + DispatchQueue.global().async { + markAgentUnread(paneId) + DispatchQueue.main.async { self.refresh() } + } + } + // MARK: menu (rebuilt from fresh data every time it opens) func menuNeedsUpdate(_ menu: NSMenu) { diff --git a/menubar/model.swift b/menubar/model.swift index 760ff31..513f9fb 100644 --- a/menubar/model.swift +++ b/menubar/model.swift @@ -94,6 +94,8 @@ struct Row: Equatable { var location: String { "\(session) · \(window)" } var waiting: Bool { status == .blocked || status == .done } + /// Only an agent you have seen can be marked unread (done again). + var canMarkUnread: Bool { status == .idle } /// The row's title text: the agent's name, or its harness when unnamed. var label: String { @@ -286,6 +288,13 @@ func focus(_ paneId: String) { if !result.ok { log("jump: dingger focus --pane \(paneId) failed: \(result.output)") } } +/// Mark an idle agent unread: `dingger unread` returns once it shows as +/// done. +func markAgentUnread(_ paneId: String) { + let result = runReporting(dinggerBinary, ["unread", "--pane", paneId]) + log("unread: \(paneId) \(result.ok ? "marked" : "failed: \(result.output)")") +} + // MARK: rendering /// herdr-style summary: `×1 ✓3 ◐2` in priority order; `○` when every agent diff --git a/menubar/panel.swift b/menubar/panel.swift index 6619dc0..85cc81e 100644 --- a/menubar/panel.swift +++ b/menubar/panel.swift @@ -265,13 +265,37 @@ private func singleLine(_ label: NSTextField) -> NSTextField { return label } -/// One agent row: colored symbol and name, then a dim subtitle. +/// A menu item that runs a closure. +final class ActionMenuItem: NSMenuItem { + private let handler: () -> Void + + init(_ title: String, handler: @escaping () -> Void) { + self.handler = handler + super.init(title: title, action: #selector(run), keyEquivalent: "") + target = self + } + + required init(coder: NSCoder) { fatalError("not used") } + + @objc private func run() { handler() } +} + +/// One agent row: colored symbol and name, then a dim subtitle. Right-click +/// offers "Mark as unread" for an idle agent. final class PanelRowView: ClickView { private let selected: Bool + private let contextMenu: NSMenu? init(row: Row, title titleText: NSAttributedString? = nil, selected: Bool = false, - onClick: @escaping (String) -> Void) { + onClick: @escaping (String) -> Void, onMarkUnread: ((String) -> Void)? = nil) { self.selected = selected + if let onMarkUnread, row.canMarkUnread { + let menu = NSMenu() + menu.addItem(ActionMenuItem("Mark as unread") { onMarkUnread(row.paneId) }) + contextMenu = menu + } else { + contextMenu = nil + } super.init(frame: .zero) self.onClick = { onClick(row.paneId) } let title = singleLine(NSTextField( @@ -299,6 +323,8 @@ final class PanelRowView: ClickView { required init?(coder: NSCoder) { fatalError("not used") } + override func menu(for event: NSEvent) -> NSMenu? { contextMenu } + override func draw(_ dirtyRect: NSRect) { guard hovered || selected else { return } NSColor.white.withAlphaComponent(selected ? 0.14 : 0.08).setFill() @@ -372,14 +398,17 @@ final class PanelController { private let panel: NSPanel private let stack = NSStackView() private let onClick: (String) -> Void + private let onMarkUnread: (String) -> Void private let onToggleFull: () -> Void private let onRestore: () -> Void private let onDismissRestore: () -> Void private var rendered: String? - init(persistFrame: Bool = true, onClick: @escaping (String) -> Void, onToggleFull: @escaping () -> Void, + init(persistFrame: Bool = true, onClick: @escaping (String) -> Void, + onMarkUnread: @escaping (String) -> Void = { _ in }, onToggleFull: @escaping () -> Void, onRestore: @escaping () -> Void = {}, onDismissRestore: @escaping () -> Void = {}) { self.onClick = onClick + self.onMarkUnread = onMarkUnread self.onToggleFull = onToggleFull self.onRestore = onRestore self.onDismissRestore = onDismissRestore @@ -438,7 +467,7 @@ final class PanelController { } for (index, row) in content.rows.enumerated() { if index == content.separatorBefore { add(separatorLine()) } - add(PanelRowView(row: row, onClick: onClick), fullWidth: true) + add(PanelRowView(row: row, onClick: onClick, onMarkUnread: onMarkUnread), fullWidth: true) } if content.overflow > 0 { add(caption(NSAttributedString( @@ -582,6 +611,28 @@ func panelSelftest() -> [String] { let t0 = Date(timeIntervalSince1970: 1_000) func at(_ seconds: TimeInterval) -> Date { t0.addingTimeInterval(seconds) } + // Right-click: "Mark as unread" for idle agents only. + func agent(_ status: Status) -> Row { + Row(paneId: "%7", agent: "pi", name: "n", status: status, session: "s", window: "w", since: 1, path: "") + } + if let click = NSEvent.mouseEvent(with: .rightMouseDown, location: .zero, modifierFlags: [], timestamp: 0, + windowNumber: 0, context: nil, eventNumber: 0, clickCount: 1, pressure: 1) { + var marked: [String] = [] + let idleMenu = PanelRowView(row: agent(.idle), onClick: { _ in }, + onMarkUnread: { marked.append($0) }).menu(for: click) + check(idleMenu?.items.map(\.title) == ["Mark as unread"], "right-click: an idle agent offers Mark as unread") + if let item = idleMenu?.items.first, let target = item.target, let action = item.action { + _ = target.perform(action, with: item) + } + check(marked == ["%7"], "right-click: Mark as unread marks that agent") + for status in [Status.done, .working, .blocked] { + check(PanelRowView(row: agent(status), onClick: { _ in }, onMarkUnread: { _ in }).menu(for: click) == nil, + "right-click: nothing to offer for a \(status.rawValue) agent") + } + } else { + check(false, "right-click: could not make an event") + } + var panel = PanelVisibility() check(panel.enabled && panel.policy == .whenWaiting, "defaults: on, only when agents are waiting") check(!panel.visible(at: t0), "waiting policy: hidden with nothing waiting") diff --git a/menubar/search.swift b/menubar/search.swift index 2bc044a..c62c612 100644 --- a/menubar/search.swift +++ b/menubar/search.swift @@ -157,9 +157,12 @@ struct SearchState: Equatable { offset = max(0, min(offset, max(0, results.count - Self.visibleRows))) } + var selectedRow: Row? { results.first { $0.row.paneId == selected }?.row } + var footer: String { let count = results.count == rows.count ? "\(rows.count) agents" : "\(results.count) of \(rows.count)" - return "\(count) ↑↓ select ↩ jump esc close" + let unread = selectedRow?.canMarkUnread == true ? " ⌘U mark unread" : "" + return "\(count) ↑↓ select ↩ jump\(unread) esc close" } /// Printed by --print-search, and compared to skip needless re-renders. @@ -213,12 +216,14 @@ final class SearchController: NSObject, NSWindowDelegate, NSTextFieldDelegate { private let field = NSTextField() private let list = NSStackView() private let onJump: (String) -> Void + private let onMarkUnread: (String) -> Void private var state = SearchState(rows: []) private var rendered: String? private var keyMonitor: Any? - init(onJump: @escaping (String) -> Void) { + init(onJump: @escaping (String) -> Void, onMarkUnread: @escaping (String) -> Void = { _ in }) { self.onJump = onJump + self.onMarkUnread = onMarkUnread panel = KeyPanel(contentRect: NSRect(x: 0, y: 0, width: searchWidth, height: 80), styleMask: [.nonactivatingPanel, .borderless], backing: .buffered, defer: false) super.init() @@ -321,6 +326,14 @@ final class SearchController: NSObject, NSWindowDelegate, NSTextFieldDelegate { onJump(paneId) } + /// ⌘U: mark the selected agent unread; search stays open, and the agent + /// moves to the done ones as the list refreshes. + private func markSelectionUnread() { + guard let row = state.selectedRow, row.canMarkUnread else { NSSound.beep(); return } + log("search: mark \(row.paneId) unread") + onMarkUnread(row.paneId) + } + /// Debug: render `query` over `rows` offscreen to a PNG. func snapshot(rows: [Row], query: String, down: Int, to path: String) -> Bool { state = SearchState(rows: rows) @@ -335,12 +348,17 @@ final class SearchController: NSObject, NSWindowDelegate, NSTextFieldDelegate { // MARK: keys /// ⌃N ⌃P ⌃J ⌃K move, ⌃U clears: fzf's keys, before the text field's own - /// Emacs bindings see them. + /// Emacs bindings see them. ⌘U marks the selected agent unread. private func installKeyMonitor() { guard keyMonitor == nil else { return } keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - guard let self, event.window === self.panel, - event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .control else { return event } + guard let self, event.window === self.panel else { return event } + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if flags == .command, event.charactersIgnoringModifiers == "u" { + self.markSelectionUnread() + return nil + } + guard flags == .control else { return event } switch event.charactersIgnoringModifiers { case "n", "j": self.move(1) case "p", "k": self.move(-1) @@ -392,9 +410,9 @@ final class SearchController: NSObject, NSWindowDelegate, NSTextFieldDelegate { } for match in state.visible { let view = PanelRowView(row: match.row, title: highlightedTitle(match), - selected: match.row.paneId == state.selected) { [weak self] paneId in - self?.jump(to: paneId) - } + selected: match.row.paneId == state.selected, + onClick: { [weak self] paneId in self?.jump(to: paneId) }, + onMarkUnread: { [weak self] paneId in self?.onMarkUnread(paneId) }) list.addArrangedSubview(view) view.widthAnchor.constraint(equalTo: list.widthAnchor).isActive = true } @@ -488,5 +506,11 @@ func searchSelftest() -> [String] { scroll.move(-12) check(scroll.offset == 0, "state: and back to the top") check(scroll.footer.hasPrefix("25 agents"), "footer: agent count") + let mixed = SearchState(rows: [row("%1", "a", .idle), row("%2", "b", .done)]) + var onDone = mixed + onDone.move(1) + check(mixed.selectedRow?.paneId == "%1" && mixed.footer.contains("⌘U mark unread"), + "footer: ⌘U offered for an idle agent") + check(onDone.selectedRow?.paneId == "%2" && !onDone.footer.contains("⌘U"), "footer: not for a done one") return failures } diff --git a/tests/e2e.sh b/tests/e2e.sh index e51b18d..0da517a 100755 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -930,6 +930,65 @@ if command -v node >/dev/null; then expect "and leaves the footer" "grep -c '^status dingger-importance $' $EXTLOG" 1 fi +# --- 22. mark as unread ---------------------------------------------------- +# An idle agent marked unread is done again, last among done agents of its +# importance, until the user comes back to it. +echo "22. mark as unread" +t new-session -d -s unr -n u1 -c "$DIR" -x 120 -y 40 "$DIR/bin/pi" +t new-window -d -t unr: -n u2 -c "$DIR" "$DIR/bin/pi" +t new-window -d -t unr: -n u3 -c "$DIR" "$DIR/bin/pi" +t new-window -d -t unr: -n away -c "$DIR" "sleep 100000" +U1=$(t display -p -t unr:u1 '#{pane_id}'); U2=$(t display -p -t unr:u2 '#{pane_id}') +U3=$(t display -p -t unr:u3 '#{pane_id}'); AWAY=$(t display -p -t unr:away '#{pane_id}') +uqueue() { gopt @dingger_queue | tr ',' '\n' | grep -xE "$U1|$U2|$U3" | tr '\n' ' '; } +look() { t switch-client -c "$CLIENT" -t "$1"; $DINGGER poke; } +look "$AWAY" +report "$U3" "" low +for p in "$U3" "$U1"; do report "$p" working ""; report "$p" idle ""; expect "unr: $p done" "opt $p @agent_state" done; sleep 0.05; done +look "$U1" +expect "unr: looking at u1 sees it" "opt $U1 @agent_state" idle +look "$AWAY" +# u2 finishes after u1 was seen: by its old time, u1 would come first. +report "$U2" working ""; report "$U2" idle ""; expect "unr: $U2 done" "opt $U2 @agent_state" done +expect "unr: the queue without it" "uqueue" "$U2 $U3 " +look "$U1" +sleep 3.5; : >"$SOUNDS"; : >"$NOTES" +expect "dingger unread marks it" "$DINGGER unread --pane $U1" "marked unread" +expect "done again" "opt $U1 @agent_state" done +expect "last among done agents of its importance, before a low one" "uqueue" "$U2 $U1 $U3 " +expect "menu: shown as done" "mb --print | grep -F 'unr · u1' | cut -d' ' -f1" "✓" +$DINGGER poke; sleep 1; $DINGGER poke +expect "still looking at it: still done" "opt $U1 @agent_state" done +sleep 1 +expect "marking makes no sound" "cat $SOUNDS" "" +expect "and no notification" "cat $NOTES" "" +look "$AWAY" +expect "looking away: still done" "opt $U1 @agent_state" done 10 +look "$U1" +expect "coming back sees it" "opt $U1 @agent_state" idle +expect "unr: marked again" "$DINGGER unread --pane $U1" "marked unread" +$DINGGER focus --client "$CLIENT" --pane "$U1" +expect "a jump to it sees it" "opt $U1 @agent_state" idle +look "$AWAY" +expect "marked while away" "$DINGGER unread --pane $U1" "marked unread" +look "$U1" +expect "seen on arrival" "opt $U1 @agent_state" idle +expect "a done agent is refused" "$DINGGER unread --pane $U2 2>&1" "dingger: it is unread already (done)" +report "$U3" working ""; expect "unr: u3 working" "opt $U3 @agent_state" working +expect "a working agent is refused" "$DINGGER unread --pane $U3 2>&1" \ + "dingger: only an idle agent can be marked unread; this one is working" +expect "a pane without an agent is refused" "$DINGGER unread --pane $AWAY 2>&1" "dingger: no agent in pane $AWAY" +if command -v node >/dev/null; then + : >"$EXTLOG"; iext "$U1" session_start "/unread" + expect "/unread marks it" "opt $U1 @agent_state" done + expect "and says so" "grep -c '^notify marked unread: dingger shows it as done until you come back to it$' $EXTLOG" 1 + look "$AWAY"; look "$U1" + expect "unr: seen again" "opt $U1 @agent_state" idle + : >"$EXTLOG"; iext "$U1" session_start agent_start "/unread" + expect "/unread while working is refused" "grep -c '^notify only an idle agent can be marked unread$' $EXTLOG" 1 + expect "and changes nothing" "opt $U1 @agent_state" working +fi + # Every daemon this run started must stop with it. kill "$DAEMON_PID" "$DAEMON2_PID" 2>/dev/null expect "the test daemons stop" "ps -o pid= -p $DAEMON_PID,$DAEMON2_PID | wc -l | tr -d ' '" 0 diff --git a/tests/pi-live.sh b/tests/pi-live.sh index 3f4b4b8..9842343 100755 --- a/tests/pi-live.sh +++ b/tests/pi-live.sh @@ -7,7 +7,7 @@ # tests/pi-live.sh [dingger binary] # # Covers: identification, the extension's state and session reports, a turn -# (working, then done), /reload, an extension's blocked event, /importance, +# (working, then done), /reload, an extension's blocked event, /importance, /unread, # screen detection for a pi without the extension, restore (`pi --session`, # importance included), exit. set -u @@ -112,6 +112,15 @@ type_line agent "/importance high" expect "/importance high: the pane option" "t show -pv -t main:agent @dingger_importance 2>/dev/null" high 50 expect "and pi's footer (its last line)" "t capture-pane -p -t main:agent | grep -v '^ *$' | tail -1 | sed 's/^ *//'" "importance: high" 30 expect "saved in the session file" "grep -c '\"customType\":\"dingger-importance\"' \"\$(t show -pv -t main:agent @agent_session)\"" 1 30 +# Seen (what a jump does; no client is attached here), then marked unread. +t set -p -t main:agent @dingger_ack 1; "$DINGGER" poke +expect "seen: idle" "st agent" idle 50 +type_line agent "/unread" +expect "/unread: done again" "st agent" done 50 +# Not a turn (an unknown command would be sent to the model, and also end +# done): the daemon applied a mark, and pi said so. +expect "by a mark" "sed 's/\x1b\[[0-9;]*m//g' $D/daemon.log | grep -c 'mark unread target=main:agent applied=true'" 1 30 +expect "pi says so" "t capture-pane -p -t main:agent | grep -c 'marked unread: dingger shows it as done'" 1 30 echo "2. screen detection (pi without the extension)" t send-keys -t main:plain "PI_CODING_AGENT_DIR=$D/agent-plain pi $NO_MCP" Enter -- cgit v1.2.3