diff options
| -rw-r--r-- | crates/dinggerd/src/snapshot.rs | 11 | ||||
| -rw-r--r-- | crates/dinggerd/src/tmux.rs | 28 | ||||
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 4 | ||||
| -rw-r--r-- | menubar/model.swift | 5 | ||||
| -rwxr-xr-x | tests/e2e.sh | 41 |
5 files changed, 70 insertions, 19 deletions
diff --git a/crates/dinggerd/src/snapshot.rs b/crates/dinggerd/src/snapshot.rs index d4ee321..715bf37 100644 --- a/crates/dinggerd/src/snapshot.rs +++ b/crates/dinggerd/src/snapshot.rs @@ -197,13 +197,22 @@ pub fn capture(now_ms: u64) -> Option<Snapshot> { "#{@agent_session}", "#{@dingger_cmd}", "#{pane_dead}", + "#{?session_grouped,#{session_group},}", ] .join("\t"); let out = tmux::run(&["list-panes", "-a", "-F", &format]).ok()?; let lines: Vec<Vec<&str>> = out .lines() .map(|line| line.split('\t').collect::<Vec<_>>()) - .filter(|f| f.len() >= 21) + .filter(|f| f.len() >= 22) + .collect(); + // One session per group: the others show the same windows, and saving + // them would restore every window, and resume every agent, twice. The + // first listed (tmux lists sessions by name) stands for its group. + let mut group_of_kept: std::collections::HashMap<&str, &str> = std::collections::HashMap::new(); + let lines: Vec<Vec<&str>> = lines + .into_iter() + .filter(|f| f[21].is_empty() || *group_of_kept.entry(f[21]).or_insert(f[0]) == f[0]) .collect(); // What runs in the foreground of each pane's terminal. let pane_pids: Vec<u32> = lines diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs index 182d62f..6610161 100644 --- a/crates/dinggerd/src/tmux.rs +++ b/crates/dinggerd/src/tmux.rs @@ -15,10 +15,10 @@ pub struct Pane { /// Working directory of the pane's foreground process. pub cwd: String, pub dead: bool, - /// The pane's window is the current window of its session. - pub window_active: bool, - /// The pane's session has at least one attached client. - pub session_attached: bool, + /// The user is looking at this pane's window: it is the current window + /// of a session with an attached client. herdr marks every pane of the + /// active tab seen (`mark_active_tab_seen`); a tmux window is a tab. + pub attended: bool, pub agent: Option<Agent>, /// Pane options written by dingger (see the watcher module docs). pub written_state: String, @@ -30,13 +30,6 @@ pub struct Pane { pub written_session: String, } -impl Pane { - /// The user is looking at this pane's window. herdr marks every pane of - /// the active tab seen (`mark_active_tab_seen`); a tmux window is a tab. - pub fn attended(&self) -> bool { - self.window_active && self.session_attached - } -} /// The tmux binary. launchd starts dingger, and the menu bar app that runs /// `dingger focus`, with PATH=/usr/bin:/bin:/usr/sbin:/sbin, where Homebrew's @@ -92,6 +85,9 @@ fn run_tmux(args: &[&str]) -> Result<String, String> { Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } +/// Every pane, once. A pane in several sessions (a session group, or a +/// linked window) keeps its first listing (tmux lists sessions by name), +/// and is attended if the user looks at it in any of them. pub fn list_panes() -> Result<Vec<Pane>, String> { let format = concat!( "#{pane_id}\t", @@ -115,13 +111,18 @@ pub fn list_panes() -> Result<Vec<Pane>, String> { "#{@agent_session}", ); let out = run_tmux(&["list-panes", "-a", "-F", format])?; - let mut panes = Vec::new(); + let mut panes: Vec<Pane> = Vec::new(); let mut process_tree = None; for line in out.lines() { let fields: Vec<&str> = line.split('\t').collect(); if fields.len() < 19 { continue; } + let attended = fields[4] == "1" && fields[5] != "0"; + if let Some(listed) = panes.iter_mut().find(|pane| pane.pane_id == fields[0]) { + listed.attended |= attended; + continue; + } let current_command = fields[1]; let start_command = fields[6]; let pane_pid: Option<u32> = fields[7].trim().parse().ok(); @@ -147,8 +148,7 @@ pub fn list_panes() -> Result<Vec<Pane>, String> { pane_title: fields[2].to_string(), cwd: fields[16].to_string(), dead: fields[3] == "1", - window_active: fields[4] == "1", - session_attached: fields[5] != "0", + attended, agent, written_state: fields[8].to_string(), written_agent: fields[9].to_string(), diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs index 8951623..9198f60 100644 --- a/crates/dinggerd/src/watcher.rs +++ b/crates/dinggerd/src/watcher.rs @@ -267,7 +267,7 @@ impl Watcher { // finished between two ticks is still a completion. let mut alerts = Vec::new(); for observed in observations { - let next = advance(track, observed, pane.attended(), acked, now); + let next = advance(track, observed, pane.attended, acked, now); if next.state != track.state { info!( target = %pane.display_target, @@ -338,7 +338,7 @@ impl Watcher { fn notify(&self, pane: &tmux::Pane, kind: NotificationKind) { // herdr: a blocked pane plays the alert even when the user is looking // at it; a finished pane stays silent when they already are. - let watching = pane.attended(); + let watching = pane.attended; if matches!(kind, NotificationKind::Finished) && watching { return; } diff --git a/menubar/model.swift b/menubar/model.swift index a17b009..8bed858 100644 --- a/menubar/model.swift +++ b/menubar/model.swift @@ -228,9 +228,12 @@ func fetchRows() -> [Row] { "#{session_name}", "#{window_name}", "#{window_panes}", "#{pane_index}", "#{pane_current_path}", ].joined(separator: "\t") guard let out = tmux(["list-panes", "-a", "-F", format]) else { return [] } + // A pane in several sessions (a session group, a linked window) is + // listed once, under its first session, as the daemon counts it. + var listed = Set<String>() let rows: [Row] = out.split(separator: "\n").compactMap { line in let f = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init) - guard f.count >= 10, !f[1].isEmpty else { return nil } + guard f.count >= 10, !f[1].isEmpty, listed.insert(f[0]).inserted else { return nil } let status = Status(rawValue: f[2]) ?? .unknown guard status != .unknown else { return nil } let window = (Int(f[7]) ?? 1) > 1 ? "\(f[6]).\(f[8])" : f[6] diff --git a/tests/e2e.sh b/tests/e2e.sh index 670f84a..75dbc31 100755 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -250,7 +250,10 @@ if [ -x "$MENUBAR" ]; then expect "menu title" "mb --print | head -1" "title: ×1 ✓1" 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 + # Wait for working: B1 was idle before, so idle alone would not show the turn. + hook "$B1" working + expect "B1 working" "opt $B1 @agent_state" 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;" @@ -618,6 +621,7 @@ expect "claude is detected" "r show -pv -t '=cc:talked' @agent 2>/dev/null; r sh claude" 80 expect "its hook's session is kept on its pane" "r show -pv -t $TALKED @agent_session 2>/dev/null" "$W/claude/talked.jsonl" 80 expect "snapshot: claude's session" "in_snapshot '\"session\": \"$W/claude/talked.jsonl\"'" True 80 +expect "snapshot: the command line of the claude without a transcript" "in_snapshot '\"command\": \"claude fresh\"'" True 80 r kill-server expect "a new restore candidate" \ "python3 -c 'import json,sys; print(\"cc\" in [s[\"name\"] for s in json.load(open(sys.argv[1]))[\"snapshot\"][\"sessions\"]])' $STATE2/restore.json 2>/dev/null" True 80 @@ -634,6 +638,41 @@ expect "the resumed claude reports its session again" \ "r show -pv -t '=cc:talked' @agent_session 2>/dev/null" "$W/claude/talked.jsonl" 80 [ "$FAILS" -gt 0 ] && { echo "--- restore-cc:"; cat "$DIR/restore-cc.out"; } +# --- 19. a session group: its panes once, its windows saved once ------------- +echo "19. session group" +start_viewer +r new-session -d -s grp -n agent -c "$W/one" +r new-window -d -t '=grp:' -n other -c "$W/two" +r new-session -d -t grp -s grp-2 +r send-keys -t '=grp:agent' 'pi' Enter +GA=$(r display -p -t '=grp:agent' '#{pane_id}') +expect "the group's agent is detected" "r show -pv -t $GA @agent 2>/dev/null" pi 80 +expect "dingger status lists it once" "renv \"$DINGGER\" status | grep -c ' grp'" 1 +expect "the menu lists it once" "mbr --print | grep -c 'grp'" 1 +# Watched only through grp-2: grp shows another window, nobody attaches it. +r select-window -t '=grp:other' +r select-window -t '=grp-2:agent' +r switch-client -c "$(r list-clients -F '#{client_name}' | head -1)" -t '=grp-2' +touch "$W/sessions/grp.jsonl" +# Wait for working: the agent was idle before, so idle alone would not show the turn. +hook2 "$GA" working "$W/sessions/grp.jsonl" +expect "the group's agent working" "r show -pv -t $GA @agent_state" working 80 +hook2 "$GA" idle "$W/sessions/grp.jsonl" +expect "a turn finished while watched through grp-2 is idle, not done" "r show -pv -t $GA @agent_state" idle 80 +expect "snapshot: the group is saved once" \ + "python3 -c 'import json,sys; print(sorted(s[\"name\"] for s in json.load(open(sys.argv[1]))[\"sessions\"] if s[\"name\"].startswith(\"grp\")))' \"\$(latest_snapshot)\"" \ + "['grp']" 80 +expect "snapshot: with the agent's session" "in_snapshot '\"session\": \"$W/sessions/grp.jsonl\"'" True 80 +r kill-server +expect "the candidate: the group once" \ + "python3 -c 'import json,sys; print(sorted(s[\"name\"] for s in json.load(open(sys.argv[1]))[\"snapshot\"][\"sessions\"] if s[\"name\"].startswith(\"grp\")))' $STATE2/restore.json 2>/dev/null" "['grp']" 80 +expect "dry run plans the group once" "renv \"$DINGGER\" restore --dry-run 2>/dev/null | grep -E '^[a-z]+ +grp' | cut -c1-14" "create grp: " +: >"$DIR/pi-args" +renv "$DINGGER" restore --session grp >"$DIR/restore-grp.out" 2>&1 +expect "restore: the agent is resumed once" "grep -c -- '--session $W/sessions/grp.jsonl' $DIR/pi-args" 1 +expect "with both windows" "r list-windows -t '=grp' -F '#{window_name}' | tr '\n' ' '" "agent other " +expect "no real agent runs in a test pane (group)" "real_agents" "" + # 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 |
