use ratatui::{
style::{Color, Style},
text::Span,
};
use std::collections::HashMap;
use std::fs;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum PaneStatus {
Unknown,
Idle,
Active,
Error,
Waiting,
}
pub struct PaneMonitor {
pane_statuses: HashMap<String, PaneStatus>,
session_statuses: HashMap<String, PaneStatus>,
window_statuses: HashMap<(String, u32), PaneStatus>,
}
impl PaneMonitor {
pub fn new() -> Self {
Self {
pane_statuses: HashMap::new(),
session_statuses: HashMap::new(),
window_statuses: HashMap::new(),
}
}
pub fn set(&mut self, pane_id: &str, session_name: &str, window_index: u32, status: PaneStatus) {
self.pane_statuses.insert(pane_id.to_string(), status);
let session_entry = self.session_statuses
.entry(session_name.to_string())
.or_insert(PaneStatus::Unknown);
*session_entry = (*session_entry).max(status);
let window_entry = self.window_statuses
.entry((session_name.to_string(), window_index))
.or_insert(PaneStatus::Unknown);
*window_entry = (*window_entry).max(status);
}
pub fn begin_update(&mut self) {
self.pane_statuses.clear();
self.session_statuses.clear();
self.window_statuses.clear();
}
pub fn get_pane(&self, pane_id: &str) -> PaneStatus {
self.pane_statuses.get(pane_id).copied().unwrap_or(PaneStatus::Unknown)
}
pub fn get_session(&self, session_name: &str) -> PaneStatus {
self.session_statuses.get(session_name).copied().unwrap_or(PaneStatus::Unknown)
}
pub fn get_window(&self, session_name: &str, window_index: u32) -> PaneStatus {
self.window_statuses
.get(&(session_name.to_string(), window_index))
.copied()
.unwrap_or(PaneStatus::Unknown)
}
pub fn clear(&mut self) {
self.begin_update();
}
}
pub const HOOK_STATUS_DIR: &str = "/tmp/tango-status";
const HOOK_STALENESS: Duration = Duration::from_secs(60);
pub fn classify(command: &str, content: Option<&str>, pane_id: &str) -> PaneStatus {
if let Some(text) = content {
if has_waiting_pattern(text) {
return PaneStatus::Waiting;
}
if has_error_pattern(text) {
return PaneStatus::Error;
}
}
if let Some(status) = read_hook_status(pane_id, command) {
return status;
}
if is_claude(command) {
return classify_claude_heuristic(content);
}
if is_shell(command) {
PaneStatus::Idle
} else {
PaneStatus::Active
}
}
fn classify_claude_heuristic(content: Option<&str>) -> PaneStatus {
if let Some(text) = content {
if has_claude_working_indicators(text) {
return PaneStatus::Active;
}
if is_claude_idle_strict(text) {
return PaneStatus::Idle;
}
}
PaneStatus::Active
}
fn read_hook_status(pane_id: &str, command: &str) -> Option<PaneStatus> {
let sanitized = pane_id.replace('%', "_");
let path = format!("{}/{}", HOOK_STATUS_DIR, sanitized);
let content = fs::read_to_string(&path).ok()?;
let metadata = fs::metadata(&path).ok()?;
let age = metadata.modified().ok()?.elapsed().ok()?;
resolve_hook_status(content.trim(), age, command)
}
const SHELLS: &[&str] = &[
"bash", "zsh", "fish", "sh", "ksh", "csh", "dash", "tcsh",
"ash", "nushell", "nu", "elvish", "oil", "pwsh", "powershell",
"login", "-bash", "-zsh", "-sh", "-fish",
];
pub fn is_shell(command: &str) -> bool {
let cmd = command.rsplit('/').next().unwrap_or(command);
SHELLS.iter().any(|s| cmd.eq_ignore_ascii_case(s))
}
fn is_claude(command: &str) -> bool {
let cmd = command.rsplit('/').next().unwrap_or(command);
cmd.eq_ignore_ascii_case("claude")
}
fn is_claude_idle_strict(content: &str) -> bool {
content
.lines()
.rev()
.filter(|l| !l.trim().is_empty())
.take(4)
.any(|l| l.trim().starts_with('❯'))
}
fn has_claude_working_indicators(content: &str) -> bool {
content
.lines()
.rev()
.filter(|l| !l.trim().is_empty())
.take(5)
.any(|l| {
let t = l.trim();
t.starts_with('⏺')
|| t.starts_with("* ")
|| t.starts_with('⠋') || t.starts_with('⠙') || t.starts_with('⠹')
|| t.starts_with('⠸') || t.starts_with('⠼') || t.starts_with('⠴')
|| t.starts_with('⠦') || t.starts_with('⠧') || t.starts_with('⠇')
|| t.starts_with('⠏')
|| t.contains("· thinking")
|| t.starts_with("Coalescing")
})
}
pub fn has_waiting_pattern(content: &str) -> bool {
let lines: Vec<&str> = content
.lines()
.rev()
.filter(|l| !l.trim().is_empty())
.take(5)
.collect();
for line in lines.iter().take(2) {
let trimmed = line.trim();
if trimmed.contains("Enter to select")
|| trimmed.contains("Esc to cancel")
|| trimmed.contains("Arrow keys to navigate")
|| trimmed.contains("Tab/Arrow keys")
|| trimmed.contains("shift+tab to approve")
{
return true;
}
}
for line in &lines {
let trimmed = line.trim();
if trimmed.contains("(Y/n)")
|| trimmed.contains("(y/N)")
|| trimmed.contains("[Y/n]")
|| trimmed.contains("[y/N]")
|| trimmed.contains("(yes/no)")
{
return true;
}
if trimmed.starts_with("Allow") || trimmed.starts_with("allow") {
return true;
}
if trimmed.contains("Press ENTER") || trimmed.contains("press enter") {
return true;
}
}
false
}
pub fn has_error_pattern(content: &str) -> bool {
let lines: Vec<&str> = content
.lines()
.rev()
.filter(|l| !l.trim().is_empty())
.take(10)
.collect();
for line in &lines {
let trimmed = line.trim();
let lower = trimmed.to_ascii_lowercase();
if lower.starts_with("error:") || lower.starts_with("error[") {
return true;
}
if lower.starts_with("fatal:") || lower.starts_with("fatal error") {
return true;
}
if lower.contains("panicked at") {
return true;
}
if trimmed.starts_with("Traceback (most recent call last)") {
return true;
}
if trimmed.contains("FAILED") && (trimmed.contains("test") || trimmed.contains("TEST")) {
return true;
}
if trimmed.starts_with("ERR!") || lower.starts_with("npm err!") {
return true;
}
if lower.ends_with("command not found") || lower.ends_with("not found") && lower.contains("error") {
return true;
}
if lower.contains("segmentation fault") || lower.contains("core dumped") {
return true;
}
}
false
}
fn resolve_hook_status(status_str: &str, age: Duration, command: &str) -> Option<PaneStatus> {
let status = match status_str {
"active" => PaneStatus::Active,
"idle" => PaneStatus::Idle,
"waiting" => PaneStatus::Waiting,
_ => return None,
};
if age <= HOOK_STALENESS {
return Some(status);
}
match status {
PaneStatus::Active | PaneStatus::Waiting if is_claude(command) => Some(status),
_ => None,
}
}
#[cfg(test)]
fn read_hook_status_from_path(path: &std::path::Path) -> Option<PaneStatus> {
let content = fs::read_to_string(path).ok()?;
let metadata = fs::metadata(path).ok()?;
let age = metadata.modified().ok()?.elapsed().ok()?;
resolve_hook_status(content.trim(), age, "claude")
}
pub fn status_indicator(status: PaneStatus) -> Span<'static> {
match status {
PaneStatus::Waiting => Span::styled("●", Style::default().fg(Color::Yellow)),
PaneStatus::Error => Span::styled("●", Style::default().fg(Color::Red)),
PaneStatus::Active => Span::styled("●", Style::default().fg(Color::Green)),
PaneStatus::Idle => Span::styled("○", Style::default().fg(Color::DarkGray)),
PaneStatus::Unknown => Span::styled("?", Style::default().fg(Color::DarkGray)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pane_status_ordering() {
assert!(PaneStatus::Waiting > PaneStatus::Error);
assert!(PaneStatus::Error > PaneStatus::Active);
assert!(PaneStatus::Active > PaneStatus::Idle);
assert!(PaneStatus::Idle > PaneStatus::Unknown);
}
#[test]
fn test_aggregation_via_max() {
let statuses = vec![PaneStatus::Idle, PaneStatus::Active, PaneStatus::Unknown];
assert_eq!(statuses.into_iter().max(), Some(PaneStatus::Active));
let statuses = vec![PaneStatus::Idle, PaneStatus::Waiting];
assert_eq!(statuses.into_iter().max(), Some(PaneStatus::Waiting));
let statuses = vec![PaneStatus::Error, PaneStatus::Active];
assert_eq!(statuses.into_iter().max(), Some(PaneStatus::Error));
}
#[test]
fn test_shell_is_idle() {
assert_eq!(classify("bash", None, ""), PaneStatus::Idle);
assert_eq!(classify("zsh", None, ""), PaneStatus::Idle);
assert_eq!(classify("fish", None, ""), PaneStatus::Idle);
assert_eq!(classify("-bash", None, ""), PaneStatus::Idle);
}
#[test]
fn test_non_shell_is_active() {
assert_eq!(classify("node", None, ""), PaneStatus::Active);
assert_eq!(classify("python", None, ""), PaneStatus::Active);
assert_eq!(classify("vim", None, ""), PaneStatus::Active);
assert_eq!(classify("claude", None, ""), PaneStatus::Active);
}
#[test]
fn test_waiting_overrides_command() {
let content = "output\nProceed? (Y/n)";
assert_eq!(classify("bash", Some(content), ""), PaneStatus::Waiting);
assert_eq!(classify("node", Some(content), ""), PaneStatus::Waiting);
}
#[test]
fn test_non_shell_without_waiting_is_active() {
let content = "Compiling project...\nBuilding 42 crates";
assert_eq!(classify("cargo", Some(content), ""), PaneStatus::Active);
}
#[test]
fn test_shell_without_waiting_is_idle() {
let content = "user@host:~$ ls\nfile1 file2";
assert_eq!(classify("bash", Some(content), ""), PaneStatus::Idle);
}
#[test]
fn test_is_shell_common() {
assert!(is_shell("bash"));
assert!(is_shell("zsh"));
assert!(is_shell("fish"));
assert!(is_shell("sh"));
assert!(is_shell("dash"));
}
#[test]
fn test_is_shell_login_variants() {
assert!(is_shell("-bash"));
assert!(is_shell("-zsh"));
assert!(is_shell("-sh"));
assert!(is_shell("login"));
}
#[test]
fn test_is_shell_full_path() {
assert!(is_shell("/bin/bash"));
assert!(is_shell("/usr/bin/zsh"));
}
#[test]
fn test_is_not_shell() {
assert!(!is_shell("node"));
assert!(!is_shell("python"));
assert!(!is_shell("vim"));
assert!(!is_shell("claude"));
assert!(!is_shell("cargo"));
}
#[test]
fn test_waiting_yn_prompt() {
assert!(has_waiting_pattern("Some output\nDo you want to continue? (Y/n)"));
}
#[test]
fn test_waiting_allow_prompt() {
assert!(has_waiting_pattern("Reading file...\nAllow Read access to /tmp/foo?"));
}
#[test]
fn test_waiting_interactive_menu() {
assert!(has_waiting_pattern("Pick an option:\n❯ 1. Foo\nEnter to select · Esc to cancel"));
}
#[test]
fn test_waiting_press_enter() {
assert!(has_waiting_pattern("Installation complete.\nPress ENTER to continue"));
}
#[test]
fn test_waiting_yes_no_variations() {
assert!(has_waiting_pattern("Continue? [Y/n]"));
assert!(has_waiting_pattern("Overwrite? [y/N]"));
assert!(has_waiting_pattern("Save changes? (yes/no)"));
}
#[test]
fn test_no_waiting_pattern() {
assert!(!has_waiting_pattern("The answer is 42"));
assert!(!has_waiting_pattern("Compiling...\nBuilding crates"));
assert!(!has_waiting_pattern("user@host:~$"));
}
#[test]
fn test_error_rust_compiler() {
let content = "Compiling foo v0.1.0\nerror[E0308]: mismatched types";
assert_eq!(classify("cargo", Some(content), ""), PaneStatus::Error);
}
#[test]
fn test_error_fatal() {
let content = "fatal: not a git repository";
assert_eq!(classify("git", Some(content), ""), PaneStatus::Error);
}
#[test]
fn test_error_panic() {
let content = "thread 'main' panicked at 'index out of bounds'";
assert_eq!(classify("myapp", Some(content), ""), PaneStatus::Error);
}
#[test]
fn test_error_python_traceback() {
let content = "Traceback (most recent call last)\n File \"app.py\"\nNameError: x";
assert_eq!(classify("python", Some(content), ""), PaneStatus::Error);
}
#[test]
fn test_error_test_failure() {
let content = "test result: FAILED. 2 passed; 1 failed; 0 ignored";
assert_eq!(classify("cargo", Some(content), ""), PaneStatus::Error);
}
#[test]
fn test_error_segfault() {
let content = "Segmentation fault (core dumped)";
assert_eq!(classify("myapp", Some(content), ""), PaneStatus::Error);
}
#[test]
fn test_error_on_shell_pane() {
let content = "$ cargo build\nerror[E0433]: failed to resolve";
assert_eq!(classify("bash", Some(content), ""), PaneStatus::Error);
}
#[test]
fn test_waiting_overrides_error() {
let content = "error: build failed\nRetry? (Y/n)";
assert_eq!(classify("bash", Some(content), ""), PaneStatus::Waiting);
}
#[test]
fn test_no_false_positive_error() {
let content = "Processing error-handling module...\nDone.";
assert_eq!(classify("cargo", Some(content), ""), PaneStatus::Active);
}
#[test]
fn test_npm_error() {
assert!(has_error_pattern("npm ERR! code ENOENT"));
}
#[test]
fn test_command_not_found() {
assert!(has_error_pattern("foobar: command not found"));
}
#[test]
fn test_monitor_set_and_get() {
let mut m = PaneMonitor::new();
m.set("%0", "main", 0, PaneStatus::Active);
assert_eq!(m.get_pane("%0"), PaneStatus::Active);
assert_eq!(m.get_session("main"), PaneStatus::Active);
assert_eq!(m.get_window("main", 0), PaneStatus::Active);
}
#[test]
fn test_monitor_aggregation() {
let mut m = PaneMonitor::new();
m.set("%0", "dev", 0, PaneStatus::Idle);
m.set("%1", "dev", 0, PaneStatus::Waiting);
m.set("%2", "dev", 1, PaneStatus::Active);
assert_eq!(m.get_session("dev"), PaneStatus::Waiting);
assert_eq!(m.get_window("dev", 0), PaneStatus::Waiting);
assert_eq!(m.get_window("dev", 1), PaneStatus::Active);
}
#[test]
fn test_monitor_clear() {
let mut m = PaneMonitor::new();
m.set("%0", "main", 0, PaneStatus::Active);
m.clear();
assert_eq!(m.get_pane("%0"), PaneStatus::Unknown);
assert_eq!(m.get_session("main"), PaneStatus::Unknown);
}
#[test]
fn test_monitor_begin_update_resets() {
let mut m = PaneMonitor::new();
m.set("%0", "main", 0, PaneStatus::Active);
m.begin_update();
assert_eq!(m.get_pane("%0"), PaneStatus::Unknown);
}
#[test]
fn test_claude_idle_prompt() {
let content = "Done writing file.\n\n❯ \n";
assert_eq!(classify("claude", Some(content), ""), PaneStatus::Idle);
}
#[test]
fn test_claude_idle_with_status_bar() {
let content = "● Write(file.rs)\n\n❯ \n\n project (main) | Opus 4.6 (1M context)";
assert_eq!(classify("claude", Some(content), ""), PaneStatus::Idle);
}
#[test]
fn test_claude_active_no_prompt() {
let content = "Compiling project...\nBuilding 42 crates\nRunning tests";
assert_eq!(classify("claude", Some(content), ""), PaneStatus::Active);
}
#[test]
fn test_claude_waiting_overrides_idle() {
let content = "❯ \nAllow Read access to /tmp/foo?";
assert_eq!(classify("claude", Some(content), ""), PaneStatus::Waiting);
}
#[test]
fn test_claude_error_overrides_idle() {
let content = "❯ \nerror: build failed";
assert_eq!(classify("claude", Some(content), ""), PaneStatus::Error);
}
#[test]
fn test_claude_active_stale_prompt() {
let content = "❯ \nproject (main) | Opus 4.6\n⏺ Read(src/main.rs)\nReading file...\nProcessing content";
assert_eq!(classify("claude", Some(content), ""), PaneStatus::Active);
}
#[test]
fn test_claude_active_tool_execution() {
let content = "Some previous output\n⏺ Write(src/lib.rs)";
assert_eq!(classify("claude", Some(content), ""), PaneStatus::Active);
}
#[test]
fn test_claude_active_plan_agent_coalescing() {
let content = "● Now let me launch the Plan agent to synthesize the audit findings.\n\n Plan(Synthesize audit findings)\n L Read(Project.toml)\n +25 more tool uses\n\n* Coalescing… (5m 42s · ↓ 15.7k tokens · thinking)\n\n❯ \n\nWhatsThePoint (main) | Opus 4.6 (1M context)\n ■ plan mode on (shift+tab to cycle)";
assert_eq!(classify("claude", Some(content), ""), PaneStatus::Active);
}
#[test]
fn test_claude_no_content_is_active() {
assert_eq!(classify("claude", None, ""), PaneStatus::Active);
}
#[test]
fn test_claude_idle_prompt_only() {
let content = "❯ ";
assert_eq!(classify("claude", Some(content), ""), PaneStatus::Idle);
}
#[test]
fn test_non_claude_unaffected_by_prompt_char() {
let content = "❯ some output";
assert_eq!(classify("vim", Some(content), ""), PaneStatus::Active);
assert_eq!(classify("bash", Some(content), ""), PaneStatus::Idle);
}
#[test]
fn test_hook_status_active() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("_99");
fs::write(&path, "active").unwrap();
let result = read_hook_status_from_path(&path);
assert_eq!(result, Some(PaneStatus::Active));
}
#[test]
fn test_hook_status_idle() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("_99");
fs::write(&path, "idle").unwrap();
let result = read_hook_status_from_path(&path);
assert_eq!(result, Some(PaneStatus::Idle));
}
#[test]
fn test_hook_status_waiting() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("_99");
fs::write(&path, "waiting").unwrap();
let result = read_hook_status_from_path(&path);
assert_eq!(result, Some(PaneStatus::Waiting));
}
#[test]
fn test_hook_status_unknown_content() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("_99");
fs::write(&path, "garbage").unwrap();
let result = read_hook_status_from_path(&path);
assert_eq!(result, None);
}
#[test]
fn test_hook_status_missing_file() {
let result = read_hook_status("%nonexistent_test_pane", "claude");
assert_eq!(result, None);
}
#[test]
fn test_fresh_hook_always_trusted() {
let fresh = Duration::from_secs(10);
assert_eq!(resolve_hook_status("active", fresh, "bash"), Some(PaneStatus::Active));
assert_eq!(resolve_hook_status("idle", fresh, "bash"), Some(PaneStatus::Idle));
assert_eq!(resolve_hook_status("waiting", fresh, "bash"), Some(PaneStatus::Waiting));
}
#[test]
fn test_stale_active_trusted_when_claude_running() {
let stale = Duration::from_secs(300);
assert_eq!(resolve_hook_status("active", stale, "claude"), Some(PaneStatus::Active));
}
#[test]
fn test_stale_waiting_trusted_when_claude_running() {
let stale = Duration::from_secs(300);
assert_eq!(resolve_hook_status("waiting", stale, "claude"), Some(PaneStatus::Waiting));
}
#[test]
fn test_stale_active_rejected_when_shell_running() {
let stale = Duration::from_secs(300);
assert_eq!(resolve_hook_status("active", stale, "bash"), None);
assert_eq!(resolve_hook_status("active", stale, "zsh"), None);
}
#[test]
fn test_stale_idle_always_rejected() {
let stale = Duration::from_secs(300);
assert_eq!(resolve_hook_status("idle", stale, "claude"), None);
assert_eq!(resolve_hook_status("idle", stale, "bash"), None);
}
#[test]
fn test_stale_unknown_content_rejected() {
let stale = Duration::from_secs(300);
assert_eq!(resolve_hook_status("garbage", stale, "claude"), None);
}
#[test]
fn test_shell_command_with_fresh_hook_uses_hook_status() {
let dir = tempfile::tempdir().unwrap();
let pane_id = "%test_shell_hook";
let sanitized = pane_id.replace('%', "_");
let path = dir.path().join(&sanitized);
fs::write(&path, "active").unwrap();
let hook = read_hook_status_from_path(&path);
assert_eq!(hook, Some(PaneStatus::Active));
assert_eq!(classify("bash", None, "%nonexistent"), PaneStatus::Idle);
}
#[test]
fn test_working_indicator_tool_marker() {
assert!(has_claude_working_indicators("output\n⏺ Edit(file.rs)"));
}
#[test]
fn test_working_indicator_spinner() {
assert!(has_claude_working_indicators("output\n⠋ Processing..."));
}
#[test]
fn test_working_indicator_agent_asterisk() {
assert!(has_claude_working_indicators("Plan(Synthesize audit findings)\n* Coalescing… (5m 42s · ↓ 15.7k tokens · thinking)"));
}
#[test]
fn test_working_indicator_thinking_status() {
assert!(has_claude_working_indicators("output\n⠋ 2m 30s · thinking"));
}
#[test]
fn test_working_indicator_coalescing() {
assert!(has_claude_working_indicators("output\nCoalescing… (3m 12s)"));
}
#[test]
fn test_no_working_indicator() {
assert!(!has_claude_working_indicators("just regular output\nmore text"));
}
#[test]
fn test_strict_idle_prompt_on_last_line() {
assert!(is_claude_idle_strict("output\n❯ "));
}
#[test]
fn test_strict_idle_multi_line_status_bar() {
let content = "previous output\n❯ \n project (main) | Opus 4.6 (1M context)\n cost: $0.05\n auto mode";
assert!(is_claude_idle_strict(content));
}
#[test]
fn test_strict_idle_prompt_too_far_up() {
assert!(!is_claude_idle_strict("❯ \nline2\nline3\nline4\nline5"));
}
}