aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md14
-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
-rw-r--r--menubar/main.swift12
-rw-r--r--menubar/model.swift9
-rw-r--r--menubar/panel.swift59
-rw-r--r--menubar/search.swift40
-rwxr-xr-xtests/e2e.sh59
-rwxr-xr-xtests/pi-live.sh11
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<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() {
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 <pi binary> [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