aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--crates/dinggerd/src/main.rs3
-rw-r--r--crates/dinggerd/src/watcher.rs83
-rw-r--r--menubar/main.swift9
-rw-r--r--menubar/model.swift19
-rwxr-xr-xtests/e2e.sh10
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