use std::collections::HashMap; use crate::detect::{Agent, AgentState}; use crate::layout::PaneId; use crate::terminal::{TerminalId, TerminalState}; use super::{Tab, Workspace}; /// Detail info for a single pane, used by the agent detail panel. pub struct PaneDetail { pub pane_id: PaneId, pub tab_idx: usize, pub tab_label: String, pub label: String, pub agent_label: String, #[allow(dead_code)] pub agent: Option, pub state: AgentState, pub seen: bool, pub custom_status: Option, } impl Tab { pub fn has_working_pane(&self, terminals: &HashMap) -> bool { self.panes.values().any(|pane| { terminals .get(&pane.attached_terminal_id) .is_some_and(|terminal| terminal.state == AgentState::Working) }) } pub fn pane_details(&self, terminals: &HashMap) -> Vec { self.layout .pane_ids() .iter() .filter_map(|id| { let pane = self.panes.get(id)?; let terminal = terminals.get(&pane.attached_terminal_id)?; let agent_label = terminal .agent_name .as_deref() .or_else(|| terminal.effective_agent_label())? .to_string(); Some(PaneDetail { pane_id: *id, tab_idx: self.number.saturating_sub(1), tab_label: self.display_name(), label: agent_label.clone(), agent_label, agent: terminal.effective_known_agent(), state: terminal.state, seen: pane.seen, custom_status: terminal.effective_custom_status().map(str::to_string), }) }) .collect() } } fn pane_attention_priority(state: AgentState, seen: bool) -> u8 { match (state, seen) { (AgentState::Blocked, _) => 4, (AgentState::Idle, false) => 3, (AgentState::Working, _) => 2, (AgentState::Idle, true) => 1, (AgentState::Unknown, _) => 0, } } impl Workspace { pub fn aggregate_state( &self, terminals: &HashMap, ) -> (AgentState, bool) { self.tabs .iter() .flat_map(|tab| tab.panes.values()) .filter_map(|pane| { terminals .get(&pane.attached_terminal_id) .map(|terminal| (terminal.state, pane.seen)) }) .max_by_key(|(state, seen)| pane_attention_priority(*state, *seen)) .unwrap_or((AgentState::Unknown, true)) } pub fn has_working_pane(&self, terminals: &HashMap) -> bool { self.tabs.iter().any(|tab| tab.has_working_pane(terminals)) } pub fn pane_details(&self, terminals: &HashMap) -> Vec { let multi_tab = self.tabs.len() > 1; self.tabs .iter() .flat_map(|tab| tab.pane_details(terminals)) .map(|mut detail| { if multi_tab { detail.label = format!("{}·{}", detail.tab_label, detail.agent_label); } detail }) .collect() } } #[cfg(test)] mod tests { use ratatui::layout::Direction; use super::*; use crate::detect::Agent; fn terminal_for_pane(ws: &Workspace, pane_id: PaneId) -> TerminalState { TerminalState::new(ws.terminal_id(pane_id).unwrap().clone(), "/tmp".into()) } #[test] fn aggregate_state_all_unknown() { let ws = Workspace::test_new("test"); let mut terminals = HashMap::new(); let root = ws.tabs[0].root_pane; let terminal = terminal_for_pane(&ws, root); terminals.insert(terminal.id.clone(), terminal); let (state, seen) = ws.aggregate_state(&terminals); assert_eq!(state, AgentState::Unknown); assert!(seen); } #[test] fn aggregate_state_priority() { let mut ws = Workspace::test_new("test"); let id2 = ws.test_split(Direction::Horizontal); let root_id = ws.tabs[0] .panes .keys() .find(|id| **id != id2) .copied() .unwrap(); let mut terminals = HashMap::new(); let mut root_terminal = terminal_for_pane(&ws, root_id); root_terminal.state = AgentState::Idle; terminals.insert(root_terminal.id.clone(), root_terminal); let mut second_terminal = terminal_for_pane(&ws, id2); second_terminal.state = AgentState::Working; terminals.insert(second_terminal.id.clone(), second_terminal); let (state, seen) = ws.aggregate_state(&terminals); assert_eq!(state, AgentState::Working); assert!(seen); } #[test] fn aggregate_state_done_unseen_beats_working() { let mut ws = Workspace::test_new("test"); let id2 = ws.test_split(Direction::Horizontal); let root_id = ws.tabs[0] .panes .keys() .find(|id| **id != id2) .copied() .unwrap(); let mut terminals = HashMap::new(); let mut root_terminal = terminal_for_pane(&ws, root_id); root_terminal.state = AgentState::Idle; terminals.insert(root_terminal.id.clone(), root_terminal); let mut second_terminal = terminal_for_pane(&ws, id2); second_terminal.state = AgentState::Working; terminals.insert(second_terminal.id.clone(), second_terminal); let root = ws.tabs[0].panes.get_mut(&root_id).unwrap(); root.seen = false; let (state, seen) = ws.aggregate_state(&terminals); assert_eq!(state, AgentState::Idle); assert!(!seen); } #[test] fn pane_details_prefers_agent_name_over_detected_agent_label() { let ws = Workspace::test_new("test"); let root_pane = ws.tabs[0].root_pane; let mut terminals = HashMap::new(); let mut terminal = terminal_for_pane(&ws, root_pane); terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); terminal.set_agent_name("planner".into()); terminals.insert(terminal.id.clone(), terminal); let labels: Vec<_> = ws .pane_details(&terminals) .into_iter() .map(|detail| (detail.label, detail.agent_label, detail.agent)) .collect(); assert_eq!( labels, vec![("planner".into(), "planner".into(), Some(Agent::Pi))] ); } #[test] fn pane_details_includes_tab_context_for_multi_tab_workspace() { let mut ws = Workspace::test_new("test"); ws.tabs[0].custom_name = Some("main".into()); let root_pane = ws.tabs[0].root_pane; let second_tab = ws.test_add_tab(Some("review")); let review_pane = ws.tabs[second_tab].root_pane; let mut terminals = HashMap::new(); let mut root_terminal = terminal_for_pane(&ws, root_pane); root_terminal.set_hook_authority( "test".into(), "pi".into(), AgentState::Working, None, None, ); terminals.insert(root_terminal.id.clone(), root_terminal); let mut review_terminal = terminal_for_pane(&ws, review_pane); review_terminal.set_hook_authority( "test".into(), "claude".into(), AgentState::Idle, None, None, ); terminals.insert(review_terminal.id.clone(), review_terminal); let labels: Vec<_> = ws .pane_details(&terminals) .into_iter() .map(|detail| (detail.label, detail.agent_label, detail.agent)) .collect(); assert_eq!( labels, vec![ ("main·pi".into(), "pi".into(), Some(Agent::Pi)), ("review·claude".into(), "claude".into(), Some(Agent::Claude)), ] ); } }