use super::tmux_cmd;
use crate::model::workspace::ForegroundKind;
use crate::proc_tree::{normalize_comm, ProcTree};
use std::collections::HashMap;
pub struct SessionStatus {
pub has_bell: bool,
pub last_activity_ts: u64, pub foreground: ForegroundKind,
pub is_running_wsx: bool, pub wsx_muted: bool, }
fn is_shell(cmd: &str) -> bool {
matches!(
cmd.trim(),
"bash" | "zsh" | "sh" | "fish" | "csh" | "tcsh" | "ksh" | "dash" | "elvish"
)
}
fn is_passive(cmd: &str) -> bool {
matches!(
cmd.trim(),
"watch" | "tail" | "less" | "more" | "man" | "top" | "htop" | "btop" | "bat"
)
}
fn is_agent(cmd: &str) -> bool {
matches!(
cmd.trim(),
"claude" | "codex" | "aider" | "opencode" | "gemini" | "qwen"
)
}
fn is_runtime(cmd: &str) -> bool {
matches!(
cmd.trim(),
"node"
| "bun"
| "deno"
| "npm"
| "pnpm"
| "yarn"
| "npx"
| "dotenvx"
| "watchexec"
| "entr"
| "reflex"
)
}
fn classify_foreground(cmd: &str) -> ForegroundKind {
if cmd.is_empty() {
ForegroundKind::Unknown
} else if is_shell(cmd) {
ForegroundKind::Shell
} else if is_passive(cmd) {
ForegroundKind::PassiveViewer
} else if is_agent(cmd) {
ForegroundKind::Agent
} else if is_runtime(cmd) {
ForegroundKind::Runtime
} else {
ForegroundKind::InteractiveApp
}
}
fn classify_pane(pane_pid: u32, tree: &ProcTree) -> Option<ForegroundKind> {
let descendants = tree.descendants(pane_pid);
if descendants.is_empty() {
return None;
}
descendants
.into_iter()
.map(|(_, comm)| classify_foreground(normalize_comm(comm)))
.max_by_key(|k| foreground_rank(*k))
}
fn foreground_rank(kind: ForegroundKind) -> u8 {
match kind {
ForegroundKind::Unknown => 0,
ForegroundKind::Shell => 1,
ForegroundKind::PassiveViewer => 2,
ForegroundKind::Runtime => 3,
ForegroundKind::InteractiveApp => 4,
ForegroundKind::Agent => 5,
}
}
pub fn session_activity() -> HashMap<String, SessionStatus> {
let Ok(output) = tmux_cmd(&[
"list-windows",
"-a",
"-F",
"#{session_name}\t#{session_alerts}\t#{window_activity}\t#{pane_current_command}\t#{@wsx-muted}\t#{pane_pid}",
])
.output() else {
return HashMap::new();
};
let tree = ProcTree::snapshot();
let mut result: HashMap<String, SessionStatus> = HashMap::new();
for line in String::from_utf8_lossy(&output.stdout).lines() {
let mut parts = line.splitn(6, '\t');
let Some(name) = parts.next() else { continue };
let Some(alerts) = parts.next() else { continue };
let Some(ts_str) = parts.next() else { continue };
let cmd = parts.next().unwrap_or("").trim();
let muted_str = parts.next().unwrap_or("").trim();
let pane_pid: Option<u32> = parts.next().and_then(|s| s.trim().parse().ok());
let name = name.trim().to_string();
let alerts = alerts.trim();
let has_bell = !alerts.is_empty() && alerts != "0";
let ts = ts_str.trim().parse::<u64>().unwrap_or(0);
let wsx_muted = muted_str == "1";
let entry = result.entry(name).or_insert(SessionStatus {
has_bell: false,
last_activity_ts: 0,
foreground: ForegroundKind::Unknown,
is_running_wsx: false,
wsx_muted,
});
entry.has_bell |= has_bell;
entry.wsx_muted |= wsx_muted;
if ts > entry.last_activity_ts {
entry.last_activity_ts = ts;
}
let foreground = pane_pid
.and_then(|pid| classify_pane(pid, &tree))
.unwrap_or_else(|| classify_foreground(cmd));
if foreground_rank(foreground) > foreground_rank(entry.foreground) {
entry.foreground = foreground;
}
let wsx_in_tree = pane_pid
.map(|pid| {
tree.descendants(pid)
.iter()
.any(|(_, c)| normalize_comm(c) == "wsx")
})
.unwrap_or(false);
if wsx_in_tree || cmd == "wsx" {
entry.is_running_wsx = true;
}
}
result
}
#[cfg(test)]
mod tests {
use super::foreground_rank;
use crate::model::workspace::ForegroundKind;
#[test]
fn rank_unknown_lt_shell() {
assert!(foreground_rank(ForegroundKind::Unknown) < foreground_rank(ForegroundKind::Shell));
}
#[test]
fn rank_shell_lt_passive_viewer() {
assert!(
foreground_rank(ForegroundKind::Shell) < foreground_rank(ForegroundKind::PassiveViewer)
);
}
#[test]
fn rank_passive_viewer_lt_runtime() {
assert!(
foreground_rank(ForegroundKind::PassiveViewer)
< foreground_rank(ForegroundKind::Runtime)
);
}
#[test]
fn rank_runtime_lt_interactive_app() {
assert!(
foreground_rank(ForegroundKind::Runtime)
< foreground_rank(ForegroundKind::InteractiveApp)
);
}
#[test]
fn rank_interactive_app_lt_agent() {
assert!(
foreground_rank(ForegroundKind::InteractiveApp)
< foreground_rank(ForegroundKind::Agent)
);
}
#[test]
fn rank_unknown_lt_agent() {
assert!(foreground_rank(ForegroundKind::Unknown) < foreground_rank(ForegroundKind::Agent));
}
use super::classify_pane;
use crate::proc_tree::ProcTree;
#[test]
fn given_single_zsh_node_when_classified_then_shell() {
let tree = ProcTree::from_rows(&[(100, 1, "zsh")]);
assert_eq!(classify_pane(100, &tree), Some(ForegroundKind::Shell));
}
#[test]
fn given_chain_zsh_claude_node_when_classified_then_agent_wins() {
let tree =
ProcTree::from_rows(&[(100, 1, "zsh"), (200, 100, "claude"), (300, 200, "node")]);
assert_eq!(classify_pane(100, &tree), Some(ForegroundKind::Agent));
}
#[test]
fn given_zsh_with_node_child_when_classified_then_runtime() {
let tree = ProcTree::from_rows(&[(100, 1, "zsh"), (200, 100, "node")]);
assert_eq!(classify_pane(100, &tree), Some(ForegroundKind::Runtime));
}
#[test]
fn given_zsh_with_vim_child_when_classified_then_interactive_app() {
let tree = ProcTree::from_rows(&[(100, 1, "zsh"), (200, 100, "vim")]);
assert_eq!(
classify_pane(100, &tree),
Some(ForegroundKind::InteractiveApp)
);
}
#[test]
fn given_zsh_with_less_child_when_classified_then_passive_viewer() {
let tree = ProcTree::from_rows(&[(100, 1, "zsh"), (200, 100, "less")]);
assert_eq!(
classify_pane(100, &tree),
Some(ForegroundKind::PassiveViewer)
);
}
#[test]
fn given_zsh_with_claude_and_node_siblings_when_classified_then_agent_wins() {
let tree =
ProcTree::from_rows(&[(100, 1, "zsh"), (200, 100, "claude"), (300, 100, "node")]);
assert_eq!(classify_pane(100, &tree), Some(ForegroundKind::Agent));
}
#[test]
fn given_agent_under_runtime_parent_when_classified_then_agent_wins() {
let tree =
ProcTree::from_rows(&[(100, 1, "zsh"), (200, 100, "node"), (300, 200, "claude")]);
assert_eq!(classify_pane(100, &tree), Some(ForegroundKind::Agent));
}
#[test]
fn given_mid_chain_pid_when_classified_then_only_subtree_seen() {
let tree =
ProcTree::from_rows(&[(100, 1, "claude"), (200, 100, "zsh"), (300, 200, "less")]);
assert_eq!(
classify_pane(200, &tree),
Some(ForegroundKind::PassiveViewer)
);
}
#[test]
fn given_pane_pid_absent_when_classified_then_none() {
let tree = ProcTree::from_rows(&[(100, 1, "zsh")]);
assert_eq!(classify_pane(999, &tree), None);
}
#[test]
fn given_empty_tree_when_classified_then_none() {
let tree = ProcTree::from_rows(&[]);
assert_eq!(classify_pane(100, &tree), None);
}
#[test]
fn given_normalized_path_and_dash_shells_when_classified_then_shell() {
let path_tree = ProcTree::from_rows(&[(100, 1, "/bin/zsh")]);
assert_eq!(classify_pane(100, &path_tree), Some(ForegroundKind::Shell));
let dash_tree = ProcTree::from_rows(&[(101, 1, "-zsh")]);
assert_eq!(classify_pane(101, &dash_tree), Some(ForegroundKind::Shell));
}
#[test]
fn given_root_pid_is_agent_alone_when_classified_then_agent() {
let tree = ProcTree::from_rows(&[(100, 1, "claude")]);
assert_eq!(classify_pane(100, &tree), Some(ForegroundKind::Agent));
}
#[test]
fn given_unknown_comm_when_classified_then_interactive_app() {
let tree = ProcTree::from_rows(&[(100, 1, "weirdproc-2.1.x")]);
assert_eq!(
classify_pane(100, &tree),
Some(ForegroundKind::InteractiveApp)
);
}
#[test]
fn given_empty_comm_row_when_classified_then_unknown_rank_minimum() {
let tree = ProcTree::from_rows(&[(100, 1, "")]);
assert_eq!(classify_pane(100, &tree), Some(ForegroundKind::Unknown));
}
}