diff options
| -rw-r--r-- | crates/dinggerd/src/main.rs | 3 | ||||
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 83 | ||||
| -rw-r--r-- | menubar/main.swift | 9 | ||||
| -rw-r--r-- | menubar/model.swift | 19 | ||||
| -rwxr-xr-x | tests/e2e.sh | 10 |
5 files changed, 87 insertions, 37 deletions
diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs index 9606d5b..2783899 100644 --- a/crates/dinggerd/src/main.rs +++ b/crates/dinggerd/src/main.rs @@ -253,8 +253,7 @@ fn status() -> Result<(), String> { } panes.sort_by_key(|pane| { let status = watcher::Status::from_label(&pane.written_state); - let since = pane.written_since.parse::<u64>().unwrap_or(u64::MAX); - (std::cmp::Reverse(status.priority()), since, pane.display_target.clone()) + (watcher::list_order(status, pane.written_since.parse().ok()), pane.display_target.clone()) }); let place = |pane: &tmux::Pane| pane.display_target.replacen(':', " · ", 1); let width = panes.iter().map(|pane| place(pane).chars().count()).max().unwrap_or(5).max(5); diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs index 346bfa8..8951623 100644 --- a/crates/dinggerd/src/watcher.rs +++ b/crates/dinggerd/src/watcher.rs @@ -20,7 +20,7 @@ //! up exactly where it left off: //! //! - `@agent_state` display status (what every surface renders) -//! - `@dingger_since` unix ms the pane started needing the user (FIFO) +//! - `@dingger_since` unix ms the pane entered its current status //! - `@dingger_hook` last state an agent extension reported //! - `@dingger_ack` one-shot "the user jumped here" event from `dingger focus` //! - `@agent_session` the agent's session reference, for restore @@ -100,8 +100,9 @@ pub fn notification_kind_for_transition( pub struct PaneTrack { pub state: AgentState, pub seen: bool, - /// Unix millisecond the pane started needing the user (done or blocked); - /// orders the FIFO queue within a status. + /// Unix millisecond the pane entered its current status: the queue + /// serves done and blocked oldest first; working and idle are listed + /// most recent first. pub since: Option<u64>, } @@ -133,12 +134,7 @@ impl PaneTrack { "blocked" => (AgentState::Blocked, true), _ => return PaneTrack::NEW, }; - let track = PaneTrack { state, seen, since }; - let needs_user = matches!(track.status(), Status::Done | Status::Blocked); - PaneTrack { - since: if needs_user { since } else { None }, - ..track - } + PaneTrack { state, seen, since } } } @@ -165,20 +161,28 @@ pub fn advance( } else if matches!(prev.state, AgentState::Working | AgentState::Blocked) { next.seen = attended; } - next.since = None; // a new episode } if attended || acked { next.seen = true; } - let needs_user = matches!(next.status(), Status::Done | Status::Blocked); - next.since = match (needs_user, next.since) { - (true, Some(since)) => Some(since), - (true, None) => Some(now), - (false, _) => None, - }; + if next.status() != prev.status() || next.since.is_none() { + next.since = Some(now); + } next } +/// The order agents are listed in: herdr's status priority; within a +/// status, done and blocked oldest first (the queue: nothing waits +/// forever), working and idle most recent first; then by place. +pub fn list_order(status: Status, since: Option<u64>) -> (std::cmp::Reverse<u8>, i128) { + let since = since.map(i128::from); + let key = match status { + Status::Done | Status::Blocked => since.unwrap_or(i128::MAX), + _ => since.map(|since| -since).unwrap_or(i128::MAX), + }; + (std::cmp::Reverse(status.priority()), key) +} + pub struct Watcher { tracks: HashMap<String, PaneTrack>, snapshots: crate::snapshot::Recorder, @@ -363,7 +367,9 @@ impl Watcher { Status::Working => working += 1, Status::Idle | Status::Unknown => {} } - if let Some(since) = track.since { + // The queue: what needs the user, oldest first. + if matches!(track.status(), Status::Blocked | Status::Done) { + let since = track.since.unwrap_or(u64::MAX); queue.push((std::cmp::Reverse(track.status().priority()), since, pane.pane_id.as_str())); } } @@ -498,7 +504,7 @@ mod tests { fn first_observation_of_idle_is_idle() { let track = step(PaneTrack::NEW, Idle, false); assert_eq!(track.status(), Status::Idle); - assert_eq!(track.since, None); + assert_eq!(track.since, Some(100)); } // herdr: state_changed_idle_in_background_marks_unseen @@ -515,9 +521,9 @@ mod tests { #[test] fn completion_while_looking_is_idle() { let working = step(PaneTrack::NEW, Working, true); - let idle = step(working, Idle, true); + let idle = advance(working, Idle, true, false, 150); assert_eq!(idle.status(), Status::Idle); - assert_eq!(idle.since, None); + assert_eq!(idle.since, Some(150), "idle since the turn ended"); } // herdr: switch_workspace_marks_panes_seen (mark_active_tab_seen) @@ -527,9 +533,9 @@ mod tests { assert_eq!(done.status(), Status::Done); let still_done = step(done, Idle, false); assert_eq!(still_done.status(), Status::Done); - let seen = step(still_done, Idle, true); + let seen = advance(still_done, Idle, true, false, 170); assert_eq!(seen.status(), Status::Idle); - assert_eq!(seen.since, None); + assert_eq!(seen.since, Some(170), "idle since the user looked"); } // herdr: agent_focus_marks_already_focused_done_agent_seen @@ -543,9 +549,9 @@ mod tests { #[test] fn working_again_clears_done_and_the_next_completion_is_done_again() { let done = step(step(PaneTrack::NEW, Working, false), Idle, false); - let working = step(done, Working, false); + let working = advance(done, Working, false, false, 250); assert_eq!(working.status(), Status::Working); - assert_eq!(working.since, None); + assert_eq!(working.since, Some(250)); let done_again = advance(working, Idle, false, false, 300); assert_eq!(done_again.status(), Status::Done); assert_eq!(done_again.since, Some(300)); @@ -562,10 +568,31 @@ mod tests { } #[test] - fn fifo_stamp_survives_unchanged_ticks() { + fn the_stamp_survives_unchanged_ticks() { let done = advance(step(PaneTrack::NEW, Working, false), Idle, false, false, 50); - let later = advance(done, Idle, false, false, 999); - assert_eq!(later.since, Some(50)); + assert_eq!(advance(done, Idle, false, false, 999).since, Some(50)); + let idle = advance(done, Idle, true, false, 60); + assert_eq!(advance(idle, Idle, true, false, 999).since, Some(60)); + } + + #[test] + fn waiting_oldest_first_the_rest_most_recent_first() { + let mut rows = [ + ("idle old", Status::Idle, Some(10)), + ("done new", Status::Done, Some(90)), + ("idle unknown", Status::Idle, None), + ("working old", Status::Working, Some(20)), + ("idle new", Status::Idle, Some(80)), + ("done old", Status::Done, Some(30)), + ("blocked", Status::Blocked, Some(99)), + ("working new", Status::Working, Some(70)), + ]; + rows.sort_by_key(|(_, status, since)| list_order(*status, *since)); + let names: Vec<_> = rows.iter().map(|(name, ..)| *name).collect(); + assert_eq!( + names, + ["blocked", "done old", "done new", "working new", "working old", "idle new", "idle old", "idle unknown"] + ); } #[test] @@ -583,7 +610,7 @@ mod tests { assert_eq!(step(done, Idle, false).status(), Status::Done); assert_eq!(PaneTrack::restore("idle", "").status(), Status::Idle); assert_eq!(PaneTrack::restore("", "").status(), Status::Unknown); - assert_eq!(PaneTrack::restore("idle", "42").since, None); + assert_eq!(PaneTrack::restore("idle", "42").since, Some(42), "recency survives a restart"); } #[test] diff --git a/menubar/main.swift b/menubar/main.swift index 0d367b8..17b9fcc 100644 --- a/menubar/main.swift +++ b/menubar/main.swift @@ -388,6 +388,15 @@ if arguments.contains("--selftest") { if Hotkey.parse("cmd+shift+f12")?.display != "⇧⌘F12" { failures.append("hotkey: parse cmd+shift+f12") } if Hotkey.parse(Settings().jumpHotkey)?.display != "⌃⌥⌘U" { failures.append("hotkey: default jump is ⌃⌥⌘U") } if Hotkey.parse(Settings().searchHotkey)?.display != "⌃⌥⇧⌘U" { failures.append("hotkey: default search is ⌃⌥⇧⌘U") } + let order: [(String, Status, Int)] = [ + ("idle old", .idle, 10), ("done new", .done, 90), ("idle unknown", .idle, Int.max), + ("working old", .working, 20), ("idle new", .idle, 80), ("done old", .done, 30), + ("blocked", .blocked, 99), ("working new", .working, 70), + ] + let listed = order.map { Row(paneId: $0.0, agent: "pi", name: $0.0, status: $0.1, session: "s", window: "w", + since: $0.2, path: "~") }.sorted(by: listedBefore).map(\.name) + let expected = ["blocked", "done old", "done new", "working new", "working old", "idle new", "idle old", "idle unknown"] + if listed != expected { failures.append("order: waiting oldest first, the rest most recent first: \(listed)") } failures.forEach { print("FAIL \($0)") } print(failures.isEmpty ? "selftest ok" : "selftest: \(failures.count) failed") exit(failures.isEmpty ? 0 : 1) diff --git a/menubar/model.swift b/menubar/model.swift index da5ad15..a17b009 100644 --- a/menubar/model.swift +++ b/menubar/model.swift @@ -219,7 +219,9 @@ func runDinggerInLoginShell(_ args: [String]) -> (ok: Bool, output: String) { private let home = NSHomeDirectory() -/// Every agent pane, in herdr's priority order, oldest waiting first. +/// Every agent pane: herdr's status priority; within a status, done and +/// blocked oldest first (the queue), working and idle most recent first +/// (`@dingger_since`: when the pane entered its status); then by place. func fetchRows() -> [Row] { let format = [ "#{pane_id}", "#{@agent}", "#{@agent_state}", "#{@dingger_since}", "#{@agent_name}", @@ -236,11 +238,18 @@ func fetchRows() -> [Row] { session: f[5], window: window, since: Int(f[3]) ?? Int.max, path: f[9].hasPrefix(home) ? "~" + f[9].dropFirst(home.count) : f[9]) } - return rows.sorted { - if $0.status.priority != $1.status.priority { return $0.status.priority > $1.status.priority } - if $0.since != $1.since { return $0.since < $1.since } - return $0.location < $1.location + return rows.sorted(by: listedBefore) +} + +/// The list order (see `fetchRows`). +func listedBefore(_ a: Row, _ b: Row) -> Bool { + if a.status.priority != b.status.priority { return a.status.priority > b.status.priority } + if a.since != b.since { + // Int.max: never stamped; last either way. + if a.since == Int.max || b.since == Int.max { return a.since < b.since } + return a.waiting ? a.since < b.since : a.since > b.since } + return a.location < b.location } /// Show the pane and mark it attended; the same code path as the tmux diff --git a/tests/e2e.sh b/tests/e2e.sh index c4bcabb..670f84a 100755 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -248,7 +248,11 @@ if [ -x "$MENUBAR" ]; then hook "$A1" working; hook "$A1" idle expect "A1 done" "opt $A1 @agent_state" done expect "menu title" "mb --print | head -1" "title: ×1 ✓1" - expect "menu rows: herdr priority, names as titles, harness when unnamed" \ + expect "menu rows: herdr priority; idle most recent first (C1 since its jump in step 4, B1 since the start)" \ + "mb --print | tail -n +2 | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|pi;○ idle|Review PR 42;" + hook "$B1" working; hook "$B1" idle + expect "B1 finishes a turn while watched: idle" "opt $B1 @agent_state" idle + expect "and is now the most recent idle agent: listed first" \ "mb --print | tail -n +2 | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|Review PR 42;○ idle|pi;" expect "subtitle: location, harness, waiting time" "mb --print | grep 'Deploy the widget' | cut -f3" "a · wa1 · pi · done now" expect "idle subtitle has no waiting time" "mb --print | grep 'Review PR 42' | cut -f3" "b · wb1 · pi" @@ -302,9 +306,11 @@ if [ -x "$MENUBAR" ]; then DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus --client "$CLIENT" --pane "$A2" expect "A2 idle after the jump" "opt $A2 @agent_state" idle expect "panel with nothing waiting" "mb --print-panel" "Nothing waiting · ○ 4 idle" + # Most recent first: A2 (just jumped to), A1 (clicked in step 11), B1 (finished + # in step 11, before that click), C1. expect "full list: every agent, then the summary with the toggle" \ "mb --print-panel --full | cut -f1,2 | tr '\t\n' '|;'" \ - "○ idle|Deploy the widget;○ idle|pi;○ idle|Review PR 42;○ idle|pi;Nothing waiting · ○ 4 idle|[Show less ▴];" + "○ idle|pi;○ idle|Deploy the widget;○ idle|Review PR 42;○ idle|pi;Nothing waiting · ○ 4 idle|[Show less ▴];" expect "compact in Always mode offers 'Show all'" "mb --print-panel --toggle" "Nothing waiting · ○ 4 idle [Show all ▾]" hook "$A1" working; hook "$A1" idle expect "A1 done" "opt $A1 @agent_state" done |
