1use 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, pub foreground: ForegroundKind,
13 pub is_running_wsx: bool, pub wsx_muted: bool, }
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
24fn 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
73fn 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
88fn 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
101pub 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 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 entry.wsx_muted |= wsx_muted;
145 if ts > entry.last_activity_ts {
146 entry.last_activity_ts = ts;
147 }
148 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 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 #[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 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 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 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 let tree =
280 ProcTree::from_rows(&[(100, 1, "claude"), (200, 100, "zsh"), (300, 200, "less")]);
281 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 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 let tree = ProcTree::from_rows(&[(100, 1, "")]);
329 assert_eq!(classify_pane(100, &tree), Some(ForegroundKind::Unknown));
330 }
331}