diff options
Diffstat (limited to 'src')
33 files changed, 2054 insertions, 1360 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index be8e1018..27ca7474 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -6,7 +6,7 @@ use tracing::{info, warn}; use crate::detect::{Agent, AgentState}; use crate::events::AppEvent; use crate::layout::{find_in_direction, NavDirection, PaneId}; -use crate::pane::EffectiveStateChange; +use crate::terminal::EffectiveStateChange; use crate::workspace::WorkspaceGitStatus; use super::state::{AppState, Mode, ToastKind, ToastNotification, ToastTarget, ViewLayout}; @@ -91,6 +91,7 @@ pub struct PaneStateUpdate { pub agent_label: Option<String>, pub known_agent: Option<Agent>, pub state: AgentState, + pub custom_status: Option<String>, } // --------------------------------------------------------------------------- @@ -406,14 +407,75 @@ impl AppState { self.agent_panel_scroll = self.agent_panel_scroll.min(max_scroll); } + pub(crate) fn terminal_ids_for_workspace( + &self, + ws_idx: usize, + ) -> Vec<crate::terminal::TerminalId> { + self.workspaces + .get(ws_idx) + .into_iter() + .flat_map(|ws| &ws.tabs) + .flat_map(|tab| tab.panes.values()) + .map(|pane| pane.attached_terminal_id.clone()) + .collect() + } + + pub(crate) fn terminal_ids_for_tab( + &self, + ws_idx: usize, + tab_idx: usize, + ) -> Vec<crate::terminal::TerminalId> { + self.workspaces + .get(ws_idx) + .and_then(|ws| ws.tabs.get(tab_idx)) + .into_iter() + .flat_map(|tab| tab.panes.values()) + .map(|pane| pane.attached_terminal_id.clone()) + .collect() + } + + pub(crate) fn terminal_id_for_pane( + &self, + ws_idx: usize, + pane_id: PaneId, + ) -> Option<crate::terminal::TerminalId> { + self.workspaces + .get(ws_idx)? + .pane_state(pane_id) + .map(|pane| pane.attached_terminal_id.clone()) + } + + pub(crate) fn remove_unattached_terminal_ids( + &mut self, + terminal_ids: impl IntoIterator<Item = crate::terminal::TerminalId>, + ) { + for terminal_id in terminal_ids { + let still_attached = self.workspaces.iter().any(|ws| { + ws.tabs.iter().any(|tab| { + tab.panes + .values() + .any(|pane| pane.attached_terminal_id == terminal_id) + }) + }); + if !still_attached { + self.terminals.remove(&terminal_id); + if let Some(runtime) = self.terminal_runtimes.remove(&terminal_id) { + runtime.shutdown(); + } + } + } + } + pub fn close_selected_workspace(&mut self) { if self.workspaces.is_empty() { return; } self.mark_session_dirty(); + let terminal_ids = self.terminal_ids_for_workspace(self.selected); let workspace_id = self.workspaces[self.selected].id.clone(); crate::logging::workspace_closed(&workspace_id); self.workspaces.remove(self.selected); + self.remove_unattached_terminal_ids(terminal_ids); if self.workspaces.is_empty() { self.active = None; self.selected = 0; @@ -529,12 +591,26 @@ impl AppState { pub fn close_pane(&mut self) { self.mark_session_dirty(); - let should_close_workspace = self - .active + let active = self.active; + let terminal_ids = active + .and_then(|i| { + self.workspaces + .get(i) + .and_then(|ws| ws.focused_pane_id().map(|pane_id| (i, pane_id))) + }) + .and_then(|(i, pane_id)| self.terminal_id_for_pane(i, pane_id)) + .into_iter() + .collect::<Vec<_>>(); + let should_close_workspace = active .and_then(|i| self.workspaces.get_mut(i)) .is_some_and(|ws| ws.close_focused()); if should_close_workspace { + if let Some(active) = active { + self.selected = active; + } self.close_selected_workspace(); + } else { + self.remove_unattached_terminal_ids(terminal_ids); } } @@ -545,16 +621,25 @@ impl AppState { .and_then(|i| self.workspaces.get(i)) .is_some_and(|ws| ws.tabs.len() <= 1); if should_close_workspace { + if let Some(active) = self.active { + self.selected = active; + } self.close_selected_workspace(); return; } if let Some(ws_idx) = self.active { + let terminal_ids = self + .workspaces + .get(ws_idx) + .map(|ws| self.terminal_ids_for_tab(ws_idx, ws.active_tab)) + .unwrap_or_default(); let Some(ws) = self.workspaces.get_mut(ws_idx) else { return; }; let workspace_id = ws.id.clone(); let closing_tab_id = format!("{}:{}", workspace_id, ws.active_tab + 1); ws.close_active_tab(); + self.remove_unattached_terminal_ids(terminal_ids); crate::logging::tab_closed(&workspace_id, &closing_tab_id); self.tab_scroll_follow_active = true; self.refresh_tab_bar_view(); @@ -572,37 +657,23 @@ impl AppState { } pub fn copy_selection(&mut self) { - let text = { - let sel = match self.selection.as_mut() { - Some(s) => { - if !s.finish() { - self.selection = None; - return; - } - s - } - None => return, - }; - - let ws = match self.active.and_then(|i| self.workspaces.get(i)) { - Some(ws) => ws, - None => { - self.selection = None; - return; - } - }; - - let rt = match ws.runtime(sel.pane_id) { - Some(r) => r, - None => { - self.selection = None; - return; - } - }; + let mut sel = match self.selection.take() { + Some(sel) => sel, + None => return, + }; + if !sel.finish() { + return; + } - rt.extract_selection(sel) + let ws_idx = match self.active { + Some(ws_idx) if self.workspaces.get(ws_idx).is_some() => ws_idx, + _ => return, }; + let text = self + .runtime_for_pane_in_workspace(ws_idx, sel.pane_id) + .and_then(|rt| rt.extract_selection(&sel)); + if let Some(text) = text { if !text.is_empty() { self.request_clipboard_write = Some(text.into_bytes()); @@ -678,7 +749,9 @@ impl AppState { agent, state, } => self - .update_pane_state(pane_id, |pane| pane.set_detected_state(agent, state)) + .update_terminal_state(pane_id, |terminal| { + terminal.set_detected_state(agent, state) + }) .into_iter() .collect(), AppEvent::HookStateReported { @@ -690,8 +763,8 @@ impl AppState { custom_status, seq, } => self - .update_pane_state(pane_id, |pane| { - pane.set_hook_authority_with_custom_status( + .update_terminal_state(pane_id, |terminal| { + terminal.set_hook_authority_with_custom_status( source, agent_label, state, @@ -707,8 +780,8 @@ impl AppState { source, seq, } => self - .update_pane_state(pane_id, |pane| { - pane.clear_hook_authority(source.as_deref(), seq) + .update_terminal_state(pane_id, |terminal| { + terminal.clear_hook_authority(source.as_deref(), seq) }) .into_iter() .collect(), @@ -719,8 +792,8 @@ impl AppState { seq, .. } => self - .update_pane_state(pane_id, |pane| { - pane.release_agent(&source, &agent_label, seq) + .update_terminal_state(pane_id, |terminal| { + terminal.release_agent(&source, &agent_label, seq) }) .into_iter() .collect(), @@ -734,20 +807,21 @@ impl AppState { } } - fn update_pane_state<F>(&mut self, pane_id: PaneId, update: F) -> Option<PaneStateUpdate> + fn update_terminal_state<F>(&mut self, pane_id: PaneId, update: F) -> Option<PaneStateUpdate> where - F: FnOnce(&mut crate::pane::PaneState) -> Option<EffectiveStateChange>, + F: FnOnce(&mut crate::terminal::TerminalState) -> Option<EffectiveStateChange>, { let ws_idx = self .workspaces .iter() .position(|ws| ws.pane_state(pane_id).is_some())?; + let terminal_id = self.workspaces[ws_idx] + .pane_state(pane_id)? + .attached_terminal_id + .clone(); let change = { - let pane = self.workspaces[ws_idx] - .tabs - .iter_mut() - .find_map(|tab| tab.panes.get_mut(&pane_id))?; - update(pane)? + let terminal = self.terminals.get_mut(&terminal_id)?; + update(terminal)? }; let update = PaneStateUpdate { pane_id, @@ -758,6 +832,7 @@ impl AppState { agent_label: change.agent_label.clone(), known_agent: change.known_agent, state: change.state, + custom_status: change.custom_status.clone(), }; self.apply_pane_state_change(ws_idx, pane_id, &change); Some(update) @@ -838,6 +913,8 @@ impl AppState { return; }; + let pane_terminal_id = self.terminal_id_for_pane(ws_idx, pane_id); + let workspace_terminal_ids = self.terminal_ids_for_workspace(ws_idx); let should_close_workspace = { let ws = &mut self.workspaces[ws_idx]; ws.remove_pane(pane_id) @@ -846,6 +923,7 @@ impl AppState { if should_close_workspace { self.workspaces.remove(ws_idx); + self.remove_unattached_terminal_ids(workspace_terminal_ids); if self.workspaces.is_empty() { self.active = None; self.selected = 0; @@ -862,6 +940,8 @@ impl AppState { self.selected = self.workspaces.len() - 1; } } + } else { + self.remove_unattached_terminal_ids(pane_terminal_id); } } } @@ -883,6 +963,7 @@ mod tests { let ws = Workspace::test_new(name); state.workspaces.push(ws); } + state.ensure_test_terminals(); if !state.workspaces.is_empty() { state.active = Some(0); state.mode = Mode::Terminal; @@ -969,11 +1050,15 @@ mod tests { } fn mark_agent(state: &mut AppState, ws_idx: usize, tab_idx: usize, pane_id: PaneId) { - state.workspaces[ws_idx].tabs[tab_idx] + let terminal_id = state.workspaces[ws_idx].tabs[tab_idx] .panes - .get_mut(&pane_id) + .get(&pane_id) .unwrap() - .set_detected_state(Some(Agent::Pi), AgentState::Idle); + .attached_terminal_id + .clone(); + if let Some(terminal) = state.terminals.get_mut(&terminal_id) { + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + } } #[test] @@ -987,6 +1072,7 @@ mod tests { let mut state = AppState::test_new(); state.workspaces = vec![first, second]; + state.ensure_test_terminals(); state.active = Some(0); state.selected = 0; state.mode = Mode::Terminal; @@ -1044,6 +1130,7 @@ mod tests { let mut state = AppState::test_new(); state.workspaces = vec![first, second]; + state.ensure_test_terminals(); state.active = Some(0); state.selected = 0; state.mode = Mode::Terminal; @@ -1068,6 +1155,7 @@ mod tests { let mut state = AppState::test_new(); state.workspaces = vec![workspace]; + state.ensure_test_terminals(); state.active = Some(0); state.selected = 0; state.mode = Mode::Terminal; @@ -1256,9 +1344,15 @@ mod tests { state: AgentState::Working, }); - let pane = state.workspaces[0].panes.get(&pane_id).unwrap(); - assert_eq!(pane.state, AgentState::Working); - assert_eq!(pane.detected_agent, Some(Agent::Pi)); + let terminal_id = state.workspaces[0] + .panes + .get(&pane_id) + .unwrap() + .attached_terminal_id + .clone(); + let terminal = state.terminals.get(&terminal_id).unwrap(); + assert_eq!(terminal.state, AgentState::Working); + assert_eq!(terminal.detected_agent, Some(Agent::Pi)); } #[test] @@ -1268,11 +1362,13 @@ mod tests { let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); // First set it to Working - state.workspaces[1] + let bg_terminal_id = state.workspaces[1] .panes - .get_mut(&bg_pane_id) + .get(&bg_pane_id) .unwrap() - .state = AgentState::Working; + .attached_terminal_id + .clone(); + state.terminals.get_mut(&bg_terminal_id).unwrap().state = AgentState::Working; // Now transition to Idle while in background state.handle_app_event(AppEvent::StateChanged { @@ -1291,9 +1387,14 @@ mod tests { state.active = Some(0); state.outer_terminal_focus = Some(true); let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); - let pane = state.workspaces[0].panes.get_mut(&pane_id).unwrap(); - pane.state = AgentState::Working; - pane.seen = false; + let terminal_id = state.workspaces[0] + .panes + .get(&pane_id) + .unwrap() + .attached_terminal_id + .clone(); + state.terminals.get_mut(&terminal_id).unwrap().state = AgentState::Working; + state.workspaces[0].panes.get_mut(&pane_id).unwrap().seen = false; state.handle_app_event(AppEvent::StateChanged { pane_id, @@ -1301,8 +1402,9 @@ mod tests { state: AgentState::Idle, }); + let terminal = state.terminals.get(&terminal_id).unwrap(); + assert_eq!(terminal.state, AgentState::Idle); let pane = state.workspaces[0].panes.get(&pane_id).unwrap(); - assert_eq!(pane.state, AgentState::Idle); assert!(pane.seen); } @@ -1394,11 +1496,13 @@ mod tests { state.active = Some(0); state.toast_config.delivery = crate::config::ToastDelivery::Herdr; let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); - state.workspaces[1] + let bg_terminal_id = state.workspaces[1] .panes - .get_mut(&bg_pane_id) + .get(&bg_pane_id) .unwrap() - .state = AgentState::Working; + .attached_terminal_id + .clone(); + state.terminals.get_mut(&bg_terminal_id).unwrap().state = AgentState::Working; state.handle_app_event(AppEvent::StateChanged { pane_id: bg_pane_id, @@ -1422,6 +1526,7 @@ mod tests { state.toast_config.delivery = crate::config::ToastDelivery::Herdr; state.workspaces[1].tabs[0].set_custom_name("main".into()); let second_tab = state.workspaces[1].test_add_tab(Some("logs")); + state.ensure_test_terminals(); let bg_pane_id = state.workspaces[1].tabs[second_tab].root_pane; state.handle_app_event(AppEvent::StateChanged { @@ -1443,6 +1548,7 @@ mod tests { state.toast_config.delivery = crate::config::ToastDelivery::Herdr; state.workspaces[0].tabs[0].set_custom_name("main".into()); let second_tab = state.workspaces[0].test_add_tab(Some("logs")); + state.ensure_test_terminals(); let bg_pane_id = state.workspaces[0].tabs[second_tab].root_pane; state.handle_app_event(AppEvent::StateChanged { @@ -1567,4 +1673,74 @@ mod tests { state.close_pane(); assert_eq!(state.workspaces[0].panes.len(), 1); } + + #[test] + fn close_pane_removes_unattached_terminal_state() { + let mut state = app_with_workspaces(&["test"]); + let pane_id = state.workspaces[0].test_split(Direction::Horizontal); + state.ensure_test_terminals(); + let terminal_id = state.terminal_id_for_pane(0, pane_id).unwrap(); + + state.close_pane(); + + assert!(!state.terminals.contains_key(&terminal_id)); + } + + #[test] + fn close_tab_removes_unattached_terminal_states() { + let mut state = app_with_workspaces(&["test"]); + let tab_idx = state.workspaces[0].test_add_tab(Some("logs")); + state.ensure_test_terminals(); + state.workspaces[0].switch_tab(tab_idx); + let pane_id = state.workspaces[0].tabs[tab_idx].root_pane; + let terminal_id = state.terminal_id_for_pane(0, pane_id).unwrap(); + + state.close_tab(); + + assert!(!state.terminals.contains_key(&terminal_id)); + } + + #[test] + fn close_workspace_removes_unattached_terminal_states() { + let mut state = app_with_workspaces(&["one", "two"]); + let terminal_id = state + .terminal_id_for_pane(0, state.workspaces[0].tabs[0].root_pane) + .unwrap(); + + state.close_selected_workspace(); + + assert!(!state.terminals.contains_key(&terminal_id)); + } + + #[test] + fn close_tab_last_tab_closes_active_workspace_not_selected_workspace() { + let mut state = app_with_workspaces(&["selected", "active"]); + let active_terminal_id = state + .terminal_id_for_pane(1, state.workspaces[1].tabs[0].root_pane) + .unwrap(); + state.active = Some(1); + state.selected = 0; + + state.close_tab(); + + assert_eq!(state.workspaces.len(), 1); + assert_eq!(state.workspaces[0].display_name(), "selected"); + assert!(!state.terminals.contains_key(&active_terminal_id)); + } + + #[test] + fn close_pane_last_pane_closes_active_workspace_not_selected_workspace() { + let mut state = app_with_workspaces(&["selected", "active"]); + let active_terminal_id = state + .terminal_id_for_pane(1, state.workspaces[1].tabs[0].root_pane) + .unwrap(); + state.active = Some(1); + state.selected = 0; + + state.close_pane(); + + assert_eq!(state.workspaces.len(), 1); + assert_eq!(state.workspaces[0].display_name(), "selected"); + assert!(!state.terminals.contains_key(&active_terminal_id)); + } } diff --git a/src/app/agents.rs b/src/app/agents.rs index 35e26c42..f9e410f5 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -77,19 +77,19 @@ impl App { } } - let Some(ws) = self.state.workspaces.get_mut(resolved.ws_idx) else { - return Err(AgentRenameError::Target(TerminalTargetError::NotFound { - target: target.to_string(), - })); - }; - let Some(pane_state) = ws.pane_state_mut(resolved.pane_id) else { + let Some(terminal) = self + .state + .terminals + .values_mut() + .find(|terminal| terminal.id.to_string() == resolved.terminal_id) + else { return Err(AgentRenameError::Target(TerminalTargetError::NotFound { target: target.to_string(), })); }; match normalized_name { - Some(name) => pane_state.set_manual_label(name), - None => pane_state.clear_manual_label(), + Some(name) => terminal.set_manual_label(name), + None => terminal.clear_manual_label(), } self.state.mark_session_dirty(); self.agent_info(resolved.ws_idx, resolved.pane_id) @@ -185,13 +185,17 @@ impl App { )? }; - let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { - return Err(AgentStartError::SpawnFailed("workspace disappeared".into())); - }; - let Some(pane_state) = ws.pane_state_mut(pane_id) else { - return Err(AgentStartError::SpawnFailed("pane disappeared".into())); + let terminal_id = self + .state + .workspaces + .get(ws_idx) + .and_then(|ws| ws.terminal_id(pane_id)) + .cloned() + .ok_or_else(|| AgentStartError::SpawnFailed("terminal disappeared".into()))?; + let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else { + return Err(AgentStartError::SpawnFailed("terminal disappeared".into())); }; - pane_state.set_manual_label(name); + terminal.set_manual_label(name); self.state.mark_session_dirty(); let agent = self @@ -317,7 +321,7 @@ impl App { argv: &[String], focus: bool, ) -> Result<(usize, usize, crate::layout::PaneId), AgentStartError> { - let ws = crate::workspace::Workspace::new_argv_command( + let (ws, terminal, runtime) = crate::workspace::Workspace::new_argv_command( cwd, rows, cols, @@ -329,6 +333,10 @@ impl App { self.render_dirty.clone(), ) .map_err(|err| AgentStartError::SpawnFailed(err.to_string()))?; + self.state + .terminal_runtimes + .insert(terminal.id.clone(), runtime); + self.state.terminals.insert(terminal.id.clone(), terminal); self.state.workspaces.push(ws); let ws_idx = self.state.workspaces.len() - 1; if focus || self.state.active.is_none() { @@ -375,13 +383,19 @@ impl App { target: target_pane.raw().to_string(), })? .map_err(|err| AgentStartError::SpawnFailed(err.to_string()))?; + self.state + .terminal_runtimes + .insert(result.1.terminal.id.clone(), result.1.runtime); + self.state + .terminals + .insert(result.1.terminal.id.clone(), result.1.terminal); if focus { self.state.switch_workspace(ws_idx); self.state.switch_tab(result.0); self.state.mode = Mode::Terminal; } self.schedule_session_save(); - Ok((ws_idx, result.0, result.1)) + Ok((ws_idx, result.0, result.1.pane_id)) } fn agent_info( @@ -409,23 +423,11 @@ impl App { name: &str, except_terminal_id: &str, ) -> Vec<crate::api::schema::AgentInfo> { - self.state - .workspaces - .iter() - .enumerate() - .flat_map(|(ws_idx, ws)| { - ws.tabs.iter().flat_map(move |tab| { - tab.layout - .pane_ids() - .into_iter() - .filter_map(move |pane_id| { - let pane_state = ws.pane_state(pane_id)?; - (pane_state.manual_label.as_deref() == Some(name)) - .then(|| self.agent_info(ws_idx, pane_id))? - }) - }) + self.collect_agent_infos() + .into_iter() + .filter(|agent| { + agent.name.as_deref() == Some(name) && agent.terminal_id != except_terminal_id }) - .filter(|agent| agent.terminal_id != except_terminal_id) .collect() } } diff --git a/src/app/api.rs b/src/app/api.rs index c6fa88fa..ab26f82b 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -75,7 +75,8 @@ impl App { if let Some((pane_id, agent)) = released_agent { if pane_updates.iter().any(|update| update.pane_id == pane_id) { if let Some((ws_idx, _)) = self.find_pane(pane_id) { - if let Some(runtime) = self.state.workspaces[ws_idx].runtimes.get(&pane_id) { + if let Some(runtime) = self.state.runtime_for_pane_in_workspace(ws_idx, pane_id) + { runtime.begin_graceful_release(agent); } } @@ -129,7 +130,12 @@ impl App { else { continue; }; - let Some(agent_label) = pane.effective_agent_label() else { + let Some(agent_label) = self + .state + .terminals + .get(&pane.attached_terminal_id) + .and_then(|terminal| terminal.effective_agent_label()) + else { continue; }; let event_text = match kind { @@ -199,14 +205,9 @@ impl App { .workspaces .get(update.ws_idx) .and_then(|ws| ws.pane_state(update.pane_id)) - .map(|pane| pane_agent_status(pane.state, pane.seen)) + .map(|pane| pane_agent_status(update.state, pane.seen)) .unwrap_or_else(|| pane_agent_status(update.state, true)); - let custom_status = self - .state - .workspaces - .get(update.ws_idx) - .and_then(|ws| ws.pane_state(update.pane_id)) - .and_then(|pane| pane.effective_custom_status().map(str::to_string)); + let custom_status = update.custom_status.clone(); self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::PaneAgentStatusChanged, data: crate::api::schema::EventData::PaneAgentStatusChanged { @@ -296,7 +297,7 @@ impl App { .state .workspaces .get(ws_idx) - .and_then(|ws| ws.runtime(pane_id)) + .and_then(|_| self.state.runtime_for_pane_in_workspace(ws_idx, pane_id)) else { return; }; @@ -629,11 +630,9 @@ impl App { let cwd = cwd .map(std::path::PathBuf::from) .or_else(|| { - self.state.workspaces.get(ws_idx).and_then(|ws| { - ws.active_tab() - .and_then(|tab| tab.focused_runtime()) - .and_then(|rt| rt.cwd()) - }) + self.state + .focused_runtime_in_workspace(ws_idx) + .and_then(|rt| rt.cwd()) }) .or_else(|| std::env::current_dir().ok()) .unwrap_or_else(|| std::path::PathBuf::from("/")); @@ -653,7 +652,11 @@ impl App { ) }); match result { - Ok(tab_idx) => { + Ok((tab_idx, terminal, runtime)) => { + self.state + .terminal_runtimes + .insert(terminal.id.clone(), runtime); + self.state.terminals.insert(terminal.id.clone(), terminal); if let Some(label) = label { let workspace_id = self.state.workspaces[ws_idx].id.clone(); let tab_id = self @@ -785,6 +788,7 @@ impl App { }) .unwrap(); }; + let terminal_ids = self.state.terminal_ids_for_tab(ws_idx, tab_idx); let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { return serde_json::to_string(&ErrorResponse { id: request.id, @@ -815,6 +819,7 @@ impl App { }) .unwrap(); } + self.state.remove_unattached_terminal_ids(terminal_ids); self.schedule_session_save(); self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::TabClosed, @@ -1004,6 +1009,16 @@ impl App { .unwrap(); }; let (rows, cols) = self.state.estimate_pane_size(); + let split_cwd = params.cwd.map(std::path::PathBuf::from).or_else(|| { + self.state.workspaces.get(ws_idx).and_then(|ws| { + let tab_idx = ws.find_tab_index_for_pane(target_pane_id)?; + ws.tabs.get(tab_idx)?.cwd_for_pane( + target_pane_id, + &self.state.terminals, + &self.state.terminal_runtimes, + ) + }) + }); let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { return serde_json::to_string(&ErrorResponse { id: request.id, @@ -1022,12 +1037,12 @@ impl App { ratatui::layout::Direction::Vertical } }; - let (target_tab_idx, new_pane_id) = match ws.split_pane( + let (target_tab_idx, new_pane) = match ws.split_pane( target_pane_id, direction, rows, cols, - params.cwd.map(std::path::PathBuf::from), + split_cwd, self.state.pane_scrollback_limit_bytes, self.state.host_terminal_theme, params.focus, @@ -1059,8 +1074,14 @@ impl App { self.state.switch_tab(target_tab_idx); self.state.mode = Mode::Terminal; } + self.state + .terminal_runtimes + .insert(new_pane.terminal.id.clone(), new_pane.runtime); + self.state + .terminals + .insert(new_pane.terminal.id.clone(), new_pane.terminal); self.schedule_session_save(); - let pane = self.pane_info(ws_idx, new_pane_id).unwrap(); + let pane = self.pane_info(ws_idx, new_pane.pane_id).unwrap(); self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::PaneCreated, data: crate::api::schema::EventData::PaneCreated { pane: pane.clone() }, @@ -1122,7 +1143,13 @@ impl App { }) .unwrap(); }; - let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { + let Some(terminal_id) = self + .state + .workspaces + .get(ws_idx) + .and_then(|ws| ws.terminal_id(pane_id)) + .cloned() + else { return serde_json::to_string(&ErrorResponse { id: request.id, error: ErrorBody { @@ -1132,7 +1159,7 @@ impl App { }) .unwrap(); }; - let Some(pane_state) = ws.pane_state_mut(pane_id) else { + let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else { return serde_json::to_string(&ErrorResponse { id: request.id, error: ErrorBody { @@ -1143,8 +1170,8 @@ impl App { .unwrap(); }; match params.label.map(|label| label.trim().to_string()) { - Some(label) if !label.is_empty() => pane_state.set_manual_label(label), - _ => pane_state.clear_manual_label(), + Some(label) if !label.is_empty() => terminal.set_manual_label(label), + _ => terminal.clear_manual_label(), } self.state.mark_session_dirty(); let pane = self.pane_info(ws_idx, pane_id).unwrap(); @@ -1419,6 +1446,7 @@ impl App { .unwrap(); }; let workspace_id = self.state.workspaces[ws_idx].id.clone(); + let terminal_id = self.state.terminal_id_for_pane(ws_idx, pane_id); let should_close_workspace = { let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { return serde_json::to_string(&ErrorResponse { @@ -1447,6 +1475,7 @@ impl App { data: crate::api::schema::EventData::WorkspaceClosed { workspace_id }, }); } else { + self.state.remove_unattached_terminal_ids(terminal_id); self.schedule_session_save(); self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::PaneClosed, diff --git a/src/app/api_helpers.rs b/src/app/api_helpers.rs index 56907ece..1ebb30ef 100644 --- a/src/app/api_helpers.rs +++ b/src/app/api_helpers.rs @@ -30,7 +30,7 @@ fn parse_api_key(key: &str) -> Option<crossterm::event::KeyEvent> { } } -pub(super) fn encode_api_text(runtime: &crate::pane::PaneRuntime, text: &str) -> Vec<u8> { +pub(super) fn encode_api_text(runtime: &crate::terminal::TerminalRuntime, text: &str) -> Vec<u8> { let bracketed = runtime .input_state() .map(|state| state.bracketed_paste) @@ -43,7 +43,7 @@ pub(super) fn encode_api_text(runtime: &crate::pane::PaneRuntime, text: &str) -> } pub(super) fn encode_api_keys( - runtime: &crate::pane::PaneRuntime, + runtime: &crate::terminal::TerminalRuntime, keys: &[String], ) -> Result<Vec<Vec<u8>>, String> { let mut encoded_keys = Vec::with_capacity(keys.len()); diff --git a/src/app/creation.rs b/src/app/creation.rs index 6e557066..1832e637 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -78,13 +78,17 @@ impl App { }; let (rows, cols) = self.state.estimate_pane_size(); let ws = &mut self.state.workspaces[ws_idx]; - let idx = ws.create_tab( + let (idx, terminal, runtime) = ws.create_tab( rows, cols, initial_cwd, self.state.pane_scrollback_limit_bytes, self.state.host_terminal_theme, )?; + self.state + .terminal_runtimes + .insert(terminal.id.clone(), runtime); + self.state.terminals.insert(terminal.id.clone(), terminal); if focus { ws.switch_tab(idx); self.state.mode = Mode::Terminal; @@ -105,7 +109,7 @@ impl App { focus: bool, ) -> std::io::Result<usize> { let (rows, cols) = self.state.estimate_pane_size(); - let ws = Workspace::new( + let (ws, terminal, runtime) = Workspace::new( initial_cwd, rows, cols, @@ -115,6 +119,10 @@ impl App { self.render_notify.clone(), self.render_dirty.clone(), )?; + self.state + .terminal_runtimes + .insert(terminal.id.clone(), runtime); + self.state.terminals.insert(terminal.id.clone(), terminal); self.state.workspaces.push(ws); let idx = self.state.workspaces.len() - 1; let workspace_id = self.state.workspaces[idx].id.clone(); @@ -177,7 +185,12 @@ impl App { let (agg_state, seen) = tab .panes .values() - .map(|pane| (pane.state, pane.seen)) + .filter_map(|pane| { + self.state + .terminals + .get(&pane.attached_terminal_id) + .map(|terminal| (terminal.state, pane.seen)) + }) .max_by_key(|(state, seen)| tab_attention_priority(*state, *seen)) .unwrap_or((crate::detect::AgentState::Unknown, true)); Some(crate::api::schema::TabInfo { @@ -230,6 +243,7 @@ impl App { ) -> Option<crate::api::schema::PaneInfo> { let ws = self.state.workspaces.get(ws_idx)?; let pane = ws.pane_state(pane_id)?; + let terminal = self.state.terminals.get(&pane.attached_terminal_id)?; let tab_idx = ws.find_tab_index_for_pane(pane_id)?; let focused = self.state.active == Some(ws_idx) && ws.active_tab == tab_idx @@ -238,18 +252,22 @@ impl App { .is_some_and(|focused| focused == pane_id); Some(crate::api::schema::PaneInfo { pane_id: self.public_pane_id(ws_idx, pane_id)?, - terminal_id: ws.terminal_id(pane_id)?.to_string(), + terminal_id: terminal.id.to_string(), workspace_id: self.public_workspace_id(ws_idx), tab_id: self.public_tab_id(ws_idx, tab_idx)?, focused, cwd: ws.tabs[tab_idx] - .cwd_for_pane(pane_id) + .cwd_for_pane( + pane_id, + &self.state.terminals, + &self.state.terminal_runtimes, + ) .map(|cwd| cwd.display().to_string()), - label: pane.manual_label.clone(), - agent: pane.effective_agent_label().map(str::to_string), - agent_status: pane_agent_status(pane.state, pane.seen), - custom_status: pane.effective_custom_status().map(str::to_string), - revision: 0, + label: terminal.manual_label.clone(), + agent: terminal.effective_agent_label().map(str::to_string), + agent_status: pane_agent_status(terminal.state, pane.seen), + custom_status: terminal.effective_custom_status().map(str::to_string), + revision: terminal.revision, }) } @@ -257,9 +275,8 @@ impl App { &self, ws_idx: usize, pane_id: crate::layout::PaneId, - ) -> Option<(&crate::pane::PaneRuntime, String)> { - let ws = self.state.workspaces.get(ws_idx)?; - let runtime = ws.runtime(pane_id)?; + ) -> Option<(&crate::terminal::TerminalRuntime, String)> { + let runtime = self.state.runtime_for_pane_in_workspace(ws_idx, pane_id)?; Some((runtime, self.public_workspace_id(ws_idx))) } @@ -267,14 +284,13 @@ impl App { &self, ws_idx: usize, pane_id: crate::layout::PaneId, - ) -> Option<&crate::pane::PaneRuntime> { - let ws = self.state.workspaces.get(ws_idx)?; - ws.runtime(pane_id) + ) -> Option<&crate::terminal::TerminalRuntime> { + self.state.runtime_for_pane_in_workspace(ws_idx, pane_id) } pub(super) fn workspace_info(&self, index: usize) -> crate::api::schema::WorkspaceInfo { let ws = &self.state.workspaces[index]; - let (agg_state, seen) = ws.aggregate_state(); + let (agg_state, seen) = ws.aggregate_state(&self.state.terminals); crate::api::schema::WorkspaceInfo { workspace_id: self.public_workspace_id(index), number: index + 1, diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 575b373d..fea88b55 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -82,8 +82,8 @@ impl App { if self.state.mode != Mode::Terminal { return; } - if let Some(ws) = self.state.active.and_then(|i| self.state.workspaces.get(i)) { - if let Some(rt) = ws.focused_runtime() { + if let Some(ws_idx) = self.state.active { + if let Some(rt) = self.state.focused_runtime_in_workspace(ws_idx) { let _ = rt.send_paste(text).await; } } @@ -197,11 +197,17 @@ impl AppState { let cwd = self .active .and_then(|i| self.workspaces.get(i)) - .and_then(|ws| ws.focused_runtime()) - .and_then(|rt| rt.cwd()); + .and_then(|ws| { + let tab = ws.active_tab()?; + tab.cwd_for_pane( + tab.layout.focused(), + &self.terminals, + &self.terminal_runtimes, + ) + }); if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { - if let Ok(new_id) = ws.split_focused( + if let Ok(new_pane) = ws.split_focused( direction, new_rows, new_cols, @@ -209,6 +215,9 @@ impl AppState { self.pane_scrollback_limit_bytes, self.host_terminal_theme, ) { + let new_id = new_pane.pane_id; + self.terminals + .insert(new_pane.terminal.id.clone(), new_pane.terminal); ws.layout.focus_pane(new_id); self.mark_session_dirty(); self.mode = Mode::Terminal; @@ -277,6 +286,8 @@ fn numbered_lines_bytes(count: usize) -> Vec<u8> { fn capture_snapshot(state: &AppState) -> crate::persist::SessionSnapshot { crate::persist::capture( &state.workspaces, + &state.terminals, + &state.terminal_runtimes, state.active, state.selected, state.agent_panel_scope, diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index 367e7e84..6de349cb 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -190,11 +190,14 @@ pub(super) fn open_rename_pane(state: &mut AppState, pane_id: crate::layout::Pan let Some(pane) = ws.pane_state(pane_id) else { return; }; + let terminal = state.terminals.get(&pane.attached_terminal_id); state.creating_new_tab = false; state.requested_new_tab_name = None; state.rename_pane_target = Some(pane_id); - state.name_input = pane.manual_label.clone().unwrap_or_default(); - state.name_input_replace_on_type = pane.manual_label.is_none(); + state.name_input = terminal + .and_then(|t| t.manual_label.clone()) + .unwrap_or_default(); + state.name_input_replace_on_type = terminal.and_then(|t| t.manual_label.as_ref()).is_none(); state.mode = Mode::RenamePane; } @@ -316,10 +319,13 @@ pub(super) fn apply_rename_action(state: &mut AppState, action: ModalAction) { Mode::RenamePane => { if let (Some(ws_idx), Some(pane_id)) = (state.active, state.rename_pane_target) { - if let Some(ws) = state.workspaces.get_mut(ws_idx) { - if let Some(pane) = ws.pane_state_mut(pane_id) { - pane.set_manual_label(new_name); - state.mark_session_dirty(); + if let Some(ws) = state.workspaces.get(ws_idx) { + if let Some(pane) = ws.pane_state(pane_id) { + let terminal_id = pane.attached_terminal_id.clone(); + if let Some(terminal) = state.terminals.get_mut(&terminal_id) { + terminal.set_manual_label(new_name); + state.mark_session_dirty(); + } } } } @@ -468,10 +474,13 @@ pub(super) fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuS } (ContextMenuKind::Pane { pane_id, .. }, Some("Clear pane name")) => { if let Some(ws_idx) = state.active { - if let Some(ws) = state.workspaces.get_mut(ws_idx) { - if let Some(pane) = ws.pane_state_mut(pane_id) { - pane.clear_manual_label(); - state.mark_session_dirty(); + if let Some(ws) = state.workspaces.get(ws_idx) { + if let Some(pane) = ws.pane_state(pane_id) { + let terminal_id = pane.attached_terminal_id.clone(); + if let Some(terminal) = state.terminals.get_mut(&terminal_id) { + terminal.clear_manual_label(); + state.mark_session_dirty(); + } } } } diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index a7a2159a..10ef9c18 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -712,7 +712,8 @@ impl AppState { .active .and_then(|ws_idx| self.workspaces.get(ws_idx)) .and_then(|ws| ws.pane_state(info.id)) - .and_then(|pane| pane.manual_label.as_ref()) + .and_then(|pane| self.terminals.get(&pane.attached_terminal_id)) + .and_then(|terminal| terminal.manual_label.as_ref()) .is_some(); self.context_menu = Some(ContextMenuState { kind: ContextMenuKind::Pane { @@ -1061,16 +1062,16 @@ impl AppState { } pub(super) fn scroll_pane_up(&self, pane_id: crate::layout::PaneId, lines: usize) { - if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { - if let Some(rt) = ws.runtimes.get(&pane_id) { + if let Some(ws_idx) = self.active { + if let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, pane_id) { rt.scroll_up(lines); } } } pub(super) fn scroll_pane_down(&self, pane_id: crate::layout::PaneId, lines: usize) { - if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { - if let Some(rt) = ws.runtimes.get(&pane_id) { + if let Some(ws_idx) = self.active { + if let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, pane_id) { rt.scroll_down(lines); } } @@ -1081,9 +1082,8 @@ impl AppState { pane_id: crate::layout::PaneId, ) -> Option<crate::pane::ScrollMetrics> { self.active - .and_then(|i| self.workspaces.get(i)) - .and_then(|ws| ws.runtime(pane_id)) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + .and_then(|i| self.runtime_for_pane_in_workspace(i, pane_id)) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) } pub(super) fn handle_terminal_wheel(&mut self, mouse: MouseEvent) { @@ -1112,8 +1112,8 @@ impl AppState { return; } - if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { - if let Some(rt) = ws.focused_runtime() { + if let Some(ws_idx) = self.active { + if let Some(rt) = self.focused_runtime_in_workspace(ws_idx) { match mouse.kind { MouseEventKind::ScrollUp => rt.scroll_up(LINES_PER_NOTCH), MouseEventKind::ScrollDown => rt.scroll_down(LINES_PER_NOTCH), @@ -1124,10 +1124,10 @@ impl AppState { } pub(super) fn forward_pane_mouse_button(&self, info: &PaneInfo, mouse: MouseEvent) -> bool { - let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else { + let Some(ws_idx) = self.active else { return false; }; - let Some(rt) = ws.runtimes.get(&info.id) else { + let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, info.id) else { return false; }; let column = mouse.column.saturating_sub(info.inner_rect.x); @@ -1169,10 +1169,10 @@ impl AppState { } pub(super) fn forward_pane_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool { - let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else { + let Some(ws_idx) = self.active else { return false; }; - let Some(rt) = ws.runtimes.get(&info.id) else { + let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, info.id) else { return false; }; match rt.wheel_routing() { @@ -1209,8 +1209,8 @@ impl AppState { pane_id: crate::layout::PaneId, offset_from_bottom: usize, ) { - if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { - if let Some(rt) = ws.runtimes.get(&pane_id) { + if let Some(ws_idx) = self.active { + if let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, pane_id) { rt.set_scroll_offset_from_bottom(offset_from_bottom); } } @@ -1221,7 +1221,7 @@ impl AppState { col: u16, row: u16, ) -> Option<(crate::layout::PaneId, ScrollbarClickTarget)> { - let ws = self.active.and_then(|i| self.workspaces.get(i))?; + let ws_idx = self.active?; let info = self.view.pane_infos.iter().find(|info| { crate::ui::pane_scrollbar_rect(info).is_some_and(|track| { col >= track.x @@ -1230,7 +1230,7 @@ impl AppState { && row < track.y + track.height }) })?; - let rt = ws.runtimes.get(&info.id)?; + let rt = self.runtime_for_pane_in_workspace(ws_idx, info.id)?; let metrics = rt.scroll_metrics()?; if metrics.max_offset_from_bottom == 0 { return None; @@ -1254,14 +1254,14 @@ impl AppState { row: u16, grab_row_offset: u16, ) -> Option<usize> { - let ws = self.active.and_then(|i| self.workspaces.get(i))?; + let ws_idx = self.active?; let info = self .view .pane_infos .iter() .find(|info| info.id == pane_id)?; let track = crate::ui::pane_scrollbar_rect(info)?; - let rt = ws.runtimes.get(&pane_id)?; + let rt = self.runtime_for_pane_in_workspace(ws_idx, pane_id)?; let metrics = rt.scroll_metrics()?; if metrics.max_offset_from_bottom == 0 { return None; @@ -1345,12 +1345,19 @@ mod tests { background.tabs[0].layout.focus_pane(first_pane); app.state.workspaces = vec![active, background]; + app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; - app.state.workspaces[1] + let target_terminal_id = app.state.workspaces[1] .panes - .get_mut(&target_pane) + .get(&target_pane) + .unwrap() + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&target_terminal_id) .unwrap() .state = AgentState::Working; @@ -1606,17 +1613,17 @@ mod tests { .expect("second pane info") .clone(); - app.state.workspaces[0].tabs[0].runtimes.insert( + app.state.insert_test_runtime( first_pane, - crate::pane::PaneRuntime::test_with_screen_bytes( + crate::terminal::TerminalRuntime::test_with_screen_bytes( first_info.inner_rect.width.max(1), first_info.inner_rect.height.max(1), b"\x1b[?1002h", ), ); - app.state.workspaces[0].tabs[0].runtimes.insert( + app.state.insert_test_runtime( second_pane, - crate::pane::PaneRuntime::test_with_screen_bytes( + crate::terminal::TerminalRuntime::test_with_screen_bytes( second_info.inner_rect.width.max(1), second_info.inner_rect.height.max(1), b"\x1b[?1002h", @@ -1675,17 +1682,17 @@ mod tests { .expect("second pane info") .clone(); - app.state.workspaces[0].tabs[0].runtimes.insert( + app.state.insert_test_runtime( first_pane, - crate::pane::PaneRuntime::test_with_screen_bytes( + crate::terminal::TerminalRuntime::test_with_screen_bytes( first_info.inner_rect.width.max(1), first_info.inner_rect.height.max(1), b"\x1b[?1002h", ), ); - app.state.workspaces[0].tabs[0].runtimes.insert( + app.state.insert_test_runtime( second_pane, - crate::pane::PaneRuntime::test_with_screen_bytes( + crate::terminal::TerminalRuntime::test_with_screen_bytes( second_info.inner_rect.width.max(1), second_info.inner_rect.height.max(1), b"\x1b[?1002h", diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index f5a0bcaa..9fc11605 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -126,10 +126,10 @@ impl App { } fn pass_through_key_to_focused_pane(&mut self, key: TerminalKey) -> bool { - let Some(ws) = self.state.active.and_then(|i| self.state.workspaces.get(i)) else { + let Some(ws_idx) = self.state.active else { return false; }; - let Some(rt) = ws.focused_runtime() else { + let Some(rt) = self.state.focused_runtime_in_workspace(ws_idx) else { return false; }; @@ -191,10 +191,13 @@ impl App { if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { env.push(("HERDR_ACTIVE_PANE_ID".to_string(), public_pane_id)); } - if let Some(pane_cwd) = workspace - .active_tab() - .and_then(|tab| tab.cwd_for_pane(pane_id)) - { + if let Some(pane_cwd) = workspace.active_tab().and_then(|tab| { + tab.cwd_for_pane( + pane_id, + &self.state.terminals, + &self.state.terminal_runtimes, + ) + }) { env.push(( "HERDR_ACTIVE_PANE_CWD".to_string(), pane_cwd.display().to_string(), @@ -308,10 +311,14 @@ impl App { .focused_pane_id() .ok_or_else(|| std::io::Error::other("no focused pane"))?; let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false); - let cwd = ws - .active_tab() - .and_then(|tab| tab.cwd_for_pane(previous_focus)); - let new_pane_id = ws.split_focused_command( + let cwd = ws.active_tab().and_then(|tab| { + tab.cwd_for_pane( + previous_focus, + &self.state.terminals, + &self.state.terminal_runtimes, + ) + }); + let new_pane = ws.split_focused_command( Direction::Horizontal, new_rows, new_cols, @@ -321,6 +328,10 @@ impl App { self.state.pane_scrollback_limit_bytes, self.state.host_terminal_theme, )?; + let new_pane_id = new_pane.pane_id; + self.state + .terminals + .insert(new_pane.terminal.id.clone(), new_pane.terminal); ws.active_tab_mut() .expect("workspace must have an active tab") .layout @@ -1072,7 +1083,7 @@ mod tests { api_rx, crate::api::EventHub::default(), ); - let workspace = Workspace::new( + let (workspace, terminal, runtime) = Workspace::new( std::env::current_dir().unwrap_or_else(|_| "/".into()), 24, 80, @@ -1084,6 +1095,10 @@ mod tests { ) .expect("workspace should spawn"); app.state.workspaces = vec![workspace]; + app.state + .terminal_runtimes + .insert(terminal.id.clone(), runtime); + app.state.terminals.insert(terminal.id.clone(), terminal); app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs index 8f6c7792..e8e1e094 100644 --- a/src/app/input/sidebar.rs +++ b/src/app/input/sidebar.rs @@ -351,7 +351,7 @@ impl AppState { let ws_idx = self.collapsed_detail_workspace_idx()?; let ws = self.workspaces.get(ws_idx)?; let detail_idx = (row - detail_content_area.y) as usize; - let details = ws.pane_details(); + let details = ws.pane_details(&self.terminals); let detail = details.get(detail_idx)?; Some((ws_idx, detail.tab_idx, detail.pane_id)) } @@ -633,19 +633,26 @@ mod tests { let mut ws = Workspace::test_new("test"); ws.tabs[0].set_custom_name("main".into()); let first_pane = ws.tabs[0].root_pane; - ws.tabs[0] - .panes - .get_mut(&first_pane) + let first_tab = ws.test_add_tab(Some("logs")); + let second_pane = ws.tabs[first_tab].root_pane; + app.state.workspaces = vec![ws]; + app.state.ensure_test_terminals(); + let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&first_terminal_id) .unwrap() .detected_agent = Some(Agent::Pi); - let second_tab = ws.test_add_tab(Some("logs")); - let second_pane = ws.tabs[second_tab].root_pane; - ws.tabs[second_tab] - .panes - .get_mut(&second_pane) + let second_terminal_id = app.state.workspaces[0].tabs[first_tab].panes[&second_pane] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&second_terminal_id) .unwrap() .detected_agent = Some(Agent::Claude); - app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; @@ -659,9 +666,9 @@ mod tests { ); assert_eq!(app.state.mode, Mode::Terminal); let snapshot = capture_snapshot(&app.state); - assert_eq!(snapshot.workspaces[0].active_tab, second_tab); + assert_eq!(snapshot.workspaces[0].active_tab, first_tab); assert_eq!( - snapshot.workspaces[0].tabs[second_tab].focused, + snapshot.workspaces[0].tabs[first_tab].focused, Some(second_pane.raw()) ); } @@ -701,23 +708,30 @@ mod tests { #[test] fn clicking_all_workspaces_agent_row_switches_to_correct_workspace() { let mut app = app_for_mouse_test(); - let mut first = Workspace::test_new("one"); + let first = Workspace::test_new("one"); let first_pane = first.tabs[0].root_pane; - first.tabs[0] - .panes - .get_mut(&first_pane) - .unwrap() - .detected_agent = Some(Agent::Pi); - let mut second = Workspace::test_new("two"); + let second = Workspace::test_new("two"); let second_pane = second.tabs[0].root_pane; - second.tabs[0] - .panes - .get_mut(&second_pane) - .unwrap() - .detected_agent = Some(Agent::Claude); app.state.workspaces = vec![first, second]; + app.state.ensure_test_terminals(); + let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&first_terminal_id) + .unwrap() + .detected_agent = Some(Agent::Pi); + let second_terminal_id = app.state.workspaces[1].tabs[0].panes[&second_pane] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&second_terminal_id) + .unwrap() + .detected_agent = Some(Agent::Claude); app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; @@ -747,12 +761,8 @@ mod tests { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); let first_pane = ws.tabs[0].root_pane; - ws.tabs[0] - .panes - .get_mut(&first_pane) - .unwrap() - .detected_agent = Some(Agent::Pi); + let mut tabs = Vec::new(); for (tab_name, agent) in [ ("logs", Agent::Claude), ("review", Agent::Codex), @@ -760,14 +770,29 @@ mod tests { ] { let tab_idx = ws.test_add_tab(Some(tab_name)); let pane_id = ws.tabs[tab_idx].root_pane; - ws.tabs[tab_idx] - .panes - .get_mut(&pane_id) - .unwrap() - .detected_agent = Some(agent); + tabs.push((tab_idx, pane_id, agent)); } app.state.workspaces = vec![ws]; + app.state.ensure_test_terminals(); + let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&first_terminal_id) + .unwrap() + .detected_agent = Some(Agent::Pi); + for (tab_idx, pane_id, agent) in tabs { + let terminal_id = app.state.workspaces[0].tabs[tab_idx].panes[&pane_id] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .detected_agent = Some(agent); + } app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; @@ -792,31 +817,43 @@ mod tests { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); let first_pane = ws.tabs[0].root_pane; - ws.tabs[0] - .panes - .get_mut(&first_pane) - .unwrap() - .detected_agent = Some(Agent::Pi); - let second_tab = ws.test_add_tab(Some("logs")); let second_pane = ws.tabs[second_tab].root_pane; - ws.tabs[second_tab] - .panes - .get_mut(&second_pane) - .unwrap() - .detected_agent = Some(Agent::Claude); - + let mut extra_tabs = Vec::new(); for (tab_name, agent) in [("review", Agent::Codex), ("ops", Agent::Gemini)] { let tab_idx = ws.test_add_tab(Some(tab_name)); let pane_id = ws.tabs[tab_idx].root_pane; - ws.tabs[tab_idx] - .panes - .get_mut(&pane_id) - .unwrap() - .detected_agent = Some(agent); + extra_tabs.push((tab_idx, pane_id, agent)); } app.state.workspaces = vec![ws]; + app.state.ensure_test_terminals(); + let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&first_terminal_id) + .unwrap() + .detected_agent = Some(Agent::Pi); + let second_terminal_id = app.state.workspaces[0].tabs[second_tab].panes[&second_pane] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&second_terminal_id) + .unwrap() + .detected_agent = Some(Agent::Claude); + for (tab_idx, pane_id, agent) in extra_tabs { + let terminal_id = app.state.workspaces[0].tabs[tab_idx].panes[&pane_id] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .detected_agent = Some(agent); + } app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; @@ -843,19 +880,26 @@ mod tests { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); let first_pane = ws.tabs[0].root_pane; - ws.tabs[0] - .panes - .get_mut(&first_pane) - .unwrap() - .detected_agent = Some(Agent::Pi); let second_tab = ws.test_add_tab(Some("logs")); let second_pane = ws.tabs[second_tab].root_pane; - ws.tabs[second_tab] - .panes - .get_mut(&second_pane) + app.state.workspaces = vec![ws]; + app.state.ensure_test_terminals(); + let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&first_terminal_id) + .unwrap() + .detected_agent = Some(Agent::Pi); + let second_terminal_id = app.state.workspaces[0].tabs[second_tab].panes[&second_pane] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&second_terminal_id) .unwrap() .detected_agent = Some(Agent::Claude); - app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; @@ -1128,20 +1172,27 @@ mod tests { let mut first = Workspace::test_new("a"); let first_root = first.tabs[0].root_pane; first.identity_cwd = first_repo.clone(); - first.tabs[0] - .pane_cwds - .insert(first_root, first_repo.clone()); first.refresh_git_ahead_behind(); let mut second = Workspace::test_new("b"); let second_root = second.tabs[0].root_pane; second.identity_cwd = second_repo.clone(); - second.tabs[0] - .pane_cwds - .insert(second_root, second_repo.clone()); second.refresh_git_ahead_behind(); app.state.workspaces = vec![first, second]; + app.state.ensure_test_terminals(); + let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_root] + .attached_terminal_id + .clone(); + app.state.terminals.get_mut(&first_terminal_id).unwrap().cwd = first_repo.clone(); + let second_terminal_id = app.state.workspaces[1].tabs[0].panes[&second_root] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&second_terminal_id) + .unwrap() + .cwd = second_repo.clone(); crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); assert_eq!(app.state.workspace_drop_index_at_row(0), Some(0)); diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index 22cf3169..3f0bff2c 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -64,7 +64,7 @@ impl App { let ws_idx = self.state.active?; let ws = self.state.workspaces.get(ws_idx)?; let pane_id = ws.focused_pane_id()?; - let rt = ws.runtimes.get(&pane_id)?; + let rt = self.state.runtime_for_pane_in_workspace(ws_idx, pane_id)?; // Intercept plain PageUp/PageDown presses for pane scrollback when the // focused pane doesn't handle its own scrolling (e.g., a plain shell @@ -179,9 +179,9 @@ mod tests { let pane_id = ws.tabs[0].root_pane; let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); let info = pane_infos[0].clone(); - ws.tabs[0].runtimes.insert( + ws.insert_test_runtime( pane_id, - crate::pane::PaneRuntime::test_with_scrollback_bytes( + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 16 * 1024, @@ -195,9 +195,10 @@ mod tests { app.state.mode = Mode::Terminal; app.state.view.pane_infos = pane_infos; - let start_metrics = app.state.workspaces[0] - .runtime(pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + let start_metrics = app + .state + .runtime_for_pane(pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("initial scroll metrics"); let start_row = info.inner_rect.y; let start_col = info.inner_rect.x + 2; @@ -213,9 +214,10 @@ mod tests { info.inner_rect.y.saturating_sub(1), )); - let end_metrics = app.state.workspaces[0] - .runtime(pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + let end_metrics = app + .state + .runtime_for_pane(pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after drag"); assert_eq!( end_metrics.offset_from_bottom, @@ -243,9 +245,9 @@ mod tests { let pane_id = ws.tabs[0].root_pane; let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); let info = pane_infos[0].clone(); - ws.tabs[0].runtimes.insert( + ws.insert_test_runtime( pane_id, - crate::pane::PaneRuntime::test_with_scrollback_bytes( + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 16 * 1024, @@ -283,9 +285,9 @@ mod tests { let pane_id = ws.tabs[0].root_pane; let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); let info = pane_infos[0].clone(); - ws.tabs[0].runtimes.insert( + ws.insert_test_runtime( pane_id, - crate::pane::PaneRuntime::test_with_scrollback_bytes( + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 16 * 1024, @@ -299,9 +301,10 @@ mod tests { app.state.mode = Mode::Terminal; app.state.view.pane_infos = pane_infos; - let start_metrics = app.state.workspaces[0] - .runtime(pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + let start_metrics = app + .state + .runtime_for_pane(pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("initial scroll metrics"); let top_row = info.inner_rect.y; let col = info.inner_rect.x + 2; @@ -309,9 +312,10 @@ mod tests { app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, top_row)); app.handle_mouse(mouse(MouseEventKind::ScrollUp, col, top_row)); - let end_metrics = app.state.workspaces[0] - .runtime(pane_id) - .and_then(crate::pane::PaneRuntime::scroll_metrics) + let end_metrics = app + .state + .runtime_for_pane(pane_id) + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after wheel"); assert_eq!( end_metrics.offset_from_bottom, @@ -352,17 +356,17 @@ mod tests { .unwrap() .clone(); - ws.tabs[0].runtimes.insert( + ws.insert_test_runtime( first_pane, - crate::pane::PaneRuntime::test_with_screen_bytes( + crate::terminal::TerminalRuntime::test_with_screen_bytes( first_info.inner_rect.width.max(1), first_info.inner_rect.height.max(1), b"", ), ); - ws.tabs[0].runtimes.insert( + ws.insert_test_runtime( second_pane, - crate::pane::PaneRuntime::test_with_screen_bytes( + crate::terminal::TerminalRuntime::test_with_screen_bytes( second_info.inner_rect.width.max(1), second_info.inner_rect.height.max(1), b"\x1b[?1002h", @@ -410,17 +414,17 @@ mod tests { .unwrap() .clone(); - ws.tabs[0].runtimes.insert( + ws.insert_test_runtime( first_pane, - crate::pane::PaneRuntime::test_with_screen_bytes( + crate::terminal::TerminalRuntime::test_with_screen_bytes( first_info.inner_rect.width.max(1), first_info.inner_rect.height.max(1), b"", ), ); - ws.tabs[0].runtimes.insert( + ws.insert_test_runtime( second_pane, - crate::pane::PaneRuntime::test_with_screen_bytes( + crate::terminal::TerminalRuntime::test_with_screen_bytes( second_info.inner_rect.width.max(1), second_info.inner_rect.height.max(1), b"\x1b[?1002h", diff --git a/src/app/mod.rs b/src/app/mod.rs index d37bcb5d..b1fb9775 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -207,6 +207,8 @@ impl App { let render_dirty = Arc::new(AtomicBool::new(false)); // Try to restore previous session + let mut restored_terminals = std::collections::HashMap::new(); + let mut restored_terminal_runtimes = std::collections::HashMap::new(); let ( workspaces, active, @@ -226,7 +228,7 @@ impl App { 0.5_f32, ) } else if let Some(snap) = crate::persist::load() { - let ws = crate::persist::restore( + let (ws, terminals, terminal_runtimes) = crate::persist::restore( &snap, 24, 80, @@ -235,6 +237,8 @@ impl App { render_notify.clone(), render_dirty.clone(), ); + restored_terminals = terminals; + restored_terminal_runtimes = terminal_runtimes; if ws.is_empty() { crate::logging::session_restored(0, "empty"); ( @@ -306,6 +310,8 @@ impl App { }; let mut state = AppState { + terminals: std::collections::HashMap::new(), + terminal_runtimes: std::collections::HashMap::new(), workspaces, active, selected, @@ -402,6 +408,9 @@ impl App { session_dirty: false, }; + state.terminals = restored_terminals; + state.terminal_runtimes = restored_terminal_runtimes; + for ws in &mut state.workspaces { ws.refresh_git_branch(); } @@ -857,7 +866,9 @@ impl App { if let Some(ws_idx) = self.state.active { if let Some(ws) = self.state.workspaces.get(ws_idx) { if let Some(focused) = ws.focused_pane_id() { - if let Some(runtime) = ws.runtimes.get(&focused) { + if let Some(runtime) = + self.state.runtime_for_pane_in_workspace(ws_idx, focused) + { let _ = runtime.try_send_bytes(bytes::Bytes::from( if runtime .input_state() @@ -945,7 +956,7 @@ mod tests { use super::*; use crate::config::Config; use crate::detect::{Agent, AgentState}; - use crate::pane::PaneRuntime; + use crate::terminal::TerminalRuntime; use crate::workspace::Workspace; use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; use std::sync::{Mutex, OnceLock}; @@ -1369,22 +1380,44 @@ mod tests { let background_tab = workspace.test_add_tab(Some("background")); let background_pane = workspace.tabs[background_tab].root_pane; - workspace.tabs[0].panes.get_mut(&root_pane).unwrap().state = AgentState::Idle; - workspace.tabs[0].panes.get_mut(&root_pane).unwrap().seen = false; - workspace.tabs[0].panes.get_mut(&split_pane).unwrap().state = AgentState::Idle; - workspace.tabs[0].panes.get_mut(&split_pane).unwrap().seen = false; - workspace.tabs[background_tab] + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + let root_terminal_id = app.state.workspaces[0].tabs[0].panes[&root_pane] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&root_terminal_id) + .unwrap() + .state = AgentState::Idle; + app.state.workspaces[0].tabs[0] .panes - .get_mut(&background_pane) + .get_mut(&root_pane) + .unwrap() + .seen = false; + let split_terminal_id = app.state.workspaces[0].tabs[0].panes[&split_pane] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&split_terminal_id) .unwrap() .state = AgentState::Idle; - workspace.tabs[background_tab] + app.state.workspaces[0].tabs[0] + .panes + .get_mut(&split_pane) + .unwrap() + .seen = false; + let bg_terminal_id = app.state.workspaces[0].tabs[background_tab].panes[&background_pane] + .attached_terminal_id + .clone(); + app.state.terminals.get_mut(&bg_terminal_id).unwrap().state = AgentState::Idle; + app.state.workspaces[0].tabs[background_tab] .panes .get_mut(&background_pane) .unwrap() .seen = false; - app.state.workspaces = vec![workspace]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; @@ -1498,6 +1531,7 @@ mod tests { fn workspace_create_response_includes_initial_tab_and_root_pane() { let mut app = test_app(); app.state.workspaces = vec![Workspace::test_new("api-root-pane")]; + app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; @@ -1524,6 +1558,7 @@ mod tests { let mut workspace = Workspace::test_new("api-tab-root-pane"); workspace.test_add_tab(None); app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; @@ -1542,15 +1577,9 @@ mod tests { fn workspace_creation_in_navigate_mode_uses_selected_workspace_seed_cwd() { let mut app = test_app(); let mut first = Workspace::test_new("herdr"); - let first_root = first.tabs[0].root_pane; - first.tabs[0] - .pane_cwds - .insert(first_root, std::path::PathBuf::from("/tmp/herdr")); + first.identity_cwd = std::path::PathBuf::from("/tmp/herdr"); let mut second = Workspace::test_new("pion"); - let second_root = second.tabs[0].root_pane; - second.tabs[0] - .pane_cwds - .insert(second_root, std::path::PathBuf::from("/tmp/pion")); + second.identity_cwd = std::path::PathBuf::from("/tmp/pion"); app.state.workspaces = vec![first, second]; app.state.active = Some(0); @@ -1586,6 +1615,7 @@ mod tests { let workspace = Workspace::test_new("api-pane-rename"); let pane = workspace.tabs[0].root_pane; app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; @@ -1601,9 +1631,15 @@ mod tests { assert_eq!(response["result"]["type"], "pane_info"); assert_eq!(response["result"]["pane"]["label"], "reviewer"); + let terminal_id = app.state.workspaces[0] + .pane_state(pane) + .unwrap() + .attached_terminal_id + .clone(); assert_eq!( - app.state.workspaces[0] - .pane_state(pane) + app.state + .terminals + .get(&terminal_id) .unwrap() .manual_label .as_deref(), @@ -1621,8 +1657,10 @@ mod tests { assert_eq!(response["result"]["type"], "pane_info"); assert!(response["result"]["pane"].get("label").is_none()); - assert!(app.state.workspaces[0] - .pane_state(pane) + assert!(app + .state + .terminals + .get(&terminal_id) .unwrap() .manual_label .is_none()); @@ -1665,11 +1703,21 @@ mod tests { #[test] fn terminal_target_resolves_unique_manual_name() { let mut app = test_app(); - let mut workspace = Workspace::test_new("terminal-target-name"); + let workspace = Workspace::test_new("terminal-target-name"); let pane = workspace.tabs[0].root_pane; - workspace.pane_state_mut(pane).unwrap().manual_label = Some("reviewer".into()); let terminal_id = workspace.terminal_id(pane).unwrap().to_string(); app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + let attached_terminal_id = app.state.workspaces[0] + .pane_state(pane) + .unwrap() + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&attached_terminal_id) + .unwrap() + .manual_label = Some("reviewer".into()); app.state.active = Some(0); app.state.selected = 0; @@ -1702,9 +1750,28 @@ mod tests { let mut workspace = Workspace::test_new("terminal-target-ambiguous"); let first = workspace.tabs[0].root_pane; let second = workspace.test_split(ratatui::layout::Direction::Horizontal); - workspace.pane_state_mut(first).unwrap().manual_label = Some("worker".into()); - workspace.pane_state_mut(second).unwrap().manual_label = Some("worker".into()); app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + let first_terminal_id = app.state.workspaces[0] + .pane_state(first) + .unwrap() + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&first_terminal_id) + .unwrap() + .manual_label = Some("worker".into()); + let second_terminal_id = app.state.workspaces[0] + .pane_state(second) + .unwrap() + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&second_terminal_id) + .unwrap() + .manual_label = Some("worker".into()); app.state.active = Some(0); app.state.selected = 0; @@ -1741,6 +1808,18 @@ mod tests { workspace.test_split(ratatui::layout::Direction::Horizontal); workspace.switch_tab(0); app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + let split_cwd = std::env::temp_dir(); + let target_terminal_id = app.state.workspaces[0] + .pane_state(target_pane) + .unwrap() + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&target_terminal_id) + .unwrap() + .cwd = split_cwd.clone(); app.state.active = Some(0); app.state.selected = 0; @@ -1761,6 +1840,10 @@ mod tests { assert_eq!(response["result"]["type"], "pane_info"); assert_eq!(response["result"]["pane"]["tab_id"], target_tab_id); + assert_eq!( + response["result"]["pane"]["cwd"], + split_cwd.display().to_string() + ); assert_eq!(response["result"]["pane"]["focused"], false); assert_eq!(app.state.active, Some(0)); assert_eq!(app.state.workspaces[0].active_tab, 0); @@ -1782,13 +1865,9 @@ mod tests { 3 ); - let runtimes: Vec<_> = app.state.workspaces[0] - .tabs - .iter_mut() - .flat_map(|tab| tab.runtimes.drain()) - .collect(); - for (pane_id, runtime) in runtimes { - runtime.shutdown(pane_id); + let runtimes: Vec<_> = app.state.terminal_runtimes.drain().collect(); + for (_terminal_id, runtime) in runtimes { + runtime.shutdown(); } match original_shell { Some(value) => std::env::set_var("SHELL", value), @@ -1807,6 +1886,7 @@ mod tests { let background_tab = workspace.test_add_tab(Some("worker")); workspace.switch_tab(0); app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; @@ -1832,13 +1912,9 @@ mod tests { assert_eq!(app.state.active, Some(0)); assert_eq!(app.state.workspaces[0].active_tab, background_tab); - let runtimes: Vec<_> = app.state.workspaces[0] - .tabs - .iter_mut() - .flat_map(|tab| tab.runtimes.drain()) - .collect(); - for (pane_id, runtime) in runtimes { - runtime.shutdown(pane_id); + let runtimes: Vec<_> = app.state.terminal_runtimes.drain().collect(); + for (_terminal_id, runtime) in runtimes { + runtime.shutdown(); } match original_shell { Some(value) => std::env::set_var("SHELL", value), @@ -1853,6 +1929,7 @@ mod tests { let second_tab = workspace.test_add_tab(Some("logs")); workspace.switch_tab(second_tab); app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; @@ -1878,6 +1955,7 @@ mod tests { let mut app = test_app(); let workspace = Workspace::test_new("api-pane-close-last"); app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; @@ -1968,17 +2046,23 @@ mod tests { let pane_id = ws.tabs[0].root_pane; app.state.workspaces = vec![ws]; + app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; + let terminal_id = app.state.workspaces[0] + .pane_state(pane_id) + .unwrap() + .attached_terminal_id + .clone(); app.handle_internal_event(AppEvent::StateChanged { pane_id, agent: Some(Agent::Pi), state: AgentState::Working, }); assert_eq!( - app.state.workspaces[0].pane_state(pane_id).unwrap().state, + app.state.terminals.get(&terminal_id).unwrap().state, AgentState::Working ); @@ -2016,7 +2100,7 @@ mod tests { app.drain_internal_events(); assert_eq!( - app.state.workspaces[0].pane_state(pane_id).unwrap().state, + app.state.terminals.get(&terminal_id).unwrap().state, AgentState::Idle, "Working→Idle should still apply after temporary queue pressure" ); @@ -2116,7 +2200,7 @@ mod tests { let mut app = test_app(); let mut workspace = Workspace::test_new("test"); let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = PaneRuntime::test_with_channel(80, 24); + let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24); workspace.tabs[0].runtimes.insert(focused, runtime); app.state.workspaces = vec![workspace]; app.state.active = Some(0); @@ -2138,7 +2222,7 @@ mod tests { let mut app = test_app(); let mut workspace = Workspace::test_new("test"); let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = PaneRuntime::test_with_channel(80, 24); + let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24); workspace.tabs[0].runtimes.insert(focused, runtime); app.state.workspaces = vec![workspace]; app.state.active = Some(0); @@ -2157,7 +2241,7 @@ mod tests { let mut app = test_app(); let mut workspace = Workspace::test_new("test"); let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = PaneRuntime::test_with_channel(80, 24); + let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24); workspace.tabs[0].runtimes.insert(focused, runtime); app.state.workspaces = vec![workspace]; app.state.active = Some(0); @@ -2178,7 +2262,7 @@ mod tests { let focused = workspace.focused_pane_id().unwrap(); let text = "中日한🙂"; let (runtime, mut rx) = - PaneRuntime::test_with_channel_capacity(80, 24, text.chars().count()); + TerminalRuntime::test_with_channel_capacity(80, 24, text.chars().count()); workspace.tabs[0].runtimes.insert(focused, runtime); app.state.workspaces = vec![workspace]; app.state.active = Some(0); @@ -2203,7 +2287,7 @@ mod tests { let focused = workspace.focused_pane_id().unwrap(); let text = "你好,今天我们测试一段比较长的语音输入。こんにちは。안녕하세요.🙂".repeat(64); let char_count = text.chars().count(); - let (runtime, mut rx) = PaneRuntime::test_with_channel_capacity(80, 24, char_count); + let (runtime, mut rx) = TerminalRuntime::test_with_channel_capacity(80, 24, char_count); workspace.tabs[0].runtimes.insert(focused, runtime); app.state.workspaces = vec![workspace]; app.state.active = Some(0); diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 4d2f6c47..98ae4e0e 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -198,12 +198,12 @@ impl App { .state .active .and_then(|idx| self.state.workspaces.get(idx)) - .is_some_and(Workspace::has_working_pane), + .is_some_and(|ws| ws.has_working_pane(&self.state.terminals)), crate::app::state::AgentPanelScope::AllWorkspaces => self .state .workspaces .iter() - .any(Workspace::has_working_pane), + .any(|ws| ws.has_working_pane(&self.state.terminals)), } } diff --git a/src/app/session.rs b/src/app/session.rs index c075fd4c..85bdd1bb 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -27,6 +27,8 @@ impl App { } else { let snap = crate::persist::capture( &self.state.workspaces, + &self.state.terminals, + &self.state.terminal_runtimes, self.state.active, self.state.selected, self.state.agent_panel_scope, diff --git a/src/app/state.rs b/src/app/state.rs index 45473435..daed47f0 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -818,6 +818,10 @@ pub enum SidebarWidthSource { /// All application state — pure data, no channels or async runtime. /// Testable without PTYs or a tokio runtime. pub struct AppState { + pub terminals: + std::collections::HashMap<crate::terminal::TerminalId, crate::terminal::TerminalState>, + pub terminal_runtimes: + std::collections::HashMap<crate::terminal::TerminalId, crate::terminal::TerminalRuntime>, pub workspaces: Vec<Workspace>, pub active: Option<usize>, pub selected: usize, @@ -953,6 +957,57 @@ impl AppState { /// Returns true when the given (workspace, tab, pane) refers to the /// currently focused pane in the active workspace's active tab. + pub(crate) fn runtime_for_pane_in_workspace( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + ) -> Option<&crate::terminal::TerminalRuntime> { + #[cfg(test)] + if let Some(runtime) = self.workspaces.get(ws_idx)?.test_runtimes.get(&pane_id) { + return Some(runtime); + } + #[cfg(test)] + if let Some(runtime) = self + .workspaces + .get(ws_idx)? + .tabs + .iter() + .find_map(|tab| tab.runtimes.get(&pane_id)) + { + return Some(runtime); + } + let terminal_id = self.workspaces.get(ws_idx)?.terminal_id(pane_id)?; + self.terminal_runtimes.get(terminal_id) + } + + #[cfg(test)] + pub(crate) fn runtime_for_pane( + &self, + pane_id: crate::layout::PaneId, + ) -> Option<&crate::terminal::TerminalRuntime> { + self.workspaces.iter().find_map(|ws| { + #[cfg(test)] + if let Some(runtime) = ws.test_runtimes.get(&pane_id) { + return Some(runtime); + } + #[cfg(test)] + if let Some(runtime) = ws.tabs.iter().find_map(|tab| tab.runtimes.get(&pane_id)) { + return Some(runtime); + } + let terminal_id = ws.terminal_id(pane_id)?; + self.terminal_runtimes.get(terminal_id) + }) + } + + pub(crate) fn focused_runtime_in_workspace( + &self, + ws_idx: usize, + ) -> Option<&crate::terminal::TerminalRuntime> { + let ws = self.workspaces.get(ws_idx)?; + let pane_id = ws.focused_pane_id()?; + self.runtime_for_pane_in_workspace(ws_idx, pane_id) + } + pub fn is_active_pane( &self, ws_idx: usize, @@ -1003,6 +1058,8 @@ impl AppState { /// Create an AppState for testing — no channels, no PTYs. pub fn test_new() -> Self { Self { + terminals: std::collections::HashMap::new(), + terminal_runtimes: std::collections::HashMap::new(), workspaces: Vec::new(), active: None, selected: 0, @@ -1155,6 +1212,40 @@ impl AppState { session_dirty: false, } } + + /// Populate missing `TerminalState` entries for every pane so tests that + /// read or write terminal metadata don't need to manually create them. + pub fn ensure_test_terminals(&mut self) { + use crate::terminal::TerminalState; + for ws in &self.workspaces { + for tab in &ws.tabs { + for pane in tab.panes.values() { + if !self.terminals.contains_key(&pane.attached_terminal_id) { + let cwd = ws.identity_cwd.clone(); + self.terminals.insert( + pane.attached_terminal_id.clone(), + TerminalState::new(pane.attached_terminal_id.clone(), cwd), + ); + } + } + } + } + } + + pub fn insert_test_runtime( + &mut self, + pane_id: crate::layout::PaneId, + runtime: crate::terminal::TerminalRuntime, + ) { + let Some(terminal_id) = self + .workspaces + .iter() + .find_map(|ws| ws.terminal_id(pane_id).cloned()) + else { + return; + }; + self.terminal_runtimes.insert(terminal_id, runtime); + } } #[cfg(test)] diff --git a/src/app/terminal_targets.rs b/src/app/terminal_targets.rs index cf90717d..94cf89d0 100644 --- a/src/app/terminal_targets.rs +++ b/src/app/terminal_targets.rs @@ -61,9 +61,11 @@ impl App { .terminal_targets() .into_iter() .filter(|candidate| { - self.state.workspaces[candidate.ws_idx] - .pane_state(candidate.pane_id) - .and_then(|pane| pane.manual_label.as_deref()) + self.state + .terminals + .values() + .find(|terminal| terminal.id.to_string() == candidate.terminal_id) + .and_then(|terminal| terminal.manual_label.as_deref()) .is_some_and(|label| label == target) }) .collect(); @@ -151,15 +153,20 @@ impl App { let ws = self.state.workspaces.get(ws_idx)?; let tab_idx = ws.find_tab_index_for_pane(pane_id)?; let pane = ws.pane_state(pane_id)?; + let terminal = self.state.terminals.get(&pane.attached_terminal_id)?; Some(TerminalTargetCandidate { - terminal_id: ws.terminal_id(pane_id)?.to_string(), + terminal_id: terminal.id.to_string(), pane_id: self.public_pane_id(ws_idx, pane_id)?, workspace_id: self.public_workspace_id(ws_idx), tab_id: self.public_tab_id(ws_idx, tab_idx)?, cwd: ws.tabs[tab_idx] - .cwd_for_pane(pane_id) + .cwd_for_pane( + pane_id, + &self.state.terminals, + &self.state.terminal_runtimes, + ) .map(|cwd| cwd.display().to_string()), - agent_status: pane_agent_status(pane.state, pane.seen), + agent_status: pane_agent_status(terminal.state, pane.seen), }) } } diff --git a/src/app/theme_sync.rs b/src/app/theme_sync.rs index df47f6a3..802725f0 100644 --- a/src/app/theme_sync.rs +++ b/src/app/theme_sync.rs @@ -37,12 +37,8 @@ impl App { return; } - for workspace in &self.state.workspaces { - for tab in &workspace.tabs { - for runtime in tab.runtimes.values() { - runtime.apply_host_terminal_theme(self.state.host_terminal_theme); - } - } + for runtime in self.state.terminal_runtimes.values() { + runtime.apply_host_terminal_theme(self.state.host_terminal_theme); } self.render_dirty.store(true, Ordering::Release); diff --git a/src/pane.rs b/src/pane.rs index b5886933..592255a5 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -20,14 +20,12 @@ mod osc; mod state; mod terminal; -use self::{ - state::stabilize_agent_state, - terminal::{GhosttyPaneTerminal, PaneTerminal}, -}; +use self::terminal::{GhosttyPaneTerminal, PaneTerminal}; pub use self::{ - state::{EffectiveStateChange, PaneState}, + state::PaneState, terminal::{InputState, ScrollMetrics, TerminalCursorState}, }; +use crate::terminal::stabilize_agent_state; const RELEASE_REACQUIRE_SUPPRESSION: std::time::Duration = std::time::Duration::from_secs(1); const PANE_TERM: &str = "xterm-256color"; @@ -160,6 +158,7 @@ impl AgentDetectionPresence { /// PTY runtime for a pane. Owns the terminal, I/O channels, and background tasks. /// Dropping this shuts down all background tasks and closes the PTY. pub struct PaneRuntime { + pane_id: PaneId, terminal: Arc<PaneTerminal>, sender: mpsc::Sender<Bytes>, resize_tx: watch::Sender<(u16, u16, u32, u32)>, @@ -181,10 +180,11 @@ pub enum WheelRouting { impl Drop for PaneRuntime { fn drop(&mut self) { - // Abort detection task immediately. + // Abort detection task immediately and terminate the owned session. // Reader/writer/resize tasks shut down naturally via channel close // and PTY EOF when the rest of PaneRuntime is dropped. self.detect_handle.abort(); + shutdown_pane_processes(self.pane_id, self.child_pid.load(Ordering::Acquire)); } } @@ -251,9 +251,9 @@ fn shutdown_pane_processes(pane_id: PaneId, child_pid: u32) { } impl PaneRuntime { - pub fn shutdown(self, pane_id: PaneId) { + pub fn shutdown(self) { self.detect_handle.abort(); - shutdown_pane_processes(pane_id, self.child_pid.load(Ordering::Acquire)); + shutdown_pane_processes(self.pane_id, self.child_pid.load(Ordering::Acquire)); } pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) { @@ -748,6 +748,7 @@ impl PaneRuntime { } Ok(Self { + pane_id, terminal, sender: input_tx, resize_tx, @@ -1039,6 +1040,7 @@ impl PaneRuntime { ( Self { + pane_id: PaneId::from_raw(0), terminal: Arc::new(PaneTerminal::new( GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(), )), @@ -1121,6 +1123,7 @@ mod tests { .mode_set(crate::ghostty::MODE_FOCUS_EVENT, true) .unwrap(); let runtime = PaneRuntime { + pane_id: PaneId::from_raw(0), terminal: Arc::new(PaneTerminal::new( GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(), )), @@ -1144,6 +1147,7 @@ mod tests { let (resize_tx, _resize_rx) = watch::channel((80, 24, 0, 0)); let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); let runtime = PaneRuntime { + pane_id: PaneId::from_raw(0), terminal: Arc::new(PaneTerminal::new( GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(), )), diff --git a/src/pane/state.rs b/src/pane/state.rs index 2a2f789e..be7ebfae 100644 --- a/src/pane/state.rs +++ b/src/pane/state.rs @@ -1,606 +1,20 @@ -use std::collections::HashMap; +use crate::terminal::TerminalId; -use crate::detect::{Agent, AgentState}; - -const CLAUDE_WORKING_HOLD: std::time::Duration = std::time::Duration::from_millis(1200); - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct HookAuthority { - pub source: String, - pub agent_label: String, - pub state: AgentState, - pub message: Option<String>, - pub custom_status: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EffectiveStateChange { - pub previous_agent_label: Option<String>, - pub previous_known_agent: Option<Agent>, - pub previous_state: AgentState, - pub agent_label: Option<String>, - pub known_agent: Option<Agent>, - pub state: AgentState, -} - -/// Observable state for a single pane. -/// This is the only part of a pane that workspace logic and tests need. +/// Viewport state for a pane. +/// +/// Terminal identity, cwd, labels, and agent metadata live in TerminalState. pub struct PaneState { - pub detected_agent: Option<Agent>, - pub fallback_state: AgentState, - pub hook_authority: Option<HookAuthority>, - pub manual_label: Option<String>, - hook_report_sequences: HashMap<String, u64>, - pub state: AgentState, + pub attached_terminal_id: TerminalId, /// Whether the user has seen this pane since its last state change to Idle. /// False = "Done" (agent finished while user was in another workspace). pub seen: bool, } impl PaneState { - pub fn new() -> Self { + pub fn new(attached_terminal_id: TerminalId) -> Self { Self { - detected_agent: None, - fallback_state: AgentState::Unknown, - hook_authority: None, - manual_label: None, - hook_report_sequences: HashMap::new(), - state: AgentState::Unknown, + attached_terminal_id, seen: true, } } - - pub fn set_detected_state( - &mut self, - agent: Option<Agent>, - fallback_state: AgentState, - ) -> Option<EffectiveStateChange> { - let previous_agent_label = self.effective_agent_label().map(str::to_string); - let previous_known_agent = self.effective_known_agent(); - let previous_state = self.state; - let previous_detected_agent = self.detected_agent; - self.detected_agent = agent; - self.fallback_state = fallback_state; - if previous_detected_agent.is_some() - && agent != previous_detected_agent - && self.hook_authority.as_ref().is_some_and(|authority| { - crate::detect::parse_agent_label(&authority.agent_label) == previous_detected_agent - }) - { - self.hook_authority = None; - } - self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) - } - - #[cfg(test)] - pub fn set_hook_authority( - &mut self, - source: String, - agent_label: String, - state: AgentState, - message: Option<String>, - seq: Option<u64>, - ) -> Option<EffectiveStateChange> { - self.set_hook_authority_with_custom_status(source, agent_label, state, message, None, seq) - } - - pub fn set_hook_authority_with_custom_status( - &mut self, - source: String, - agent_label: String, - state: AgentState, - message: Option<String>, - custom_status: Option<String>, - seq: Option<u64>, - ) -> Option<EffectiveStateChange> { - if !self.accept_hook_report(&source, seq) { - return None; - } - - let previous_agent_label = self.effective_agent_label().map(str::to_string); - let previous_known_agent = self.effective_known_agent(); - let previous_state = self.state; - self.hook_authority = Some(HookAuthority { - source, - agent_label, - state, - message, - custom_status, - }); - self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) - } - - fn accept_hook_report(&mut self, source: &str, seq: Option<u64>) -> bool { - let Some(seq) = seq else { - return !self.hook_report_sequences.contains_key(source); - }; - - if self - .hook_report_sequences - .get(source) - .is_some_and(|last_seq| seq <= *last_seq) - { - return false; - } - - self.hook_report_sequences.insert(source.to_string(), seq); - true - } - - pub fn clear_hook_authority( - &mut self, - source: Option<&str>, - seq: Option<u64>, - ) -> Option<EffectiveStateChange> { - let sequence_source = source.map(str::to_string).or_else(|| { - self.hook_authority - .as_ref() - .map(|authority| authority.source.clone()) - }); - if let Some(source) = sequence_source.as_deref() { - if !self.accept_hook_report(source, seq) { - return None; - } - } - - let previous_agent_label = self.effective_agent_label().map(str::to_string); - let previous_known_agent = self.effective_known_agent(); - let previous_state = self.state; - let should_clear = self - .hook_authority - .as_ref() - .is_some_and(|authority| source.is_none_or(|source| authority.source == source)); - if !should_clear { - return None; - } - self.hook_authority = None; - self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) - } - - pub fn release_agent( - &mut self, - source: &str, - agent_label: &str, - seq: Option<u64>, - ) -> Option<EffectiveStateChange> { - if !self.accept_hook_report(source, seq) { - return None; - } - - let current_agent_label = self.effective_agent_label()?; - if current_agent_label != agent_label { - return None; - } - - if self.hook_authority.as_ref().is_some_and(|authority| { - authority.agent_label != agent_label || authority.source != source - }) { - return None; - } - - let previous_agent_label = self.effective_agent_label().map(str::to_string); - let previous_known_agent = self.effective_known_agent(); - let previous_state = self.state; - self.detected_agent = None; - self.fallback_state = AgentState::Unknown; - self.hook_authority = None; - self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) - } - - pub fn effective_agent_label(&self) -> Option<&str> { - self.hook_authority - .as_ref() - .map(|authority| authority.agent_label.as_str()) - .or_else(|| self.detected_agent.map(crate::detect::agent_label)) - } - - pub fn effective_known_agent(&self) -> Option<Agent> { - if let Some(authority) = &self.hook_authority { - return crate::detect::parse_agent_label(&authority.agent_label); - } - self.detected_agent - } - - pub fn effective_custom_status(&self) -> Option<&str> { - self.hook_authority - .as_ref() - .and_then(|authority| authority.custom_status.as_deref()) - } - - pub fn set_manual_label(&mut self, label: String) { - let label = label.trim().to_string(); - self.manual_label = (!label.is_empty()).then_some(label); - } - - pub fn clear_manual_label(&mut self) { - self.manual_label = None; - } - - pub fn border_label(&self, show_agent_labels: bool) -> Option<&str> { - self.manual_label.as_deref().or_else(|| { - show_agent_labels - .then(|| self.effective_agent_label()) - .flatten() - }) - } - - fn recompute_effective_state( - &mut self, - previous_agent_label: Option<String>, - previous_known_agent: Option<Agent>, - previous_state: AgentState, - ) -> Option<EffectiveStateChange> { - let state = self - .hook_authority - .as_ref() - .map(|authority| authority.state) - .unwrap_or(self.fallback_state); - let agent_label = self.effective_agent_label().map(str::to_string); - let known_agent = self.effective_known_agent(); - - if previous_agent_label == agent_label && previous_state == state { - return None; - } - - self.state = state; - Some(EffectiveStateChange { - previous_agent_label, - previous_known_agent, - previous_state, - agent_label, - known_agent, - state, - }) - } -} - -pub(crate) fn stabilize_agent_state( - agent: Option<Agent>, - previous: AgentState, - raw: AgentState, - now: std::time::Instant, - last_claude_working_at: &mut Option<std::time::Instant>, -) -> AgentState { - if agent != Some(Agent::Claude) { - return raw; - } - - match raw { - AgentState::Working => { - *last_claude_working_at = Some(now); - AgentState::Working - } - AgentState::Blocked => AgentState::Blocked, - AgentState::Idle if previous == AgentState::Working => { - if last_claude_working_at - .is_some_and(|last_working| now.duration_since(last_working) < CLAUDE_WORKING_HOLD) - { - AgentState::Working - } else { - AgentState::Idle - } - } - _ => raw, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn claude_working_is_sticky_for_short_gap() { - let now = std::time::Instant::now(); - let mut last_working = None; - - let working = stabilize_agent_state( - Some(Agent::Claude), - AgentState::Idle, - AgentState::Working, - now, - &mut last_working, - ); - assert_eq!(working, AgentState::Working); - - let still_working = stabilize_agent_state( - Some(Agent::Claude), - AgentState::Working, - AgentState::Idle, - now + std::time::Duration::from_millis(400), - &mut last_working, - ); - assert_eq!(still_working, AgentState::Working); - } - - #[test] - fn claude_transitions_to_idle_after_hold_expires() { - let now = std::time::Instant::now(); - let mut last_working = Some(now); - - let state = stabilize_agent_state( - Some(Agent::Claude), - AgentState::Working, - AgentState::Idle, - now + CLAUDE_WORKING_HOLD + std::time::Duration::from_millis(1), - &mut last_working, - ); - assert_eq!(state, AgentState::Idle); - } - - #[test] - fn non_claude_states_are_unchanged() { - let now = std::time::Instant::now(); - let mut last_working = None; - - let state = stabilize_agent_state( - Some(Agent::Codex), - AgentState::Working, - AgentState::Idle, - now, - &mut last_working, - ); - assert_eq!(state, AgentState::Idle); - } - - #[test] - fn hook_authority_overrides_fallback_for_same_agent() { - let mut pane = PaneState::new(); - pane.set_detected_state(Some(Agent::Pi), AgentState::Idle); - pane.set_hook_authority( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - None, - ); - - assert_eq!(pane.detected_agent, Some(Agent::Pi)); - assert_eq!(pane.fallback_state, AgentState::Idle); - assert_eq!(pane.effective_agent_label(), Some("pi")); - assert_eq!(pane.state, AgentState::Working); - } - - #[test] - fn hook_authority_can_override_with_unknown_agent_label() { - let mut pane = PaneState::new(); - pane.set_detected_state(Some(Agent::Pi), AgentState::Idle); - pane.set_hook_authority( - "herdr:custom".into(), - "hermes".into(), - AgentState::Working, - None, - None, - ); - - assert_eq!(pane.detected_agent, Some(Agent::Pi)); - assert_eq!(pane.effective_agent_label(), Some("hermes")); - assert_eq!(pane.effective_known_agent(), None); - assert_eq!(pane.state, AgentState::Working); - } - - #[test] - fn border_label_prefers_manual_label_over_agent_label() { - let mut pane = PaneState::new(); - pane.set_detected_state(Some(Agent::Claude), AgentState::Idle); - - assert_eq!(pane.border_label(false), None); - assert_eq!(pane.border_label(true), Some("claude")); - - pane.set_manual_label(" reviewer ".into()); - assert_eq!(pane.border_label(false), Some("reviewer")); - assert_eq!(pane.border_label(true), Some("reviewer")); - - pane.set_manual_label(" ".into()); - assert_eq!(pane.border_label(true), Some("claude")); - - pane.set_manual_label("reviewer".into()); - pane.clear_manual_label(); - assert_eq!(pane.border_label(true), Some("claude")); - } - - #[test] - fn hook_authority_survives_unrelated_detected_agent_clear() { - let mut pane = PaneState::new(); - pane.set_detected_state(Some(Agent::Pi), AgentState::Idle); - pane.set_hook_authority( - "herdr:custom".into(), - "hermes".into(), - AgentState::Working, - None, - None, - ); - - pane.set_detected_state(None, AgentState::Unknown); - - assert!(pane.hook_authority.is_some()); - assert_eq!(pane.detected_agent, None); - assert_eq!(pane.effective_agent_label(), Some("hermes")); - assert_eq!(pane.state, AgentState::Working); - } - - #[test] - fn detected_agent_clear_clears_matching_hook_authority() { - let mut pane = PaneState::new(); - pane.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); - pane.set_hook_authority( - "herdr:opencode".into(), - "opencode".into(), - AgentState::Idle, - None, - None, - ); - - pane.set_detected_state(None, AgentState::Unknown); - - assert!(pane.hook_authority.is_none()); - assert_eq!(pane.detected_agent, None); - assert_eq!(pane.fallback_state, AgentState::Unknown); - assert_eq!(pane.effective_agent_label(), None); - assert_eq!(pane.state, AgentState::Unknown); - } - - #[test] - fn detected_agent_change_clears_previous_matching_hook_authority() { - let mut pane = PaneState::new(); - pane.set_detected_state(Some(Agent::Codex), AgentState::Idle); - pane.set_hook_authority( - "herdr:codex".into(), - "codex".into(), - AgentState::Idle, - None, - None, - ); - - pane.set_detected_state(Some(Agent::OpenCode), AgentState::Working); - - assert!(pane.hook_authority.is_none()); - assert_eq!(pane.detected_agent, Some(Agent::OpenCode)); - assert_eq!(pane.effective_agent_label(), Some("opencode")); - assert_eq!(pane.state, AgentState::Working); - } - - #[test] - fn release_agent_clears_identity_immediately() { - let mut pane = PaneState::new(); - pane.set_detected_state(Some(Agent::Pi), AgentState::Idle); - pane.set_hook_authority( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - None, - ); - - pane.release_agent("herdr:pi", "pi", None); - - assert!(pane.hook_authority.is_none()); - assert_eq!(pane.detected_agent, None); - assert_eq!(pane.fallback_state, AgentState::Unknown); - assert_eq!(pane.state, AgentState::Unknown); - } - - #[test] - fn custom_status_is_hook_authority_metadata() { - let mut pane = PaneState::new(); - pane.set_detected_state(Some(Agent::Claude), AgentState::Idle); - pane.set_hook_authority_with_custom_status( - "herdr:claude".into(), - "claude".into(), - AgentState::Idle, - None, - Some("scheduled".into()), - None, - ); - - assert_eq!(pane.state, AgentState::Idle); - assert_eq!(pane.effective_custom_status(), Some("scheduled")); - - pane.clear_hook_authority(Some("herdr:claude"), None); - - assert_eq!(pane.effective_custom_status(), None); - } - - #[test] - fn stale_hook_report_sequence_is_ignored_for_same_source() { - let mut pane = PaneState::new(); - pane.set_hook_authority( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - Some(20), - ); - - let change = pane.set_hook_authority( - "herdr:pi".into(), - "pi".into(), - AgentState::Idle, - None, - Some(19), - ); - - assert!(change.is_none()); - assert_eq!(pane.state, AgentState::Working); - assert_eq!( - pane.hook_authority.as_ref().unwrap().state, - AgentState::Working - ); - } - - #[test] - fn unsequenced_hook_report_is_ignored_after_source_uses_sequence() { - let mut pane = PaneState::new(); - pane.set_hook_authority( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - Some(20), - ); - - let change = - pane.set_hook_authority("herdr:pi".into(), "pi".into(), AgentState::Idle, None, None); - - assert!(change.is_none()); - assert_eq!(pane.state, AgentState::Working); - } - - #[test] - fn stale_release_sequence_is_ignored_for_same_source() { - let mut pane = PaneState::new(); - pane.set_hook_authority( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - Some(20), - ); - - let change = pane.release_agent("herdr:pi", "pi", Some(19)); - - assert!(change.is_none()); - assert_eq!(pane.state, AgentState::Working); - assert!(pane.hook_authority.is_some()); - } - - #[test] - fn stale_clear_all_sequence_is_checked_against_current_authority_source() { - let mut pane = PaneState::new(); - pane.set_hook_authority( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - Some(20), - ); - - let change = pane.clear_hook_authority(None, Some(19)); - - assert!(change.is_none()); - assert_eq!(pane.state, AgentState::Working); - assert!(pane.hook_authority.is_some()); - } - - #[test] - fn same_sequence_from_different_sources_is_independent() { - let mut pane = PaneState::new(); - pane.set_hook_authority( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - Some(20), - ); - - pane.set_hook_authority( - "custom:pi".into(), - "pi".into(), - AgentState::Idle, - None, - Some(19), - ); - - assert_eq!(pane.state, AgentState::Idle); - assert_eq!(pane.hook_authority.as_ref().unwrap().source, "custom:pi"); - } } diff --git a/src/persist/restore.rs b/src/persist/restore.rs index 1e10df6d..e53a61d9 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -9,7 +9,8 @@ use tracing::{error, warn}; use crate::events::AppEvent; use crate::layout::{Node, PaneId, TileLayout}; -use crate::pane::{PaneRuntime, PaneState}; +use crate::pane::PaneState; +use crate::terminal::{TerminalId, TerminalRuntime, TerminalState}; use crate::workspace::Workspace; use super::{DirectionSnapshot, LayoutSnapshot, SessionSnapshot, TabSnapshot, WorkspaceSnapshot}; @@ -23,22 +24,32 @@ pub fn restore( events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, -) -> Vec<Workspace> { - snapshot - .workspaces - .iter() - .filter_map(|ws_snap| { - restore_workspace( - ws_snap, - rows, - cols, - scrollback_limit_bytes, - events.clone(), - render_notify.clone(), - render_dirty.clone(), - ) - }) - .collect() +) -> ( + Vec<Workspace>, + HashMap<TerminalId, TerminalState>, + HashMap<TerminalId, TerminalRuntime>, +) { + let mut workspaces = Vec::new(); + let mut terminals = HashMap::new(); + let mut terminal_runtimes = HashMap::new(); + for ws_snap in &snapshot.workspaces { + if let Some((workspace, restored_terminals, restored_runtimes)) = restore_workspace( + ws_snap, + rows, + cols, + scrollback_limit_bytes, + events.clone(), + render_notify.clone(), + render_dirty.clone(), + ) { + for terminal in restored_terminals { + terminals.insert(terminal.id.clone(), terminal); + } + terminal_runtimes.extend(restored_runtimes); + workspaces.push(workspace); + } + } + (workspaces, terminals, terminal_runtimes) } fn restore_workspace( @@ -49,13 +60,19 @@ fn restore_workspace( events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, -) -> Option<Workspace> { +) -> Option<( + Workspace, + Vec<TerminalState>, + HashMap<TerminalId, TerminalRuntime>, +)> { let mut tabs = Vec::new(); + let mut terminals = Vec::new(); + let mut terminal_runtimes = HashMap::new(); let mut public_pane_numbers = HashMap::new(); let mut next_public_pane_number = 1; for (idx, tab_snap) in snap.tabs.iter().enumerate() { - let tab = restore_tab( + let (tab, restored_terminals, restored_runtimes) = restore_tab( tab_snap, idx + 1, rows, @@ -69,6 +86,8 @@ fn restore_workspace( public_pane_numbers.insert(pane_id, next_public_pane_number); next_public_pane_number += 1; } + terminals.extend(restored_terminals); + terminal_runtimes.extend(restored_runtimes); tabs.push(tab); } @@ -76,20 +95,26 @@ fn restore_workspace( return None; } - Some(Workspace { - id: snap - .id - .clone() - .unwrap_or_else(crate::workspace::generate_workspace_id), - custom_name: snap.custom_name.clone(), - identity_cwd: snap.identity_cwd.clone(), - cached_git_branch: None, - cached_git_ahead_behind: None, - public_pane_numbers, - next_public_pane_number, - active_tab: snap.active_tab.min(tabs.len().saturating_sub(1)), - tabs, - }) + Some(( + Workspace { + id: snap + .id + .clone() + .unwrap_or_else(crate::workspace::generate_workspace_id), + custom_name: snap.custom_name.clone(), + identity_cwd: snap.identity_cwd.clone(), + cached_git_branch: None, + cached_git_ahead_behind: None, + public_pane_numbers, + next_public_pane_number, + active_tab: snap.active_tab.min(tabs.len().saturating_sub(1)), + tabs, + #[cfg(test)] + test_runtimes: HashMap::new(), + }, + terminals, + terminal_runtimes, + )) } fn restore_tab( @@ -101,7 +126,11 @@ fn restore_tab( events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, -) -> Option<crate::workspace::Tab> { +) -> Option<( + crate::workspace::Tab, + Vec<TerminalState>, + HashMap<TerminalId, TerminalRuntime>, +)> { let (node, id_map) = restore_node_remapped(&snap.layout); let reverse_id_map: HashMap<PaneId, u32> = id_map .iter() @@ -110,9 +139,8 @@ fn restore_tab( let pane_ids = collect_pane_ids(&node); let mut panes = HashMap::new(); - let mut pane_cwds = HashMap::new(); - let mut terminal_ids = HashMap::new(); - let mut runtimes = HashMap::new(); + let mut terminals = Vec::new(); + let mut terminal_runtimes = HashMap::new(); for id in &pane_ids { let saved_cwd = reverse_id_map .get(id) @@ -142,7 +170,7 @@ fn restore_tab( .and_then(|old_id| snap.panes.get(old_id)) .and_then(|p| p.label.clone()); - match PaneRuntime::spawn( + match TerminalRuntime::spawn( *id, rows, cols, @@ -154,12 +182,14 @@ fn restore_tab( render_dirty.clone(), ) { Ok(runtime) => { - let mut pane_state = PaneState::new(); - pane_state.manual_label = saved_label; - panes.insert(*id, pane_state); - pane_cwds.insert(*id, cwd.clone()); - terminal_ids.insert(*id, crate::terminal::TerminalId::alloc()); - runtimes.insert(*id, runtime); + let terminal_id = TerminalId::alloc(); + let mut terminal = TerminalState::new(terminal_id.clone(), cwd.clone()); + if let Some(label) = saved_label { + terminal.set_manual_label(label); + } + panes.insert(*id, PaneState::new(terminal_id.clone())); + terminal_runtimes.insert(terminal_id, runtime); + terminals.push(terminal); } Err(e) => { error!( @@ -193,20 +223,23 @@ fn restore_tab( let root_pane = resolve_restored_pane(snap.root_pane, &id_map, &surviving, &pane_ids)?; let layout = TileLayout::from_saved(node, focus); - Some(crate::workspace::Tab { - custom_name: snap.custom_name.clone(), - number, - root_pane, - layout, - panes, - pane_cwds, - terminal_ids, - runtimes, - zoomed: snap.zoomed, - events, - render_notify, - render_dirty, - }) + Some(( + crate::workspace::Tab { + custom_name: snap.custom_name.clone(), + number, + root_pane, + layout, + panes, + #[cfg(test)] + runtimes: HashMap::new(), + zoomed: snap.zoomed, + events, + render_notify, + render_dirty, + }, + terminals, + terminal_runtimes, + )) } pub(super) fn prune_restored_node(node: Node, surviving: &HashSet<PaneId>) -> Option<Node> { diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs index 76ed96f5..245fbb76 100644 --- a/src/persist/snapshot.rs +++ b/src/persist/snapshot.rs @@ -195,6 +195,14 @@ fn first_pane_id_in_layout(layout: &LayoutSnapshot) -> Option<u32> { /// Capture the current app state into a serializable snapshot. pub fn capture( workspaces: &[Workspace], + terminals: &std::collections::HashMap< + crate::terminal::TerminalId, + crate::terminal::TerminalState, + >, + terminal_runtimes: &std::collections::HashMap< + crate::terminal::TerminalId, + crate::terminal::TerminalRuntime, + >, active: Option<usize>, selected: usize, agent_panel_scope: crate::app::state::AgentPanelScope, @@ -203,7 +211,10 @@ pub fn capture( ) -> SessionSnapshot { SessionSnapshot { version: SNAPSHOT_VERSION, - workspaces: workspaces.iter().map(capture_workspace).collect(), + workspaces: workspaces + .iter() + .map(|workspace| capture_workspace(workspace, terminals, terminal_runtimes)) + .collect(), active, selected, agent_panel_scope, @@ -212,25 +223,53 @@ pub fn capture( } } -fn capture_workspace(ws: &Workspace) -> WorkspaceSnapshot { +fn capture_workspace( + ws: &Workspace, + terminals: &std::collections::HashMap< + crate::terminal::TerminalId, + crate::terminal::TerminalState, + >, + terminal_runtimes: &std::collections::HashMap< + crate::terminal::TerminalId, + crate::terminal::TerminalRuntime, + >, +) -> WorkspaceSnapshot { WorkspaceSnapshot { id: Some(ws.id.clone()), custom_name: ws.custom_name.clone(), identity_cwd: ws .resolved_identity_cwd() .unwrap_or_else(|| ws.identity_cwd.clone()), - tabs: ws.tabs.iter().map(capture_tab).collect(), + tabs: ws + .tabs + .iter() + .map(|tab| capture_tab(tab, terminals, terminal_runtimes)) + .collect(), active_tab: ws.active_tab, } } -fn capture_tab(tab: &crate::workspace::Tab) -> TabSnapshot { +fn capture_tab( + tab: &crate::workspace::Tab, + terminals: &std::collections::HashMap< + crate::terminal::TerminalId, + crate::terminal::TerminalState, + >, + terminal_runtimes: &std::collections::HashMap< + crate::terminal::TerminalId, + crate::terminal::TerminalRuntime, + >, +) -> TabSnapshot { let mut panes = HashMap::new(); for id in tab.panes.keys() { let cwd = tab - .cwd_for_pane(*id) + .cwd_for_pane(*id, terminals, terminal_runtimes) .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); - let label = tab.panes.get(id).and_then(|pane| pane.manual_label.clone()); + let label = tab + .panes + .get(id) + .and_then(|pane| terminals.get(&pane.attached_terminal_id)) + .and_then(|terminal| terminal.manual_label.clone()); panes.insert(id.raw(), PaneSnapshot { cwd, label }); } TabSnapshot { @@ -310,6 +349,7 @@ mod tests { fn state_with_workspaces(names: &[&str]) -> AppState { let mut state = AppState::test_new(); state.workspaces = names.iter().map(|name| Workspace::test_new(name)).collect(); + state.ensure_test_terminals(); if !state.workspaces.is_empty() { state.active = Some(0); state.selected = 0; @@ -321,6 +361,8 @@ mod tests { fn capture_from_state(state: &AppState) -> SessionSnapshot { capture( &state.workspaces, + &state.terminals, + &state.terminal_runtimes, state.active, state.selected, state.agent_panel_scope, @@ -657,13 +699,17 @@ mod tests { fn capture_contract_tracks_workspace_identity_and_pane_cwds() { let mut state = state_with_workspaces(&["one"]); let root = state.workspaces[0].tabs[0].root_pane; - state.workspaces[0].tabs[0] - .pane_cwds - .insert(root, PathBuf::from("/tmp/pion")); + state.workspaces[0].identity_cwd = PathBuf::from("/tmp/pion"); let second = state.workspaces[0].test_split(Direction::Horizontal); - state.workspaces[0].tabs[0] - .pane_cwds - .insert(second, PathBuf::from("/tmp/herdr")); + state.ensure_test_terminals(); + let root_terminal_id = state.workspaces[0].tabs[0].panes[&root] + .attached_terminal_id + .clone(); + state.terminals.get_mut(&root_terminal_id).unwrap().cwd = PathBuf::from("/tmp/pion"); + let second_terminal_id = state.workspaces[0].tabs[0].panes[&second] + .attached_terminal_id + .clone(); + state.terminals.get_mut(&second_terminal_id).unwrap().cwd = PathBuf::from("/tmp/herdr"); let snapshot = capture_from_state(&state); let workspace = &snapshot.workspaces[0]; diff --git a/src/server/headless.rs b/src/server/headless.rs index a1e7669b..fbfef251 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -126,7 +126,10 @@ fn toast_message_from_state_change( .find_map(|(ws_idx, ws)| { ws.tabs.iter().find_map(|tab| { let pane = tab.panes.get(&pane_id)?; - let agent_label = pane.effective_agent_label()?; + let agent_label = state + .terminals + .get(&pane.attached_terminal_id) + .and_then(|terminal| terminal.effective_agent_label())?; Some(format!( "{} {}: {}", agent_label, @@ -762,9 +765,14 @@ impl HeadlessServer { .workspaces .iter() .find_map(|ws| { - ws.tabs - .iter() - .find_map(|tab| tab.panes.get(&pane_id).map(|pane| pane.state)) + ws.tabs.iter().find_map(|tab| { + let pane = tab.panes.get(&pane_id)?; + self.app + .state + .terminals + .get(&pane.attached_terminal_id) + .map(|terminal| terminal.state) + }) }) .unwrap_or(crate::detect::AgentState::Unknown) } @@ -1332,7 +1340,8 @@ impl HeadlessServer { // bypasses drain_internal_events_with_forwarding. Headless mode disables // local sound playback, so sound notifications need to be forwarded here. let toast_before = self.app.state.toast.clone(); - let pane_states_before: Vec<(usize, crate::layout::PaneId, crate::detect::AgentState)> = + let pane_states_before: Vec<(usize, crate::layout::PaneId, crate::detect::AgentState)> = { + let terminals = &self.app.state.terminals; self.app .state .workspaces @@ -1340,12 +1349,15 @@ impl HeadlessServer { .enumerate() .flat_map(|(ws_idx, ws)| { ws.tabs.iter().flat_map(move |tab| { - tab.panes - .iter() - .map(move |(&pane_id, pane)| (ws_idx, pane_id, pane.state)) + tab.panes.iter().filter_map(move |(&pane_id, pane)| { + terminals + .get(&pane.attached_terminal_id) + .map(|terminal| (ws_idx, pane_id, terminal.state)) + }) }) }) - .collect(); + .collect() + }; self.sync_foreground_client_state(); let response = self.app.handle_api_request(msg.request); @@ -1391,7 +1403,16 @@ impl HeadlessServer { continue; }; - let new_state = pane_after.state; + let Some(terminal_after) = self + .app + .state + .terminals + .get(&pane_after.attached_terminal_id) + else { + continue; + }; + + let new_state = terminal_after.state; if new_state == *prev_state { continue; } @@ -1400,7 +1421,7 @@ impl HeadlessServer { let suppress_active_tab_notifications = self.active_tab_suppresses_notifications(is_active_tab); - let agent = pane_after.effective_known_agent(); + let agent = terminal_after.effective_known_agent(); debug!( ws_idx, @@ -1419,7 +1440,13 @@ impl HeadlessServer { *prev_state, new_state, ) { - if let Some(agent_label) = pane_after.effective_agent_label() { + if let Some(agent_label) = self + .app + .state + .terminals + .get(&pane_after.attached_terminal_id) + .and_then(|terminal| terminal.effective_agent_label()) + { let event_text = match kind { crate::app::state::ToastKind::NeedsAttention => "needs attention", crate::app::state::ToastKind::Finished => "finished", @@ -2129,9 +2156,9 @@ mod tests { let mut state = AppState::test_new(); let mut ws = crate::workspace::Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; - ws.tabs[0].runtimes.insert( + ws.insert_test_runtime( pane_id, - crate::pane::PaneRuntime::test_with_screen_bytes(20, 5, b"left"), + crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left"), ); state.workspaces = vec![ws]; @@ -2165,9 +2192,9 @@ mod tests { let mut state = AppState::test_new(); let mut ws = crate::workspace::Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; - ws.tabs[0].runtimes.insert( + ws.insert_test_runtime( pane_id, - crate::pane::PaneRuntime::test_with_screen_bytes(20, 5, b"left\x1b[?25l"), + crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left\x1b[?25l"), ); state.workspaces = vec![ws]; @@ -2201,9 +2228,9 @@ mod tests { let mut state = AppState::test_new(); let mut ws = crate::workspace::Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; - ws.tabs[0].runtimes.insert( + ws.insert_test_runtime( pane_id, - crate::pane::PaneRuntime::test_with_screen_bytes(20, 5, b"left"), + crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left"), ); state.workspaces = vec![ws]; @@ -2227,8 +2254,9 @@ mod tests { for line in 0..80 { bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); } - let runtime = crate::pane::PaneRuntime::test_with_scrollback_bytes(20, 5, 4096, &bytes); - ws.tabs[0].runtimes.insert(pane_id, runtime); + let runtime = + crate::terminal::TerminalRuntime::test_with_scrollback_bytes(20, 5, 4096, &bytes); + ws.insert_test_runtime(pane_id, runtime); state.workspaces = vec![ws]; state.active = Some(0); @@ -2237,8 +2265,8 @@ mod tests { let area = Rect::new(0, 0, 80, 24); let _ = crate::server::render_stream::render_virtual(&mut state, area, true); - let runtime = state.workspaces[0] - .runtime(pane_id) + let runtime = state + .runtime_for_pane(pane_id) .expect("pane runtime after initial render"); runtime.scroll_up(6); assert!(crate::ui::pane_is_scrolled_back(runtime)); @@ -2498,11 +2526,11 @@ mod tests { let background_pane = workspace.tabs[background_tab].root_pane; workspace.tabs[0].runtimes.insert( active_pane, - crate::pane::PaneRuntime::test_with_screen_bytes(80, 24, b""), + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), ); workspace.tabs[background_tab].runtimes.insert( background_pane, - crate::pane::PaneRuntime::test_with_screen_bytes(80, 24, b""), + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), ); server.app.state.workspaces = vec![workspace]; server.app.state.active = Some(0); @@ -2528,11 +2556,20 @@ mod tests { let terminal_area = server.app.state.view.terminal_area; let expected = (terminal_area.height, terminal_area.width.saturating_sub(1)); assert_eq!( - server.app.state.workspaces[0].tabs[0].runtimes[&active_pane].current_size(), + server + .app + .state + .runtime_for_pane(active_pane) + .unwrap() + .current_size(), expected ); assert_eq!( - server.app.state.workspaces[0].tabs[background_tab].runtimes[&background_pane] + server + .app + .state + .runtime_for_pane(background_pane) + .unwrap() .current_size(), expected ); @@ -3029,12 +3066,22 @@ mod tests { #[test] fn stale_api_agent_report_does_not_forward_done_sound() { let mut server = test_headless_server(); - let mut background = crate::workspace::Workspace::test_new("background"); + let background = crate::workspace::Workspace::test_new("background"); let pane_id = background.tabs[0].root_pane; let public_pane_id = format!("{}-1", background.id); - background.tabs[0] - .panes - .get_mut(&pane_id) + let foreground = crate::workspace::Workspace::test_new("foreground"); + server.app.state.workspaces = vec![background, foreground]; + server.app.state.ensure_test_terminals(); + let terminal_id = server.app.state.workspaces[0] + .pane_state(pane_id) + .unwrap() + .attached_terminal_id + .clone(); + server + .app + .state + .terminals + .get_mut(&terminal_id) .unwrap() .set_hook_authority( "herdr:pi".into(), @@ -3043,8 +3090,6 @@ mod tests { None, Some(20), ); - let foreground = crate::workspace::Workspace::test_new("foreground"); - server.app.state.workspaces = vec![background, foreground]; server.app.state.active = Some(1); server.app.state.selected = 1; server.app.state.mode = crate::app::Mode::Terminal; @@ -3085,10 +3130,7 @@ mod tests { assert!(changed); assert!(response_rx.recv_timeout(Duration::from_millis(100)).is_ok()); assert_eq!( - server.app.state.workspaces[0] - .pane_state(pane_id) - .unwrap() - .state, + server.app.state.terminals.get(&terminal_id).unwrap().state, crate::detect::AgentState::Working ); assert!( diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs index a921960c..94a43980 100644 --- a/src/server/render_stream.rs +++ b/src/server/render_stream.rs @@ -272,7 +272,10 @@ pub(crate) fn visible_hyperlinks(app_state: &AppState) -> Vec<((u16, u16), Strin let mut links = Vec::new(); for info in &app_state.view.pane_infos { - if let Some(runtime) = tab.runtimes.get(&info.id) { + if let Some(runtime) = tab + .terminal_id(info.id) + .and_then(|terminal_id| app_state.terminal_runtimes.get(terminal_id)) + { links.extend(runtime.visible_hyperlinks(info.inner_rect)); } } @@ -285,13 +288,12 @@ fn focused_terminal_cursor(app_state: &AppState) -> Option<CursorState> { } let ws_idx = app_state.active?; - let ws = app_state.workspaces.get(ws_idx)?; let info = app_state .view .pane_infos .iter() .find(|info| info.is_focused)?; - let rt = ws.runtime(info.id)?; + let rt = app_state.runtime_for_pane_in_workspace(ws_idx, info.id)?; let cursor = rt.cursor_state(info.inner_rect, true)?; Some(CursorState { x: cursor.x, diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 489f7876..5f9bb41b 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -1,3 +1,13 @@ mod id; +pub mod state; pub use id::TerminalId; +pub(crate) use state::stabilize_agent_state; +pub use state::{EffectiveStateChange, TerminalState}; + +/// Live runtime for a server-owned terminal. +/// +/// The implementation still lives in the pane module during this migration, but +/// all new ownership should address it by `TerminalId` through this terminal +/// noun. +pub type TerminalRuntime = crate::pane::PaneRuntime; diff --git a/src/terminal/state.rs b/src/terminal/state.rs new file mode 100644 index 00000000..df89b688 --- /dev/null +++ b/src/terminal/state.rs @@ -0,0 +1,615 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use crate::detect::{Agent, AgentState}; +use crate::terminal::TerminalId; + +const CLAUDE_WORKING_HOLD: std::time::Duration = std::time::Duration::from_millis(1200); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HookAuthority { + pub source: String, + pub agent_label: String, + pub state: AgentState, + pub message: Option<String>, + pub custom_status: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EffectiveStateChange { + pub previous_agent_label: Option<String>, + pub previous_known_agent: Option<Agent>, + pub previous_state: AgentState, + pub agent_label: Option<String>, + pub known_agent: Option<Agent>, + pub state: AgentState, + pub custom_status: Option<String>, +} + +/// Pure state for a server-owned terminal. +/// +/// During the migration this is still one-to-one with a pane-backed PTY, but +/// pane/view state no longer owns terminal identity, cwd, labels, or agent +/// metadata. +pub struct TerminalState { + pub id: TerminalId, + pub cwd: PathBuf, + pub detected_agent: Option<Agent>, + pub fallback_state: AgentState, + pub hook_authority: Option<HookAuthority>, + pub manual_label: Option<String>, + hook_report_sequences: HashMap<String, u64>, + pub state: AgentState, + pub revision: u64, + pub launch_argv: Option<Vec<String>>, +} + +impl TerminalState { + pub fn new(id: TerminalId, cwd: PathBuf) -> Self { + Self { + id, + cwd, + detected_agent: None, + fallback_state: AgentState::Unknown, + hook_authority: None, + manual_label: None, + hook_report_sequences: HashMap::new(), + state: AgentState::Unknown, + revision: 0, + launch_argv: None, + } + } + + pub fn with_launch_argv(mut self, argv: Vec<String>) -> Self { + self.launch_argv = Some(argv); + self + } + + pub fn set_detected_state( + &mut self, + agent: Option<Agent>, + fallback_state: AgentState, + ) -> Option<EffectiveStateChange> { + let previous_agent_label = self.effective_agent_label().map(str::to_string); + let previous_known_agent = self.effective_known_agent(); + let previous_state = self.state; + let previous_detected_agent = self.detected_agent; + self.detected_agent = agent; + self.fallback_state = fallback_state; + if previous_detected_agent.is_some() + && agent != previous_detected_agent + && self.hook_authority.as_ref().is_some_and(|authority| { + crate::detect::parse_agent_label(&authority.agent_label) == previous_detected_agent + }) + { + self.hook_authority = None; + } + self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) + } + + #[cfg(test)] + pub fn set_hook_authority( + &mut self, + source: String, + agent_label: String, + state: AgentState, + message: Option<String>, + seq: Option<u64>, + ) -> Option<EffectiveStateChange> { + self.set_hook_authority_with_custom_status(source, agent_label, state, message, None, seq) + } + + pub fn set_hook_authority_with_custom_status( + &mut self, + source: String, + agent_label: String, + state: AgentState, + message: Option<String>, + custom_status: Option<String>, + seq: Option<u64>, + ) -> Option<EffectiveStateChange> { + if !self.accept_hook_report(&source, seq) { + return None; + } + + let previous_agent_label = self.effective_agent_label().map(str::to_string); + let previous_known_agent = self.effective_known_agent(); + let previous_state = self.state; + self.hook_authority = Some(HookAuthority { + source, + agent_label, + state, + message, + custom_status, + }); + self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) + } + + fn accept_hook_report(&mut self, source: &str, seq: Option<u64>) -> bool { + let Some(seq) = seq else { + return !self.hook_report_sequences.contains_key(source); + }; + + if self + .hook_report_sequences + .get(source) + .is_some_and(|last_seq| seq <= *last_seq) + { + return false; + } + + self.hook_report_sequences.insert(source.to_string(), seq); + true + } + + pub fn clear_hook_authority( + &mut self, + source: Option<&str>, + seq: Option<u64>, + ) -> Option<EffectiveStateChange> { + let sequence_source = source.map(str::to_string).or_else(|| { + self.hook_authority + .as_ref() + .map(|authority| authority.source.clone()) + }); + if let Some(source) = sequence_source.as_deref() { + if !self.accept_hook_report(source, seq) { + return None; + } + } + + let previous_agent_label = self.effective_agent_label().map(str::to_string); + let previous_known_agent = self.effective_known_agent(); + let previous_state = self.state; + let should_clear = self + .hook_authority + .as_ref() + .is_some_and(|authority| source.is_none_or(|source| authority.source == source)); + if !should_clear { + return None; + } + self.hook_authority = None; + self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) + } + + pub fn release_agent( + &mut self, + source: &str, + agent_label: &str, + seq: Option<u64>, + ) -> Option<EffectiveStateChange> { + if !self.accept_hook_report(source, seq) { + return None; + } + + let current_agent_label = self.effective_agent_label()?; + if current_agent_label != agent_label { + return None; + } + + if self.hook_authority.as_ref().is_some_and(|authority| { + authority.agent_label != agent_label || authority.source != source + }) { + return None; + } + + let previous_agent_label = self.effective_agent_label().map(str::to_string); + let previous_known_agent = self.effective_known_agent(); + let previous_state = self.state; + self.detected_agent = None; + self.fallback_state = AgentState::Unknown; + self.hook_authority = None; + self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) + } + + pub fn effective_agent_label(&self) -> Option<&str> { + self.hook_authority + .as_ref() + .map(|authority| authority.agent_label.as_str()) + .or_else(|| self.detected_agent.map(crate::detect::agent_label)) + } + + pub fn effective_known_agent(&self) -> Option<Agent> { + if let Some(authority) = &self.hook_authority { + return crate::detect::parse_agent_label(&authority.agent_label); + } + self.detected_agent + } + + pub fn effective_custom_status(&self) -> Option<&str> { + self.hook_authority + .as_ref() + .and_then(|authority| authority.custom_status.as_deref()) + } + + pub fn set_manual_label(&mut self, label: String) { + let label = label.trim().to_string(); + self.manual_label = (!label.is_empty()).then_some(label); + } + + pub fn clear_manual_label(&mut self) { + self.manual_label = None; + } + + pub fn border_label(&self, show_agent_labels: bool) -> Option<&str> { + self.manual_label.as_deref().or_else(|| { + show_agent_labels + .then(|| self.effective_agent_label()) + .flatten() + }) + } + + fn recompute_effective_state( + &mut self, + previous_agent_label: Option<String>, + previous_known_agent: Option<Agent>, + previous_state: AgentState, + ) -> Option<EffectiveStateChange> { + let state = self + .hook_authority + .as_ref() + .map(|authority| authority.state) + .unwrap_or(self.fallback_state); + let agent_label = self.effective_agent_label().map(str::to_string); + let known_agent = self.effective_known_agent(); + + let custom_status = self.effective_custom_status().map(str::to_string); + + if previous_agent_label == agent_label && previous_state == state { + return None; + } + + self.state = state; + Some(EffectiveStateChange { + previous_agent_label, + previous_known_agent, + previous_state, + agent_label, + known_agent, + state, + custom_status, + }) + } +} + +pub(crate) fn stabilize_agent_state( + agent: Option<Agent>, + previous: AgentState, + raw: AgentState, + now: std::time::Instant, + last_claude_working_at: &mut Option<std::time::Instant>, +) -> AgentState { + if agent != Some(Agent::Claude) { + return raw; + } + + match raw { + AgentState::Working => { + *last_claude_working_at = Some(now); + AgentState::Working + } + AgentState::Blocked => AgentState::Blocked, + AgentState::Idle if previous == AgentState::Working => { + if last_claude_working_at + .is_some_and(|last_working| now.duration_since(last_working) < CLAUDE_WORKING_HOLD) + { + AgentState::Working + } else { + AgentState::Idle + } + } + _ => raw, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_terminal() -> TerminalState { + TerminalState::new(TerminalId::alloc(), "/tmp".into()) + } + + #[test] + fn claude_working_is_sticky_for_short_gap() { + let now = std::time::Instant::now(); + let mut last_working = None; + + let working = stabilize_agent_state( + Some(Agent::Claude), + AgentState::Idle, + AgentState::Working, + now, + &mut last_working, + ); + assert_eq!(working, AgentState::Working); + + let still_working = stabilize_agent_state( + Some(Agent::Claude), + AgentState::Working, + AgentState::Idle, + now + std::time::Duration::from_millis(400), + &mut last_working, + ); + assert_eq!(still_working, AgentState::Working); + } + + #[test] + fn claude_transitions_to_idle_after_hold_expires() { + let now = std::time::Instant::now(); + let mut last_working = Some(now); + + let state = stabilize_agent_state( + Some(Agent::Claude), + AgentState::Working, + AgentState::Idle, + now + CLAUDE_WORKING_HOLD + std::time::Duration::from_millis(1), + &mut last_working, + ); + assert_eq!(state, AgentState::Idle); + } + + #[test] + fn non_claude_states_are_unchanged() { + let now = std::time::Instant::now(); + let mut last_working = None; + + let state = stabilize_agent_state( + Some(Agent::Codex), + AgentState::Working, + AgentState::Idle, + now, + &mut last_working, + ); + assert_eq!(state, AgentState::Idle); + } + + #[test] + fn hook_authority_overrides_fallback_for_same_agent() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + ); + + assert_eq!(terminal.detected_agent, Some(Agent::Pi)); + assert_eq!(terminal.fallback_state, AgentState::Idle); + assert_eq!(terminal.effective_agent_label(), Some("pi")); + assert_eq!(terminal.state, AgentState::Working); + } + + #[test] + fn hook_authority_can_override_with_unknown_agent_label() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority( + "herdr:custom".into(), + "hermes".into(), + AgentState::Working, + None, + None, + ); + + assert_eq!(terminal.detected_agent, Some(Agent::Pi)); + assert_eq!(terminal.effective_agent_label(), Some("hermes")); + assert_eq!(terminal.effective_known_agent(), None); + assert_eq!(terminal.state, AgentState::Working); + } + + #[test] + fn border_label_prefers_manual_label_over_agent_label() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Claude), AgentState::Idle); + + assert_eq!(terminal.border_label(false), None); + assert_eq!(terminal.border_label(true), Some("claude")); + + terminal.set_manual_label(" reviewer ".into()); + assert_eq!(terminal.border_label(false), Some("reviewer")); + assert_eq!(terminal.border_label(true), Some("reviewer")); + + terminal.set_manual_label(" ".into()); + assert_eq!(terminal.border_label(true), Some("claude")); + + terminal.set_manual_label("reviewer".into()); + terminal.clear_manual_label(); + assert_eq!(terminal.border_label(true), Some("claude")); + } + + #[test] + fn hook_authority_survives_unrelated_detected_agent_clear() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority( + "herdr:custom".into(), + "hermes".into(), + AgentState::Working, + None, + None, + ); + + terminal.set_detected_state(None, AgentState::Unknown); + + assert!(terminal.hook_authority.is_some()); + assert_eq!(terminal.detected_agent, None); + assert_eq!(terminal.effective_agent_label(), Some("hermes")); + assert_eq!(terminal.state, AgentState::Working); + } + + #[test] + fn detected_agent_clear_clears_matching_hook_authority() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); + terminal.set_hook_authority( + "herdr:opencode".into(), + "opencode".into(), + AgentState::Idle, + None, + None, + ); + + terminal.set_detected_state(None, AgentState::Unknown); + + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.detected_agent, None); + assert_eq!(terminal.fallback_state, AgentState::Unknown); + assert_eq!(terminal.effective_agent_label(), None); + assert_eq!(terminal.state, AgentState::Unknown); + } + + #[test] + fn detected_agent_change_clears_previous_matching_hook_authority() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); + terminal.set_hook_authority( + "herdr:codex".into(), + "codex".into(), + AgentState::Idle, + None, + None, + ); + + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Working); + + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.detected_agent, Some(Agent::OpenCode)); + assert_eq!(terminal.effective_agent_label(), Some("opencode")); + assert_eq!(terminal.state, AgentState::Working); + } + + #[test] + fn release_agent_clears_identity_immediately() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + ); + + terminal.release_agent("herdr:pi", "pi", None); + + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.detected_agent, None); + assert_eq!(terminal.fallback_state, AgentState::Unknown); + assert_eq!(terminal.state, AgentState::Unknown); + } + + #[test] + fn stale_hook_report_sequence_is_ignored_for_same_source() { + let mut terminal = test_terminal(); + terminal.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some(20), + ); + + let change = terminal.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Idle, + None, + Some(19), + ); + + assert!(change.is_none()); + assert_eq!(terminal.state, AgentState::Working); + assert_eq!( + terminal.hook_authority.as_ref().unwrap().state, + AgentState::Working + ); + } + + #[test] + fn unsequenced_hook_report_is_ignored_after_source_uses_sequence() { + let mut terminal = test_terminal(); + terminal.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some(20), + ); + + let change = terminal.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Idle, + None, + None, + ); + + assert!(change.is_none()); + assert_eq!(terminal.state, AgentState::Working); + } + + #[test] + fn stale_release_sequence_is_ignored_for_same_source() { + let mut terminal = test_terminal(); + terminal.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some(20), + ); + + let change = terminal.release_agent("herdr:pi", "pi", Some(19)); + + assert!(change.is_none()); + assert_eq!(terminal.state, AgentState::Working); + assert!(terminal.hook_authority.is_some()); + } + + #[test] + fn stale_clear_all_sequence_is_checked_against_current_authority_source() { + let mut terminal = test_terminal(); + terminal.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some(20), + ); + + let change = terminal.clear_hook_authority(None, Some(19)); + + assert!(change.is_none()); + assert_eq!(terminal.state, AgentState::Working); + assert!(terminal.hook_authority.is_some()); + } + + #[test] + fn same_sequence_from_different_sources_is_independent() { + let mut terminal = test_terminal(); + terminal.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some(20), + ); + + terminal.set_hook_authority( + "custom:pi".into(), + "pi".into(), + AgentState::Idle, + None, + Some(19), + ); + + assert_eq!(terminal.state, AgentState::Idle); + assert_eq!( + terminal.hook_authority.as_ref().unwrap().source, + "custom:pi" + ); + } +} @@ -127,7 +127,7 @@ fn resize_background_tab_panes_to_terminal_area( if app.active == Some(ws_idx) && tab_idx == ws.active_tab_index() { continue; } - resize_tab_panes(tab, terminal_area, cell_size); + resize_tab_panes(app, tab, terminal_area, cell_size); } } } @@ -396,13 +396,13 @@ mod tests { let first_pane = ws.tabs[0].root_pane; let second_pane = ws.test_split(ratatui::layout::Direction::Horizontal); - ws.tabs[0].runtimes.insert( + ws.insert_test_runtime( first_pane, - crate::pane::PaneRuntime::test_with_screen_bytes(20, 5, b"left"), + crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left"), ); - ws.tabs[0].runtimes.insert( + ws.insert_test_runtime( second_pane, - crate::pane::PaneRuntime::test_with_screen_bytes(20, 5, b"r\r\nb"), + crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"r\r\nb"), ); ws.tabs[0].layout.focus_pane(first_pane); @@ -480,10 +480,14 @@ mod tests { let repo = temp_git_repo("main"); ws.identity_cwd = repo.clone(); let root_pane = ws.tabs[0].root_pane; - ws.tabs[0].pane_cwds.insert(root_pane, repo.clone()); ws.refresh_git_ahead_behind(); app.workspaces = vec![ws]; + app.ensure_test_terminals(); + let root_terminal_id = app.workspaces[0].tabs[0].panes[&root_pane] + .attached_terminal_id + .clone(); + app.terminals.get_mut(&root_terminal_id).unwrap().cwd = repo.clone(); app.selected = 0; app.mode = Mode::Navigate; @@ -681,9 +685,9 @@ mod tests { let mut app = crate::app::state::AppState::test_new(); let mut ws = Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; - ws.tabs[0].runtimes.insert( + ws.insert_test_runtime( pane_id, - crate::pane::PaneRuntime::test_with_scrollback_bytes( + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( 12, 4, 4096, diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs index 9e8c57e9..8bcdf184 100644 --- a/src/ui/mobile.rs +++ b/src/ui/mobile.rs @@ -266,7 +266,7 @@ fn render_header_status(app: &AppState, frame: &mut Frame, area: Rect) { return; }; - let (state, seen) = ws.aggregate_state(); + let (state, seen) = ws.aggregate_state(&app.terminals); let (dot, dot_style) = if matches!(state, AgentState::Working) { ( super::spinner_frame(app.spinner_tick), @@ -430,7 +430,7 @@ fn render_mobile_switcher_content(app: &AppState, frame: &mut Frame, viewport: R let active = Some(idx) == app.active; let selected = idx == app.selected; let bg = mobile_item_bg(selected, active, p); - let (state, seen) = ws.aggregate_state(); + let (state, seen) = ws.aggregate_state(&app.terminals); let (dot, dot_style) = state_dot(state, seen, p); let title = Line::from(vec![ Span::styled(" ", Style::default().bg(bg)), @@ -824,7 +824,7 @@ fn agent_priority_label(app: &AppState) -> String { let mut blocked = 0usize; let mut working = 0usize; let mut done = 0usize; - for detail in ws.pane_details() { + for detail in ws.pane_details(&app.terminals) { match (detail.state, detail.seen) { (AgentState::Blocked, _) => blocked += 1, (AgentState::Working, _) => working += 1, diff --git a/src/ui/panes.rs b/src/ui/panes.rs index 289d5fab..c90183ea 100644 --- a/src/ui/panes.rs +++ b/src/ui/panes.rs @@ -11,9 +11,9 @@ use super::widgets::panel_contrast_fg; use crate::app::state::Palette; use crate::app::{AppState, Mode}; use crate::layout::PaneInfo; -use crate::pane::PaneRuntime; +use crate::terminal::TerminalRuntime; -pub(crate) fn pane_is_scrolled_back(rt: &PaneRuntime) -> bool { +pub(crate) fn pane_is_scrolled_back(rt: &TerminalRuntime) -> bool { rt.scroll_metrics() .is_some_and(|metrics| metrics.offset_from_bottom > 0) } @@ -55,7 +55,20 @@ fn stable_terminal_inner_rect(pane_inner: Rect) -> Rect { ) } -fn stable_scrollbar_gutter(rt: &PaneRuntime, pane_inner: Rect) -> (Rect, Option<Rect>) { +fn runtime_for_tab_pane<'a>( + app: &'a AppState, + tab: &'a crate::workspace::Tab, + pane_id: crate::layout::PaneId, +) -> Option<&'a TerminalRuntime> { + #[cfg(test)] + if let Some(runtime) = tab.runtimes.get(&pane_id) { + return Some(runtime); + } + let terminal_id = tab.terminal_id(pane_id)?; + app.terminal_runtimes.get(terminal_id) +} + +fn stable_scrollbar_gutter(rt: &TerminalRuntime, pane_inner: Rect) -> (Rect, Option<Rect>) { let inner_rect = stable_terminal_inner_rect(pane_inner); if inner_rect == pane_inner { return (inner_rect, None); @@ -76,6 +89,7 @@ fn stable_scrollbar_gutter(rt: &PaneRuntime, pane_inner: Rect) -> (Rect, Option< /// Resize every visible runtime in a tab to the geometry it would receive if the tab were selected. pub(super) fn resize_tab_panes( + app: &AppState, tab: &crate::workspace::Tab, area: Rect, cell_size: crate::kitty_graphics::HostCellSize, @@ -84,7 +98,7 @@ pub(super) fn resize_tab_panes( if tab.zoomed { let focused_id = tab.layout.focused(); - if let Some(rt) = tab.runtimes.get(&focused_id) { + if let Some(rt) = runtime_for_tab_pane(app, tab, focused_id) { let inner_rect = stable_terminal_inner_rect(area); rt.resize( inner_rect.height, @@ -103,7 +117,7 @@ pub(super) fn resize_tab_panes( area }; - if let Some(rt) = tab.runtimes.get(&info.id) { + if let Some(rt) = runtime_for_tab_pane(app, tab, info.id) { let inner_rect = stable_terminal_inner_rect(pane_inner); rt.resize( inner_rect.height, @@ -136,7 +150,7 @@ pub(super) fn compute_pane_infos( let focused_id = ws.layout.focused(); let mut inner_rect = area; let mut scrollbar_rect = None; - if let Some(rt) = ws.runtimes.get(&focused_id) { + if let Some(rt) = app.runtime_for_pane_in_workspace(ws_idx, focused_id) { (inner_rect, scrollbar_rect) = stable_scrollbar_gutter(rt, area); if resize_panes { rt.resize( @@ -175,7 +189,7 @@ pub(super) fn compute_pane_infos( let mut inner_rect = pane_inner; let mut scrollbar_rect = None; - if let Some(rt) = ws.runtimes.get(&info.id) { + if let Some(rt) = app.runtime_for_pane_in_workspace(ws_idx, info.id) { (inner_rect, scrollbar_rect) = stable_scrollbar_gutter(rt, pane_inner); if resize_panes { rt.resize( @@ -208,7 +222,7 @@ pub(super) fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) { let terminal_active = app.mode == Mode::Terminal; for info in &app.view.pane_infos { - if let Some(rt) = ws.runtimes.get(&info.id) { + if let Some(rt) = app.runtime_for_pane_in_workspace(ws_idx, info.id) { if multi_pane { let (border_style, border_set) = if info.is_focused && terminal_active { ( @@ -233,7 +247,10 @@ pub(super) fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) { .border_set(border_set); if let Some(title) = ws .pane_state(info.id) - .and_then(|pane| pane.border_label(app.show_agent_labels_on_pane_borders)) + .and_then(|pane| app.terminals.get(&pane.attached_terminal_id)) + .and_then(|terminal| { + terminal.border_label(app.show_agent_labels_on_pane_borders) + }) .and_then(|label| pane_border_title(label, info.rect.width)) { block = block.title(Line::from(Span::styled(title, border_style))); @@ -360,7 +377,7 @@ fn render_empty(app: &AppState, frame: &mut Frame, area: Rect) { #[cfg(test)] mod tests { use super::*; - use crate::pane::PaneRuntime; + use crate::terminal::TerminalRuntime; use crate::workspace::Workspace; #[test] @@ -381,7 +398,7 @@ mod tests { let root_pane = workspace.tabs[0].root_pane; workspace.tabs[0].runtimes.insert( root_pane, - PaneRuntime::test_with_scrollback_bytes(40, 8, 1024, b"ready\n"), + TerminalRuntime::test_with_scrollback_bytes(40, 8, 1024, b"ready\n"), ); app.workspaces = vec![workspace]; app.active = Some(0); @@ -408,7 +425,7 @@ mod tests { let root_pane = workspace.tabs[0].root_pane; workspace.tabs[0].runtimes.insert( root_pane, - PaneRuntime::test_with_scrollback_bytes(40, 8, 1024, b"ready\n"), + TerminalRuntime::test_with_scrollback_bytes(40, 8, 1024, b"ready\n"), ); app.workspaces = vec![workspace]; app.active = Some(0); @@ -434,7 +451,7 @@ mod tests { let root_pane = workspace.tabs[0].root_pane; workspace.tabs[0].runtimes.insert( root_pane, - PaneRuntime::test_with_scrollback_bytes(4, 8, 1024, b"ready\n"), + TerminalRuntime::test_with_scrollback_bytes(4, 8, 1024, b"ready\n"), ); app.workspaces = vec![workspace]; app.active = Some(0); @@ -460,7 +477,7 @@ mod tests { let root_pane = workspace.tabs[0].root_pane; workspace.tabs[0].runtimes.insert( root_pane, - PaneRuntime::test_with_scrollback_bytes( + TerminalRuntime::test_with_scrollback_bytes( 40, 8, 1024, diff --git a/src/ui/scrollbar.rs b/src/ui/scrollbar.rs index 34e739d0..b73b2c50 100644 --- a/src/ui/scrollbar.rs +++ b/src/ui/scrollbar.rs @@ -165,7 +165,7 @@ pub(super) fn render_pane_scrollbar( app: &AppState, frame: &mut Frame, info: &PaneInfo, - rt: &crate::pane::PaneRuntime, + rt: &crate::terminal::TerminalRuntime, ) { let Some(metrics) = rt.scroll_metrics() else { return; diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index 29f3525d..eb6d6fab 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -116,7 +116,7 @@ pub(crate) fn agent_panel_entries(app: &AppState) -> Vec<AgentPanelEntry> { let Some(ws) = app.workspaces.get(ws_idx) else { return Vec::new(); }; - ws.pane_details() + ws.pane_details(&app.terminals) .into_iter() .map(|detail| AgentPanelEntry { ws_idx, @@ -138,7 +138,7 @@ pub(crate) fn agent_panel_entries(app: &AppState) -> Vec<AgentPanelEntry> { .flat_map(|(ws_idx, ws)| { let multi_tab = ws.tabs.len() > 1; let workspace_label = ws.display_name(); - ws.pane_details() + ws.pane_details(&app.terminals) .into_iter() .map(move |detail| AgentPanelEntry { ws_idx, @@ -429,7 +429,7 @@ pub(super) fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: if y >= ws_area.y + ws_area.height { break; } - let (agg_state, agg_seen) = ws.aggregate_state(); + let (agg_state, agg_seen) = ws.aggregate_state(&app.terminals); let (icon, icon_style) = state_dot(agg_state, agg_seen, p); let is_selected = visible_idx == app.selected && is_navigating; let is_active = Some(visible_idx) == app.active; @@ -487,7 +487,7 @@ pub(super) fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: if detail_content_area != Rect::default() { if let Some(ws_idx) = detail_ws_idx { if let Some(ws) = app.workspaces.get(ws_idx) { - for (detail_idx, detail) in ws.pane_details().iter().enumerate() { + for (detail_idx, detail) in ws.pane_details(&app.terminals).iter().enumerate() { let y = detail_content_area.y + detail_idx as u16; if y >= detail_content_area.y + detail_content_area.height { break; @@ -603,7 +603,7 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig let is_active = Some(i) == app.active; let is_dragged = dragged_ws_idx == Some(i); let highlighted = selected || is_active || is_dragged; - let (agg_state, agg_seen) = ws.aggregate_state(); + let (agg_state, agg_seen) = ws.aggregate_state(&app.terminals); if highlighted { let bg = if selected { @@ -899,24 +899,28 @@ mod tests { #[test] fn all_workspaces_agent_panel_entries_use_workspace_and_optional_tab_labels() { let mut app = crate::app::state::AppState::test_new(); - let mut first = Workspace::test_new("one"); + let first = Workspace::test_new("one"); let first_pane = first.tabs[0].root_pane; - first.tabs[0] - .panes - .get_mut(&first_pane) - .unwrap() - .detected_agent = Some(Agent::Pi); - let mut second = Workspace::test_new("two"); let second_tab = second.test_add_tab(Some("logs")); let second_pane = second.tabs[second_tab].root_pane; - second.tabs[second_tab] - .panes - .get_mut(&second_pane) - .unwrap() - .detected_agent = Some(Agent::Claude); app.workspaces = vec![first, second]; + app.ensure_test_terminals(); + let first_terminal_id = app.workspaces[0].tabs[0].panes[&first_pane] + .attached_terminal_id + .clone(); + app.terminals + .get_mut(&first_terminal_id) + .unwrap() + .detected_agent = Some(Agent::Pi); + let second_terminal_id = app.workspaces[1].tabs[second_tab].panes[&second_pane] + .attached_terminal_id + .clone(); + app.terminals + .get_mut(&second_terminal_id) + .unwrap() + .detected_agent = Some(Agent::Claude); app.active = Some(0); app.selected = 0; app.agent_panel_scope = AgentPanelScope::AllWorkspaces; diff --git a/src/workspace.rs b/src/workspace.rs index 59b3e35d..553206c4 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -12,8 +12,8 @@ use crate::events::AppEvent; use crate::layout::PaneId; #[cfg(test)] use crate::layout::TileLayout; -use crate::pane::{PaneRuntime, PaneState}; -use crate::terminal::TerminalId; +use crate::pane::PaneState; +use crate::terminal::{TerminalId, TerminalRuntime, TerminalState}; mod aggregate; mod git; @@ -62,6 +62,8 @@ pub struct Workspace { pub(crate) next_public_pane_number: usize, pub tabs: Vec<Tab>, pub active_tab: usize, + #[cfg(test)] + pub(crate) test_runtimes: HashMap<PaneId, TerminalRuntime>, } impl Deref for Workspace { @@ -90,7 +92,7 @@ impl Workspace { events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, - ) -> std::io::Result<Self> { + ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { Self::new_with_tab( initial_cwd, rows, @@ -114,7 +116,7 @@ impl Workspace { events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, - ) -> std::io::Result<Self> { + ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { Self::new_with_tab( initial_cwd, rows, @@ -139,8 +141,8 @@ impl Workspace { render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, argv: Option<&[String]>, - ) -> std::io::Result<Self> { - let tab = if let Some(argv) = argv { + ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { + let (tab, terminal, runtime) = if let Some(argv) = argv { Tab::new_argv_command( 1, initial_cwd.clone(), @@ -168,17 +170,23 @@ impl Workspace { }; let mut public_pane_numbers = HashMap::new(); public_pane_numbers.insert(tab.root_pane, 1); - Ok(Self { - id: generate_workspace_id(), - custom_name: None, - identity_cwd: initial_cwd.clone(), - cached_git_branch: git_branch(&initial_cwd), - cached_git_ahead_behind: None, - public_pane_numbers, - next_public_pane_number: 2, - tabs: vec![tab], - active_tab: 0, - }) + Ok(( + Self { + id: generate_workspace_id(), + custom_name: None, + identity_cwd: initial_cwd.clone(), + cached_git_branch: git_branch(&initial_cwd), + cached_git_ahead_behind: None, + public_pane_numbers, + next_public_pane_number: 2, + tabs: vec![tab], + active_tab: 0, + #[cfg(test)] + test_runtimes: HashMap::new(), + }, + terminal, + runtime, + )) } pub fn active_tab(&self) -> Option<&Tab> { @@ -215,7 +223,7 @@ impl Workspace { cwd: PathBuf, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, - ) -> std::io::Result<usize> { + ) -> std::io::Result<(usize, TerminalState, TerminalRuntime)> { self.create_tab_with_runtime( rows, cols, @@ -234,7 +242,7 @@ impl Workspace { scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, argv: Option<&[String]>, - ) -> std::io::Result<usize> { + ) -> std::io::Result<(usize, TerminalState, TerminalRuntime)> { let number = self.tabs.len() + 1; let events = self .active_tab() @@ -249,7 +257,7 @@ impl Workspace { .map(|tab| tab.render_dirty.clone()) .expect("workspace must always have at least one tab"); - let tab = if let Some(argv) = argv { + let (tab, terminal, runtime) = if let Some(argv) = argv { Tab::new_argv_command( number, cwd, @@ -277,7 +285,7 @@ impl Workspace { }; self.register_new_pane(tab.root_pane); self.tabs.push(tab); - Ok(self.tabs.len() - 1) + Ok((self.tabs.len() - 1, terminal, runtime)) } pub fn close_tab(&mut self, idx: usize) -> bool { @@ -335,8 +343,8 @@ impl Workspace { cwd: Option<PathBuf>, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, - ) -> std::io::Result<PaneId> { - let new_id = self + ) -> std::io::Result<crate::workspace::tab::NewPane> { + let new_pane = self .active_tab_mut() .expect("workspace must always have at least one tab") .split_focused( @@ -347,8 +355,8 @@ impl Workspace { scrollback_limit_bytes, host_terminal_theme, )?; - self.register_new_pane(new_id); - Ok(new_id) + self.register_new_pane(new_pane.pane_id); + Ok(new_pane) } pub fn split_pane( @@ -361,7 +369,7 @@ impl Workspace { scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, focus_new_pane: bool, - ) -> Option<std::io::Result<(usize, PaneId)>> { + ) -> Option<std::io::Result<(usize, crate::workspace::tab::NewPane)>> { self.split_pane_with_runtime( pane_id, direction, @@ -387,7 +395,7 @@ impl Workspace { scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, focus_new_pane: bool, - ) -> Option<std::io::Result<(usize, PaneId)>> { + ) -> Option<std::io::Result<(usize, crate::workspace::tab::NewPane)>> { self.split_pane_with_runtime( pane_id, direction, @@ -413,12 +421,12 @@ impl Workspace { host_terminal_theme: crate::terminal_theme::TerminalTheme, focus_new_pane: bool, argv: Option<&[String]>, - ) -> Option<std::io::Result<(usize, PaneId)>> { + ) -> Option<std::io::Result<(usize, crate::workspace::tab::NewPane)>> { let tab_idx = self.find_tab_index_for_pane(pane_id)?; let tab = &mut self.tabs[tab_idx]; let previous_focus = tab.layout.focused(); tab.layout.focus_pane(pane_id); - let new_id = match if let Some(argv) = argv { + let new_pane = match if let Some(argv) = argv { tab.split_focused_argv_command( direction, rows, @@ -438,7 +446,7 @@ impl Workspace { host_terminal_theme, ) } { - Ok(new_id) => new_id, + Ok(new_pane) => new_pane, Err(err) => { tab.layout.focus_pane(previous_focus); return Some(Err(err)); @@ -447,8 +455,8 @@ impl Workspace { if !focus_new_pane { tab.layout.focus_pane(previous_focus); } - self.register_new_pane(new_id); - Some(Ok((tab_idx, new_id))) + self.register_new_pane(new_pane.pane_id); + Some(Ok((tab_idx, new_pane))) } /// Close the focused pane. Returns true if the workspace should close. @@ -462,11 +470,8 @@ impl Workspace { return tab_count <= 1 || self.close_active_tab_and_report(); } - if let Some((removed, runtime)) = self.active_tab_mut().and_then(Tab::close_focused) { + if let Some((removed, _terminal_id)) = self.active_tab_mut().and_then(Tab::close_focused) { self.unregister_pane(removed); - if let Some(runtime) = runtime { - runtime.shutdown(removed); - } } false } @@ -494,7 +499,7 @@ impl Workspace { return false; } - if let Some((removed, _)) = self.tabs[tab_idx].remove_pane(pane_id) { + if let Some((removed, _terminal_id)) = self.tabs[tab_idx].remove_pane(pane_id) { self.unregister_pane(removed); } false @@ -509,10 +514,7 @@ impl Workspace { } pub fn resolved_identity_cwd(&self) -> Option<PathBuf> { - self.tabs - .first() - .and_then(Tab::root_cwd) - .or_else(|| Some(self.identity_cwd.clone())) + Some(self.identity_cwd.clone()) } pub fn display_name(&self) -> String { @@ -557,10 +559,6 @@ impl Workspace { } } - pub fn focused_runtime(&self) -> Option<&PaneRuntime> { - self.active_tab().and_then(Tab::focused_runtime) - } - pub fn find_tab_index_for_pane(&self, pane_id: PaneId) -> Option<usize> { self.tabs .iter() @@ -571,16 +569,6 @@ impl Workspace { self.tabs.iter().find_map(|tab| tab.panes.get(&pane_id)) } - pub fn pane_state_mut(&mut self, pane_id: PaneId) -> Option<&mut PaneState> { - self.tabs - .iter_mut() - .find_map(|tab| tab.panes.get_mut(&pane_id)) - } - - pub fn runtime(&self, pane_id: PaneId) -> Option<&PaneRuntime> { - self.tabs.iter().find_map(|tab| tab.runtimes.get(&pane_id)) - } - pub fn terminal_id(&self, pane_id: PaneId) -> Option<&TerminalId> { self.tabs.iter().find_map(|tab| tab.terminal_id(pane_id)) } @@ -611,11 +599,8 @@ impl Workspace { return false; } - if let Some((removed, runtime)) = self.tabs[tab_idx].close_pane(pane_id) { + if let Some((removed, _terminal_id)) = self.tabs[tab_idx].close_pane(pane_id) { self.unregister_pane(removed); - if let Some(runtime) = runtime { - runtime.shutdown(removed); - } } false } @@ -660,20 +645,15 @@ impl Workspace { let render_dirty = Arc::new(AtomicBool::new(false)); let identity_cwd = std::env::current_dir().unwrap_or_else(|_| "/".into()); let (layout, root_id) = TileLayout::new(); + let terminal_id = TerminalId::alloc(); let mut panes = HashMap::new(); - panes.insert(root_id, PaneState::new()); - let mut pane_cwds = HashMap::new(); - pane_cwds.insert(root_id, identity_cwd.clone()); - let mut terminal_ids = HashMap::new(); - terminal_ids.insert(root_id, TerminalId::alloc()); + panes.insert(root_id, PaneState::new(terminal_id)); let tab = Tab { custom_name: None, number: 1, root_pane: root_id, layout, panes, - pane_cwds, - terminal_ids, runtimes: HashMap::new(), zoomed: false, events, @@ -692,16 +672,19 @@ impl Workspace { next_public_pane_number: 2, tabs: vec![tab], active_tab: 0, + test_runtimes: HashMap::new(), } } + pub(crate) fn insert_test_runtime(&mut self, pane_id: PaneId, runtime: TerminalRuntime) { + self.test_runtimes.insert(pane_id, runtime); + } + pub(crate) fn test_split(&mut self, direction: Direction) -> PaneId { let tab = self.active_tab_mut().expect("workspace must have tab"); let new_id = tab.layout.split_focused(direction); - tab.panes.insert(new_id, PaneState::new()); - let cwd = std::env::current_dir().unwrap_or_else(|_| "/".into()); - tab.pane_cwds.insert(new_id, cwd); - tab.terminal_ids.insert(new_id, TerminalId::alloc()); + tab.panes + .insert(new_id, PaneState::new(TerminalId::alloc())); self.register_new_pane(new_id); new_id } @@ -712,20 +695,13 @@ impl Workspace { let render_dirty = Arc::new(AtomicBool::new(false)); let (layout, root_id) = TileLayout::new(); let mut panes = HashMap::new(); - panes.insert(root_id, PaneState::new()); - let cwd = std::env::current_dir().unwrap_or_else(|_| "/".into()); - let mut pane_cwds = HashMap::new(); - pane_cwds.insert(root_id, cwd); - let mut terminal_ids = HashMap::new(); - terminal_ids.insert(root_id, TerminalId::alloc()); + panes.insert(root_id, PaneState::new(TerminalId::alloc())); let tab = Tab { custom_name: name.map(str::to_string), number: self.tabs.len() + 1, root_pane: root_id, layout, panes, - pane_cwds, - terminal_ids, runtimes: HashMap::new(), zoomed: false, events, @@ -743,13 +719,10 @@ mod tests { use super::*; #[test] - fn workspace_identity_follows_first_tab_root_pane_cwd() { + fn workspace_identity_uses_identity_cwd() { let mut ws = Workspace::test_new("ignored"); ws.custom_name = None; - let root_pane = ws.tabs[0].root_pane; - ws.tabs[0] - .pane_cwds - .insert(root_pane, PathBuf::from("/herdr-test/pion")); + ws.identity_cwd = PathBuf::from("/herdr-test/pion"); assert_eq!(ws.display_name(), "pion"); assert_eq!( diff --git a/src/workspace/aggregate.rs b/src/workspace/aggregate.rs index 0507781e..3ac7ea9b 100644 --- a/src/workspace/aggregate.rs +++ b/src/workspace/aggregate.rs @@ -1,5 +1,8 @@ +use std::collections::HashMap; + use crate::detect::{Agent, AgentState}; use crate::layout::PaneId; +use crate::terminal::{TerminalId, TerminalState}; use super::{Tab, Workspace}; @@ -18,29 +21,32 @@ pub struct PaneDetail { } impl Tab { - pub fn has_working_pane(&self) -> bool { - self.panes - .values() - .any(|pane| pane.state == AgentState::Working) + pub fn has_working_pane(&self, terminals: &HashMap<TerminalId, TerminalState>) -> 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) -> Vec<PaneDetail> { + pub fn pane_details(&self, terminals: &HashMap<TerminalId, TerminalState>) -> Vec<PaneDetail> { self.layout .pane_ids() .iter() .filter_map(|id| { let pane = self.panes.get(id)?; - let agent_label = pane.effective_agent_label()?.to_string(); + let terminal = terminals.get(&pane.attached_terminal_id)?; + let agent_label = 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: pane.effective_known_agent(), - state: pane.state, + agent: terminal.effective_known_agent(), + state: terminal.state, seen: pane.seen, - custom_status: pane.effective_custom_status().map(str::to_string), + custom_status: terminal.effective_custom_status().map(str::to_string), }) }) .collect() @@ -58,24 +64,31 @@ fn pane_attention_priority(state: AgentState, seen: bool) -> u8 { } impl Workspace { - pub fn aggregate_state(&self) -> (AgentState, bool) { + pub fn aggregate_state( + &self, + terminals: &HashMap<TerminalId, TerminalState>, + ) -> (AgentState, bool) { self.tabs .iter() .flat_map(|tab| tab.panes.values()) - .map(|pane| (pane.state, pane.seen)) + .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) -> bool { - self.tabs.iter().any(Tab::has_working_pane) + pub fn has_working_pane(&self, terminals: &HashMap<TerminalId, TerminalState>) -> bool { + self.tabs.iter().any(|tab| tab.has_working_pane(terminals)) } - pub fn pane_details(&self) -> Vec<PaneDetail> { + pub fn pane_details(&self, terminals: &HashMap<TerminalId, TerminalState>) -> Vec<PaneDetail> { let multi_tab = self.tabs.len() > 1; self.tabs .iter() - .flat_map(Tab::pane_details) + .flat_map(|tab| tab.pane_details(terminals)) .map(|mut detail| { if multi_tab { detail.label = format!("{}·{}", detail.tab_label, detail.agent_label); @@ -93,10 +106,18 @@ mod tests { 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 (state, seen) = ws.aggregate_state(); + 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); } @@ -111,10 +132,16 @@ mod tests { .find(|id| **id != id2) .copied() .unwrap(); - ws.tabs[0].panes.get_mut(&root_id).unwrap().state = AgentState::Idle; - ws.tabs[0].panes.get_mut(&id2).unwrap().state = AgentState::Working; + 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); - let (state, seen) = ws.aggregate_state(); assert_eq!(state, AgentState::Working); assert!(seen); } @@ -129,68 +156,61 @@ mod tests { .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.state = AgentState::Idle; root.seen = false; - ws.tabs[0].panes.get_mut(&id2).unwrap().state = AgentState::Working; - let (state, seen) = ws.aggregate_state(); + let (state, seen) = ws.aggregate_state(&terminals); + assert_eq!(state, AgentState::Idle); assert!(!seen); } #[test] - fn pane_details_hide_plain_shells() { - let mut ws = Workspace::test_new("test"); - let root_pane = ws.tabs[0].root_pane; - ws.tabs[0].panes.get_mut(&root_pane).unwrap().detected_agent = Some(Agent::Pi); - ws.test_split(Direction::Horizontal); - - let details = ws.pane_details(); - assert_eq!(details.len(), 1); - assert_eq!(details[0].label, "pi"); - } - - #[test] - fn pane_details_include_tab_context_when_workspace_has_multiple_tabs() { - let mut ws = Workspace::test_new("test"); - ws.tabs[0].set_custom_name("main".into()); - let root_pane = ws.tabs[0].root_pane; - ws.tabs[0].panes.get_mut(&root_pane).unwrap().detected_agent = Some(Agent::Pi); - - let tab_idx = ws.test_add_tab(Some("logs")); - let second_root_pane = ws.tabs[tab_idx].root_pane; - ws.tabs[tab_idx] - .panes - .get_mut(&second_root_pane) - .unwrap() - .detected_agent = Some(Agent::Claude); - - let details = ws.pane_details(); - assert_eq!(details.len(), 2); - assert!(details.iter().any(|detail| detail.label == "main·pi")); - assert!(details.iter().any(|detail| detail.label == "logs·claude")); - } - - #[test] - fn pane_details_include_hook_reported_unknown_agents() { + 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; - ws.tabs[0] - .panes - .get_mut(&root_pane) - .unwrap() - .set_hook_authority( - "custom:hermes".into(), - "hermes".into(), - AgentState::Working, - None, - None, - ); - - let details = ws.pane_details(); - assert_eq!(details.len(), 1); - assert_eq!(details[0].agent_label, "hermes"); - assert_eq!(details[0].agent, None); + 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)), + ] + ); } } diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs index d809f944..943a45a0 100644 --- a/src/workspace/tab.rs +++ b/src/workspace/tab.rs @@ -8,8 +8,16 @@ use tokio::sync::{mpsc, Notify}; use crate::events::AppEvent; use crate::layout::{PaneId, TileLayout}; -use crate::pane::{PaneRuntime, PaneState}; -use crate::terminal::TerminalId; +use crate::pane::PaneState; +use crate::terminal::{TerminalId, TerminalRuntime, TerminalState}; + +pub(crate) type DetachedPane = (PaneId, TerminalId); + +pub struct NewPane { + pub pane_id: PaneId, + pub terminal: TerminalState, + pub runtime: TerminalRuntime, +} enum SplitCommand<'a> { Shell { @@ -27,29 +35,16 @@ pub struct Tab { /// Identity source for this tab's pane tree. pub root_pane: PaneId, pub layout: TileLayout, - /// Pane state — always present, testable without PTYs. + /// Pane viewport state — always present, testable without PTYs. pub panes: HashMap<PaneId, PaneState>, - /// Best-effort cwd cache per pane. Live runtime cwd wins when available. - pub pane_cwds: HashMap<PaneId, PathBuf>, - /// One-to-one terminal identity for each pane-backed PTY during the transition. - pub terminal_ids: HashMap<PaneId, TerminalId>, - /// Pane runtimes — only present in production (empty in tests). - pub runtimes: HashMap<PaneId, PaneRuntime>, + #[cfg(test)] + pub runtimes: HashMap<PaneId, TerminalRuntime>, pub zoomed: bool, pub events: mpsc::Sender<AppEvent>, pub(crate) render_notify: Arc<Notify>, pub(crate) render_dirty: Arc<AtomicBool>, } -impl Drop for Tab { - fn drop(&mut self) { - let runtimes = std::mem::take(&mut self.runtimes); - for (pane_id, runtime) in runtimes { - runtime.shutdown(pane_id); - } - } -} - impl Tab { pub fn new( number: usize, @@ -61,7 +56,7 @@ impl Tab { events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, - ) -> std::io::Result<Self> { + ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { Self::new_with_runtime( number, initial_cwd, @@ -87,7 +82,7 @@ impl Tab { events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, - ) -> std::io::Result<Self> { + ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { Self::new_with_runtime( number, initial_cwd, @@ -114,10 +109,10 @@ impl Tab { render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, argv: Option<&[String]>, - ) -> std::io::Result<Self> { + ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { let (layout, root_id) = TileLayout::new(); let runtime = if let Some(argv) = argv { - PaneRuntime::spawn_argv_command( + TerminalRuntime::spawn_argv_command( root_id, rows, cols, @@ -130,7 +125,7 @@ impl Tab { render_dirty.clone(), )? } else { - PaneRuntime::spawn( + TerminalRuntime::spawn( root_id, rows, cols, @@ -143,29 +138,33 @@ impl Tab { )? }; + let terminal_id = TerminalId::alloc(); + let terminal = match argv { + Some(argv) => { + TerminalState::new(terminal_id.clone(), initial_cwd).with_launch_argv(argv.to_vec()) + } + None => TerminalState::new(terminal_id.clone(), initial_cwd), + }; let mut panes = HashMap::new(); - panes.insert(root_id, PaneState::new()); - let mut pane_cwds = HashMap::new(); - pane_cwds.insert(root_id, initial_cwd); - let mut terminal_ids = HashMap::new(); - terminal_ids.insert(root_id, TerminalId::alloc()); - let mut runtimes = HashMap::new(); - runtimes.insert(root_id, runtime); + panes.insert(root_id, PaneState::new(terminal_id)); - Ok(Self { - custom_name: None, - number, - root_pane: root_id, - layout, - panes, - pane_cwds, - terminal_ids, - runtimes, - zoomed: false, - events, - render_notify, - render_dirty, - }) + Ok(( + Self { + custom_name: None, + number, + root_pane: root_id, + layout, + panes, + #[cfg(test)] + runtimes: HashMap::new(), + zoomed: false, + events, + render_notify, + render_dirty, + }, + terminal, + runtime, + )) } pub fn display_name(&self) -> String { @@ -190,7 +189,7 @@ impl Tab { cwd: Option<PathBuf>, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, - ) -> std::io::Result<PaneId> { + ) -> std::io::Result<NewPane> { self.split_focused_with_runtime( direction, rows, @@ -212,7 +211,7 @@ impl Tab { extra_env: &[(String, String)], scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, - ) -> std::io::Result<PaneId> { + ) -> std::io::Result<NewPane> { self.split_focused_with_runtime( direction, rows, @@ -233,7 +232,7 @@ impl Tab { argv: &[String], scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, - ) -> std::io::Result<PaneId> { + ) -> std::io::Result<NewPane> { self.split_focused_with_runtime( direction, rows, @@ -254,25 +253,33 @@ impl Tab { scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, command: Option<SplitCommand<'_>>, - ) -> std::io::Result<PaneId> { + ) -> std::io::Result<NewPane> { + let previous_focus = self.layout.focused(); let new_id = self.layout.split_focused(direction); let actual_cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); + let launch_argv = if let Some(SplitCommand::Argv { argv }) = &command { + Some((*argv).to_vec()) + } else { + None + }; let runtime = match command { - Some(SplitCommand::Shell { command, extra_env }) => PaneRuntime::spawn_shell_command( - new_id, - rows, - cols, - actual_cwd.clone(), - command, - extra_env, - scrollback_limit_bytes, - host_terminal_theme, - self.events.clone(), - self.render_notify.clone(), - self.render_dirty.clone(), - )?, - Some(SplitCommand::Argv { argv }) => PaneRuntime::spawn_argv_command( + Some(SplitCommand::Shell { command, extra_env }) => { + TerminalRuntime::spawn_shell_command( + new_id, + rows, + cols, + actual_cwd.clone(), + command, + extra_env, + scrollback_limit_bytes, + host_terminal_theme, + self.events.clone(), + self.render_notify.clone(), + self.render_dirty.clone(), + ) + } + Some(SplitCommand::Argv { argv }) => TerminalRuntime::spawn_argv_command( new_id, rows, cols, @@ -283,8 +290,8 @@ impl Tab { self.events.clone(), self.render_notify.clone(), self.render_dirty.clone(), - )?, - None => PaneRuntime::spawn( + ), + None => TerminalRuntime::spawn( new_id, rows, cols, @@ -294,30 +301,46 @@ impl Tab { self.events.clone(), self.render_notify.clone(), self.render_dirty.clone(), - )?, + ), + }; + let runtime = match runtime { + Ok(runtime) => runtime, + Err(err) => { + self.layout.close_focused(); + self.layout.focus_pane(previous_focus); + return Err(err); + } }; - self.panes.insert(new_id, PaneState::new()); - self.pane_cwds.insert(new_id, actual_cwd); - self.terminal_ids.insert(new_id, TerminalId::alloc()); - self.runtimes.insert(new_id, runtime); + let terminal_id = TerminalId::alloc(); + let terminal = match launch_argv { + Some(argv) => { + TerminalState::new(terminal_id.clone(), actual_cwd).with_launch_argv(argv) + } + None => TerminalState::new(terminal_id.clone(), actual_cwd), + }; + self.panes.insert(new_id, PaneState::new(terminal_id)); self.zoomed = false; - Ok(new_id) + Ok(NewPane { + pane_id: new_id, + terminal, + runtime, + }) } - pub fn close_focused(&mut self) -> Option<(PaneId, Option<PaneRuntime>)> { + pub fn close_focused(&mut self) -> Option<DetachedPane> { let pane_id = self.layout.focused(); self.detach_pane(pane_id) } - pub fn close_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> { + pub fn close_pane(&mut self, pane_id: PaneId) -> Option<DetachedPane> { self.detach_pane(pane_id) } - pub fn remove_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> { + pub fn remove_pane(&mut self, pane_id: PaneId) -> Option<DetachedPane> { self.detach_pane(pane_id) } - fn detach_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> { + fn detach_pane(&mut self, pane_id: PaneId) -> Option<DetachedPane> { if self.layout.pane_count() <= 1 { return None; } @@ -333,15 +356,13 @@ impl Tab { self.layout.focus_pane(prev_focus); } - self.panes.remove(&pane_id); - self.pane_cwds.remove(&pane_id); - self.terminal_ids.remove(&pane_id); - let runtime = self.runtimes.remove(&pane_id); + let pane = self.panes.remove(&pane_id)?; + let terminal_id = pane.attached_terminal_id; self.zoomed = false; if let Some(next_root) = next_root { self.root_pane = next_root; } - Some((pane_id, runtime)) + Some((pane_id, terminal_id)) } fn promoted_root_if_needed(&self, closing: PaneId) -> Option<PaneId> { @@ -351,37 +372,26 @@ impl Tab { self.layout.pane_ids().into_iter().find(|id| *id != closing) } - pub fn focused_runtime(&self) -> Option<&PaneRuntime> { - self.runtimes.get(&self.layout.focused()) - } - pub fn terminal_id(&self, pane_id: PaneId) -> Option<&TerminalId> { - self.terminal_ids.get(&pane_id) - } - - pub fn cwd_for_pane(&self, pane_id: PaneId) -> Option<PathBuf> { - self.runtimes + self.panes .get(&pane_id) - .and_then(|rt| rt.cwd()) - .or_else(|| self.pane_cwds.get(&pane_id).cloned()) + .map(|pane| &pane.attached_terminal_id) } - pub fn root_cwd(&self) -> Option<PathBuf> { - self.cwd_for_pane(self.root_pane) - } - - #[cfg(test)] - #[allow(dead_code)] // retained for focused layout-order assertions in tests - pub fn pane_states(&self) -> Vec<(crate::detect::AgentState, bool)> { - self.layout - .pane_ids() - .iter() - .map(|id| { - self.panes - .get(id) - .map(|p| (p.state, p.seen)) - .unwrap_or((crate::detect::AgentState::Unknown, true)) + pub fn cwd_for_pane( + &self, + pane_id: PaneId, + terminals: &HashMap<TerminalId, TerminalState>, + terminal_runtimes: &HashMap<TerminalId, TerminalRuntime>, + ) -> Option<PathBuf> { + let terminal_id = self.terminal_id(pane_id)?; + terminal_runtimes + .get(terminal_id) + .and_then(|rt| rt.cwd()) + .or_else(|| { + terminals + .get(terminal_id) + .map(|terminal| terminal.cwd.clone()) }) - .collect() } } |
