Skip to main content

wsx_core/tmux/
monitor.rs

1// Bell/activity detection from tmux sessions.
2// ref: tmux(1) — list-windows, session_alerts, window_activity
3
4use super::tmux_cmd;
5use crate::model::workspace::ForegroundKind;
6use crate::proc_tree::{normalize_comm, ProcTree};
7use std::collections::HashMap;
8
9pub struct SessionStatus {
10    pub has_bell: bool,
11    pub last_activity_ts: u64, // Unix timestamp, 0 if unknown
12    pub foreground: ForegroundKind,
13    pub is_running_wsx: bool, // foreground process is wsx itself
14    pub wsx_muted: bool,      // @wsx-muted user option set on this session
15}
16
17fn is_shell(cmd: &str) -> bool {
18    matches!(
19        cmd.trim(),
20        "bash" | "zsh" | "sh" | "fish" | "csh" | "tcsh" | "ksh" | "dash" | "elvish"
21    )
22}
23
24// Pure output viewers — a live process, but classified PassiveViewer so they
25// never read as Active. Runtimes (node, bun, etc.) are classified separately.
26fn is_passive(cmd: &str) -> bool {
27    matches!(
28        cmd.trim(),
29        "watch" | "tail" | "less" | "more" | "man" | "top" | "htop" | "btop" | "bat"
30    )
31}
32
33fn is_agent(cmd: &str) -> bool {
34    matches!(
35        cmd.trim(),
36        "claude" | "codex" | "aider" | "opencode" | "gemini" | "qwen"
37    )
38}
39
40fn is_runtime(cmd: &str) -> bool {
41    matches!(
42        cmd.trim(),
43        "node"
44            | "bun"
45            | "deno"
46            | "npm"
47            | "pnpm"
48            | "yarn"
49            | "npx"
50            | "dotenvx"
51            | "watchexec"
52            | "entr"
53            | "reflex"
54    )
55}
56
57fn classify_foreground(cmd: &str) -> ForegroundKind {
58    if cmd.is_empty() {
59        ForegroundKind::Unknown
60    } else if is_shell(cmd) {
61        ForegroundKind::Shell
62    } else if is_passive(cmd) {
63        ForegroundKind::PassiveViewer
64    } else if is_agent(cmd) {
65        ForegroundKind::Agent
66    } else if is_runtime(cmd) {
67        ForegroundKind::Runtime
68    } else {
69        ForegroundKind::InteractiveApp
70    }
71}
72
73/// Pick the highest-ranked foreground from a process subtree rooted at
74/// `pane_pid`. This is what makes `claude` win over its node child (whose
75/// `comm` is the version number tmux's `pane_current_command` reports).
76/// Returns `None` if `pane_pid` is not present in the tree.
77fn classify_pane(pane_pid: u32, tree: &ProcTree) -> Option<ForegroundKind> {
78    let descendants = tree.descendants(pane_pid);
79    if descendants.is_empty() {
80        return None;
81    }
82    descendants
83        .into_iter()
84        .map(|(_, comm)| classify_foreground(normalize_comm(comm)))
85        .max_by_key(|k| foreground_rank(*k))
86}
87
88// Multi-window aggregation priority — the most significant foreground wins,
89// independent of window order.
90fn foreground_rank(kind: ForegroundKind) -> u8 {
91    match kind {
92        ForegroundKind::Unknown => 0,
93        ForegroundKind::Shell => 1,
94        ForegroundKind::PassiveViewer => 2,
95        ForegroundKind::Runtime => 3,
96        ForegroundKind::InteractiveApp => 4,
97        ForegroundKind::Agent => 5,
98    }
99}
100
101/// Single tmux call + one ps snapshot: returns bell, last activity timestamp,
102/// foreground classified by walking the process tree under each pane, and
103/// @wsx-muted per session.
104pub fn session_activity() -> HashMap<String, SessionStatus> {
105    let Ok(output) = tmux_cmd(&[
106        "list-windows",
107        "-a",
108        "-F",
109        "#{session_name}\t#{session_alerts}\t#{window_activity}\t#{pane_current_command}\t#{@wsx-muted}\t#{pane_pid}",
110    ])
111    .output() else {
112        return HashMap::new();
113    };
114
115    // One ps call shared across every pane. Agents nested under shells need
116    // the tree walk; `pane_current_command` reports the deepest spawned child
117    // (e.g. a node subprocess named "2.1.x") and hides the real foreground.
118    let tree = ProcTree::snapshot();
119
120    let mut result: HashMap<String, SessionStatus> = HashMap::new();
121    for line in String::from_utf8_lossy(&output.stdout).lines() {
122        let mut parts = line.splitn(6, '\t');
123        let Some(name) = parts.next() else { continue };
124        let Some(alerts) = parts.next() else { continue };
125        let Some(ts_str) = parts.next() else { continue };
126        let cmd = parts.next().unwrap_or("").trim();
127        let muted_str = parts.next().unwrap_or("").trim();
128        let pane_pid: Option<u32> = parts.next().and_then(|s| s.trim().parse().ok());
129        let name = name.trim().to_string();
130        let alerts = alerts.trim();
131        let has_bell = !alerts.is_empty() && alerts != "0";
132        let ts = ts_str.trim().parse::<u64>().unwrap_or(0);
133        let wsx_muted = muted_str == "1";
134        let entry = result.entry(name).or_insert(SessionStatus {
135            has_bell: false,
136            last_activity_ts: 0,
137            foreground: ForegroundKind::Unknown,
138            is_running_wsx: false,
139            wsx_muted,
140        });
141        entry.has_bell |= has_bell;
142        // @wsx-muted is a session option but tmux reports it per-window; OR across windows
143        // so any window with the flag set treats the whole session as muted.
144        entry.wsx_muted |= wsx_muted;
145        if ts > entry.last_activity_ts {
146            entry.last_activity_ts = ts;
147        }
148        // Tree classification first; fall back to pane_current_command only if
149        // the ps snapshot is empty or this pane's pid is missing from it.
150        let foreground = pane_pid
151            .and_then(|pid| classify_pane(pid, &tree))
152            .unwrap_or_else(|| classify_foreground(cmd));
153        if foreground_rank(foreground) > foreground_rank(entry.foreground) {
154            entry.foreground = foreground;
155        }
156        // wsx-self detection: scan the subtree, not just pane_current_command,
157        // so wrapped or nested invocations still suppress the preview.
158        let wsx_in_tree = pane_pid
159            .map(|pid| {
160                tree.descendants(pid)
161                    .iter()
162                    .any(|(_, c)| normalize_comm(c) == "wsx")
163            })
164            .unwrap_or(false);
165        if wsx_in_tree || cmd == "wsx" {
166            entry.is_running_wsx = true;
167        }
168    }
169    result
170}
171
172#[cfg(test)]
173mod tests {
174    use super::foreground_rank;
175    use crate::model::workspace::ForegroundKind;
176
177    // Strict ordering: Unknown < Shell < PassiveViewer < Runtime
178    //                          < InteractiveApp < Agent.
179    // Multi-window aggregation keeps the highest rank, so this ordering is what
180    // makes classification order-independent.
181    #[test]
182    fn rank_unknown_lt_shell() {
183        assert!(foreground_rank(ForegroundKind::Unknown) < foreground_rank(ForegroundKind::Shell));
184    }
185    #[test]
186    fn rank_shell_lt_passive_viewer() {
187        assert!(
188            foreground_rank(ForegroundKind::Shell) < foreground_rank(ForegroundKind::PassiveViewer)
189        );
190    }
191    #[test]
192    fn rank_passive_viewer_lt_runtime() {
193        assert!(
194            foreground_rank(ForegroundKind::PassiveViewer)
195                < foreground_rank(ForegroundKind::Runtime)
196        );
197    }
198    #[test]
199    fn rank_runtime_lt_interactive_app() {
200        assert!(
201            foreground_rank(ForegroundKind::Runtime)
202                < foreground_rank(ForegroundKind::InteractiveApp)
203        );
204    }
205    #[test]
206    fn rank_interactive_app_lt_agent() {
207        assert!(
208            foreground_rank(ForegroundKind::InteractiveApp)
209                < foreground_rank(ForegroundKind::Agent)
210        );
211    }
212    #[test]
213    fn rank_unknown_lt_agent() {
214        assert!(foreground_rank(ForegroundKind::Unknown) < foreground_rank(ForegroundKind::Agent));
215    }
216
217    // ── classify_pane (tree-walking foreground picker) ───────────────────────
218
219    use super::classify_pane;
220    use crate::proc_tree::ProcTree;
221
222    #[test]
223    fn given_single_zsh_node_when_classified_then_shell() {
224        let tree = ProcTree::from_rows(&[(100, 1, "zsh")]);
225        assert_eq!(classify_pane(100, &tree), Some(ForegroundKind::Shell));
226    }
227
228    #[test]
229    fn given_chain_zsh_claude_node_when_classified_then_agent_wins() {
230        // claude (Agent, rank 5) > node (Runtime, rank 3) > zsh (Shell, rank 1)
231        let tree =
232            ProcTree::from_rows(&[(100, 1, "zsh"), (200, 100, "claude"), (300, 200, "node")]);
233        assert_eq!(classify_pane(100, &tree), Some(ForegroundKind::Agent));
234    }
235
236    #[test]
237    fn given_zsh_with_node_child_when_classified_then_runtime() {
238        let tree = ProcTree::from_rows(&[(100, 1, "zsh"), (200, 100, "node")]);
239        assert_eq!(classify_pane(100, &tree), Some(ForegroundKind::Runtime));
240    }
241
242    #[test]
243    fn given_zsh_with_vim_child_when_classified_then_interactive_app() {
244        let tree = ProcTree::from_rows(&[(100, 1, "zsh"), (200, 100, "vim")]);
245        assert_eq!(
246            classify_pane(100, &tree),
247            Some(ForegroundKind::InteractiveApp)
248        );
249    }
250
251    #[test]
252    fn given_zsh_with_less_child_when_classified_then_passive_viewer() {
253        let tree = ProcTree::from_rows(&[(100, 1, "zsh"), (200, 100, "less")]);
254        assert_eq!(
255            classify_pane(100, &tree),
256            Some(ForegroundKind::PassiveViewer)
257        );
258    }
259
260    #[test]
261    fn given_zsh_with_claude_and_node_siblings_when_classified_then_agent_wins() {
262        let tree =
263            ProcTree::from_rows(&[(100, 1, "zsh"), (200, 100, "claude"), (300, 100, "node")]);
264        assert_eq!(classify_pane(100, &tree), Some(ForegroundKind::Agent));
265    }
266
267    #[test]
268    fn given_agent_under_runtime_parent_when_classified_then_agent_wins() {
269        // Parent is Runtime, child is Agent — max_by_key must walk the whole
270        // subtree, not just the root.
271        let tree =
272            ProcTree::from_rows(&[(100, 1, "zsh"), (200, 100, "node"), (300, 200, "claude")]);
273        assert_eq!(classify_pane(100, &tree), Some(ForegroundKind::Agent));
274    }
275
276    #[test]
277    fn given_mid_chain_pid_when_classified_then_only_subtree_seen() {
278        // From a non-root pid: only that subtree is visible, ancestors are not.
279        let tree =
280            ProcTree::from_rows(&[(100, 1, "claude"), (200, 100, "zsh"), (300, 200, "less")]);
281        // From 200, subtree is {zsh, less} → PassiveViewer wins over Shell.
282        assert_eq!(
283            classify_pane(200, &tree),
284            Some(ForegroundKind::PassiveViewer)
285        );
286    }
287
288    #[test]
289    fn given_pane_pid_absent_when_classified_then_none() {
290        let tree = ProcTree::from_rows(&[(100, 1, "zsh")]);
291        assert_eq!(classify_pane(999, &tree), None);
292    }
293
294    #[test]
295    fn given_empty_tree_when_classified_then_none() {
296        let tree = ProcTree::from_rows(&[]);
297        assert_eq!(classify_pane(100, &tree), None);
298    }
299
300    #[test]
301    fn given_normalized_path_and_dash_shells_when_classified_then_shell() {
302        let path_tree = ProcTree::from_rows(&[(100, 1, "/bin/zsh")]);
303        assert_eq!(classify_pane(100, &path_tree), Some(ForegroundKind::Shell));
304        let dash_tree = ProcTree::from_rows(&[(101, 1, "-zsh")]);
305        assert_eq!(classify_pane(101, &dash_tree), Some(ForegroundKind::Shell));
306    }
307
308    #[test]
309    fn given_root_pid_is_agent_alone_when_classified_then_agent() {
310        let tree = ProcTree::from_rows(&[(100, 1, "claude")]);
311        assert_eq!(classify_pane(100, &tree), Some(ForegroundKind::Agent));
312    }
313
314    #[test]
315    fn given_unknown_comm_when_classified_then_interactive_app() {
316        // Name not in any matcher → InteractiveApp. Catches a regression where
317        // unknown names start returning Unknown and silently demote real apps.
318        let tree = ProcTree::from_rows(&[(100, 1, "weirdproc-2.1.x")]);
319        assert_eq!(
320            classify_pane(100, &tree),
321            Some(ForegroundKind::InteractiveApp)
322        );
323    }
324
325    #[test]
326    fn given_empty_comm_row_when_classified_then_unknown_rank_minimum() {
327        // from_rows allows comm=""; normalize_comm("") → "" → Unknown.
328        let tree = ProcTree::from_rows(&[(100, 1, "")]);
329        assert_eq!(classify_pane(100, &tree), Some(ForegroundKind::Unknown));
330    }
331}