//! Pure state mutations on AppState. //! These don't need channels, async, or PTY runtime. 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 super::state::{AppState, Mode, ToastKind, ToastNotification}; fn notification_sound_for_state_change( is_active_tab: bool, prev_state: AgentState, new_state: AgentState, ) -> Option { if new_state == prev_state { return None; } match new_state { AgentState::Blocked => Some(crate::sound::Sound::Request), AgentState::Idle if prev_state != AgentState::Idle && !is_active_tab => { Some(crate::sound::Sound::Done) } _ => None, } } fn notification_toast_for_state_change( is_active_tab: bool, prev_state: AgentState, new_state: AgentState, ) -> Option { if is_active_tab || new_state == prev_state { return None; } match new_state { AgentState::Blocked => Some(ToastKind::NeedsAttention), AgentState::Idle if prev_state != AgentState::Idle => Some(ToastKind::Finished), _ => None, } } fn agent_label(agent: Agent) -> &'static str { match agent { crate::detect::Agent::Pi => "pi", crate::detect::Agent::Claude => "claude", crate::detect::Agent::Codex => "codex", crate::detect::Agent::Gemini => "gemini", crate::detect::Agent::Cursor => "cursor", crate::detect::Agent::Cline => "cline", crate::detect::Agent::OpenCode => "opencode", crate::detect::Agent::GithubCopilot => "copilot", crate::detect::Agent::Kimi => "kimi", crate::detect::Agent::Droid => "droid", crate::detect::Agent::Amp => "amp", } } fn notification_context( ws: &crate::workspace::Workspace, ws_idx: usize, pane_id: PaneId, ) -> String { let mut context = format!("{} · {}", ws.display_name(), ws_idx + 1); if ws.tabs.len() > 1 { if let Some(tab_idx) = ws.find_tab_index_for_pane(pane_id) { let tab = &ws.tabs[tab_idx]; context.push_str(&format!(" · {}", tab.display_name())); } } context } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PaneStateUpdate { pub pane_id: PaneId, pub ws_idx: usize, pub previous_agent: Option, pub previous_state: AgentState, pub agent: Option, pub state: AgentState, } // --------------------------------------------------------------------------- // Workspace operations // --------------------------------------------------------------------------- impl AppState { fn pane_is_in_active_tab(&self, ws_idx: usize, pane_id: PaneId) -> bool { let Some(active_ws_idx) = self.active else { return false; }; if active_ws_idx != ws_idx { return false; } self.workspaces[ws_idx] .find_tab_index_for_pane(pane_id) .is_some_and(|tab_idx| tab_idx == self.workspaces[ws_idx].active_tab) } pub fn switch_workspace(&mut self, idx: usize) { if idx < self.workspaces.len() { self.active = Some(idx); self.selected = idx; if let Some(ws) = self.workspaces.get_mut(idx) { ws.switch_tab(ws.active_tab); } } } pub fn switch_tab(&mut self, idx: usize) { if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { ws.switch_tab(idx); } } pub fn next_workspace(&mut self) { if !self.workspaces.is_empty() { let current = self.active.unwrap_or(self.selected); let next = (current + 1) % self.workspaces.len(); self.switch_workspace(next); } } pub fn previous_workspace(&mut self) { if !self.workspaces.is_empty() { let current = self.active.unwrap_or(self.selected); let prev = if current == 0 { self.workspaces.len() - 1 } else { current - 1 }; self.switch_workspace(prev); } } pub fn move_workspace(&mut self, source_idx: usize, insert_idx: usize) { if source_idx >= self.workspaces.len() || insert_idx > self.workspaces.len() { return; } let active_id = self.active.map(|idx| self.workspaces[idx].id.clone()); let selected_id = self .workspaces .get(self.selected) .map(|workspace| workspace.id.clone()); let workspace = self.workspaces.remove(source_idx); let target_idx = if source_idx < insert_idx { insert_idx.saturating_sub(1) } else { insert_idx } .min(self.workspaces.len()); self.workspaces.insert(target_idx, workspace); self.active = active_id.and_then(|id| self.workspaces.iter().position(|ws| ws.id == id)); self.selected = selected_id .and_then(|id| self.workspaces.iter().position(|ws| ws.id == id)) .unwrap_or(0); } pub fn next_tab(&mut self) { if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { if !ws.tabs.is_empty() { let next = (ws.active_tab + 1) % ws.tabs.len(); ws.switch_tab(next); } } } pub fn previous_tab(&mut self) { if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { if !ws.tabs.is_empty() { let prev = if ws.active_tab == 0 { ws.tabs.len() - 1 } else { ws.active_tab - 1 }; ws.switch_tab(prev); } } } pub fn close_selected_workspace(&mut self) { if self.workspaces.is_empty() { return; } let name = self.workspaces[self.selected].display_name(); info!(workspace = %name, "workspace closed"); self.workspaces.remove(self.selected); if self.workspaces.is_empty() { self.active = None; self.selected = 0; } else { if self.selected >= self.workspaces.len() { self.selected = self.workspaces.len() - 1; } self.active = Some(self.selected); } } } // --------------------------------------------------------------------------- // Pane operations // --------------------------------------------------------------------------- impl AppState { pub fn navigate_pane(&mut self, direction: NavDirection) { let panes = &self.view.pane_infos; if let Some(focused) = panes.iter().find(|p| p.is_focused) { if let Some(target) = find_in_direction(focused, direction, panes) { if let Some(tab) = self .active .and_then(|i| self.workspaces.get_mut(i)) .and_then(|ws| ws.active_tab_mut()) { tab.layout.focus_pane(target); } } } } pub fn resize_pane(&mut self, direction: NavDirection) { if let Some(first) = self.view.pane_infos.first() { let area = self .view .pane_infos .iter() .fold(first.rect, |acc, p| acc.union(p.rect)); if let Some(tab) = self .active .and_then(|i| self.workspaces.get_mut(i)) .and_then(|ws| ws.active_tab_mut()) { tab.layout.resize_focused(direction, 0.05, area); } } } pub fn cycle_pane(&mut self, reverse: bool) { if let Some(tab) = self .active .and_then(|i| self.workspaces.get_mut(i)) .and_then(|ws| ws.active_tab_mut()) { if reverse { tab.layout.focus_prev(); } else { tab.layout.focus_next(); } } } pub fn toggle_fullscreen(&mut self) { if let Some(tab) = self .active .and_then(|i| self.workspaces.get_mut(i)) .and_then(|ws| ws.active_tab_mut()) { if tab.layout.pane_count() > 1 { tab.zoomed = !tab.zoomed; } } } pub fn close_pane(&mut self) { let should_close_workspace = self .active .and_then(|i| self.workspaces.get_mut(i)) .is_some_and(|ws| ws.close_focused()); if should_close_workspace { self.close_selected_workspace(); } } pub fn close_tab(&mut self) { let should_close_workspace = self .active .and_then(|i| self.workspaces.get(i)) .is_some_and(|ws| ws.tabs.len() <= 1); if should_close_workspace { self.close_selected_workspace(); return; } if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { ws.close_active_tab(); } } } // --------------------------------------------------------------------------- // Selection // --------------------------------------------------------------------------- impl AppState { pub fn clear_selection(&mut self) { self.selection = None; } pub fn copy_selection(&mut self) { 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 => return, }; let rt = match ws.runtime(sel.pane_id) { Some(r) => r, None => return, }; if let Some(text) = rt.extract_selection(sel) { if !text.is_empty() { crate::selection::write_osc52(&text); info!(len = text.len(), "copied selection to clipboard"); } } } } // --------------------------------------------------------------------------- // Event handling // --------------------------------------------------------------------------- impl AppState { pub fn handle_app_event(&mut self, event: AppEvent) -> Vec { match event { AppEvent::PaneDied { pane_id } => { self.handle_pane_died(pane_id); Vec::new() } AppEvent::UpdateReady { version } => { self.update_available = Some(version.clone()); self.latest_release_notes_available = true; self.update_dismissed = true; self.toast = Some(ToastNotification { kind: ToastKind::UpdateInstalled, title: format!("updated to v{version}"), context: "restart to use it".to_string(), }); Vec::new() } AppEvent::StateChanged { pane_id, agent, state, } => self .update_pane_state(pane_id, |pane| pane.set_detected_state(agent, state)) .into_iter() .collect(), AppEvent::HookStateReported { pane_id, source, agent, state, message, } => self .update_pane_state(pane_id, |pane| { pane.set_hook_authority(source, agent, state, message) }) .into_iter() .collect(), AppEvent::HookAuthorityCleared { pane_id, source } => self .update_pane_state(pane_id, |pane| pane.clear_hook_authority(source.as_deref())) .into_iter() .collect(), AppEvent::HookAgentReleased { pane_id, source, agent, } => self .update_pane_state(pane_id, |pane| pane.release_agent(&source, agent)) .into_iter() .collect(), } } fn update_pane_state(&mut self, pane_id: PaneId, update: F) -> Option where F: FnOnce(&mut crate::pane::PaneState) -> Option, { let ws_idx = self .workspaces .iter() .position(|ws| ws.pane_state(pane_id).is_some())?; let change = { let pane = self.workspaces[ws_idx] .tabs .iter_mut() .find_map(|tab| tab.panes.get_mut(&pane_id))?; update(pane)? }; self.apply_pane_state_change(ws_idx, pane_id, change); Some(PaneStateUpdate { pane_id, ws_idx, previous_agent: change.previous_agent, previous_state: change.previous_state, agent: change.agent, state: change.state, }) } fn apply_pane_state_change( &mut self, ws_idx: usize, pane_id: PaneId, change: EffectiveStateChange, ) { let is_active_tab = self.pane_is_in_active_tab(ws_idx, pane_id); let Some(pane) = self.workspaces[ws_idx] .tabs .iter_mut() .find_map(|tab| tab.panes.get_mut(&pane_id)) else { return; }; if change.state == AgentState::Idle && change.previous_state != AgentState::Idle && !is_active_tab { pane.seen = false; } if self.sound.allows(change.agent) { if let Some(sound) = notification_sound_for_state_change( is_active_tab, change.previous_state, change.state, ) { crate::sound::play(sound, &self.sound); } } if self.toast_config.enabled { if let (Some(agent), Some(kind)) = ( change.agent, notification_toast_for_state_change( is_active_tab, change.previous_state, change.state, ), ) { let event_text = match kind { ToastKind::NeedsAttention => "needs attention", ToastKind::Finished => "finished", ToastKind::UpdateInstalled => "updated", }; let context = notification_context(&self.workspaces[ws_idx], ws_idx, pane_id); self.toast = Some(ToastNotification { kind, title: format!("{} {}", agent_label(agent), event_text), context, }); } } } fn handle_pane_died(&mut self, pane_id: PaneId) { let ws_idx = self .workspaces .iter() .position(|ws| ws.find_tab_index_for_pane(pane_id).is_some()); let Some(ws_idx) = ws_idx else { warn!(pane = pane_id.raw(), "PaneDied for unknown pane"); return; }; let should_close_workspace = { let ws = &mut self.workspaces[ws_idx]; ws.remove_pane(pane_id) }; if should_close_workspace { self.workspaces.remove(ws_idx); if self.workspaces.is_empty() { self.active = None; self.selected = 0; if self.mode == Mode::Terminal { self.mode = Mode::Navigate; } } else { if let Some(active) = self.active { if active >= self.workspaces.len() { self.active = Some(self.workspaces.len() - 1); } } if self.selected >= self.workspaces.len() { self.selected = self.workspaces.len() - 1; } } } } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::detect::{Agent, AgentState}; use crate::workspace::Workspace; use ratatui::layout::Direction; fn app_with_workspaces(names: &[&str]) -> AppState { let mut state = AppState::test_new(); for name in names { let ws = Workspace::test_new(name); state.workspaces.push(ws); } if !state.workspaces.is_empty() { state.active = Some(0); state.mode = Mode::Terminal; } state } #[test] fn switch_workspace_updates_active_and_selected() { let mut state = app_with_workspaces(&["a", "b", "c"]); state.switch_workspace(2); assert_eq!(state.active, Some(2)); assert_eq!(state.selected, 2); } #[test] fn switch_workspace_marks_panes_seen() { let mut state = app_with_workspaces(&["a", "b"]); // Mark a pane in workspace 1 as unseen let id = *state.workspaces[1].panes.keys().next().unwrap(); state.workspaces[1].panes.get_mut(&id).unwrap().seen = false; state.switch_workspace(1); assert!(state.workspaces[1].panes.get(&id).unwrap().seen); } #[test] fn switch_workspace_out_of_bounds_is_noop() { let mut state = app_with_workspaces(&["a"]); state.switch_workspace(5); assert_eq!(state.active, Some(0)); } #[test] fn move_workspace_reorders_without_changing_logical_selection() { let mut state = app_with_workspaces(&["a", "b", "c"]); let active_id = state.workspaces[1].id.clone(); let selected_id = state.workspaces[2].id.clone(); state.active = Some(1); state.selected = 2; state.move_workspace(1, 0); let names: Vec<_> = state .workspaces .iter() .map(|ws| ws.display_name()) .collect(); assert_eq!(names, vec!["b", "a", "c"]); assert_eq!(state.active, Some(0)); assert_eq!(state.selected, 2); assert_eq!(state.workspaces[state.active.unwrap()].id, active_id); assert_eq!(state.workspaces[state.selected].id, selected_id); } #[test] fn move_workspace_accepts_insert_at_end() { let mut state = app_with_workspaces(&["a", "b", "c"]); state.move_workspace(0, state.workspaces.len()); let names: Vec<_> = state .workspaces .iter() .map(|ws| ws.display_name()) .collect(); assert_eq!(names, vec!["b", "c", "a"]); } #[test] fn close_workspace_adjusts_indices() { let mut state = app_with_workspaces(&["a", "b", "c"]); state.selected = 1; state.active = Some(1); state.close_selected_workspace(); assert_eq!(state.workspaces.len(), 2); assert_eq!(state.selected, 1); assert_eq!(state.active, Some(1)); assert_eq!(state.workspaces[1].custom_name.as_deref(), Some("c")); } #[test] fn close_last_workspace_clears_active() { let mut state = app_with_workspaces(&["only"]); state.selected = 0; state.close_selected_workspace(); assert!(state.workspaces.is_empty()); assert_eq!(state.active, None); assert_eq!(state.selected, 0); } #[test] fn close_workspace_at_end_adjusts_selected() { let mut state = app_with_workspaces(&["a", "b"]); state.selected = 1; state.active = Some(1); state.close_selected_workspace(); assert_eq!(state.workspaces.len(), 1); assert_eq!(state.selected, 0); assert_eq!(state.active, Some(0)); } #[test] fn pane_died_last_pane_removes_workspace() { let mut state = app_with_workspaces(&["a", "b"]); let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); state.handle_pane_died(pane_id); assert_eq!(state.workspaces.len(), 1); assert_eq!(state.workspaces[0].custom_name.as_deref(), Some("b")); } #[test] fn pane_died_last_workspace_enters_navigate() { let mut state = app_with_workspaces(&["only"]); state.mode = Mode::Terminal; let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); state.handle_pane_died(pane_id); assert!(state.workspaces.is_empty()); assert_eq!(state.mode, Mode::Navigate); } #[test] fn pane_died_multi_pane_keeps_workspace() { let mut state = app_with_workspaces(&["test"]); let second_id = state.workspaces[0].test_split(Direction::Horizontal); state.handle_pane_died(second_id); assert_eq!(state.workspaces.len(), 1); assert_eq!(state.workspaces[0].panes.len(), 1); } #[test] fn pane_died_unknown_pane_is_noop() { let mut state = app_with_workspaces(&["test"]); let fake_id = PaneId::from_raw(9999); state.handle_pane_died(fake_id); assert_eq!(state.workspaces.len(), 1); } #[test] fn state_changed_updates_pane() { let mut state = app_with_workspaces(&["test"]); let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); state.handle_app_event(AppEvent::StateChanged { pane_id, agent: Some(Agent::Pi), 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)); } #[test] fn state_changed_idle_in_background_marks_unseen() { let mut state = app_with_workspaces(&["active", "background"]); state.active = Some(0); let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); // First set it to Working state.workspaces[1] .panes .get_mut(&bg_pane_id) .unwrap() .state = AgentState::Working; // Now transition to Idle while in background state.handle_app_event(AppEvent::StateChanged { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Idle, }); let pane = state.workspaces[1].panes.get(&bg_pane_id).unwrap(); assert!(!pane.seen); } #[test] fn waiting_sound_plays_even_in_active_workspace() { assert_eq!( notification_sound_for_state_change(true, AgentState::Working, AgentState::Blocked), Some(crate::sound::Sound::Request) ); } #[test] fn done_sound_only_plays_in_background() { assert_eq!( notification_sound_for_state_change(false, AgentState::Working, AgentState::Idle), Some(crate::sound::Sound::Done) ); assert_eq!( notification_sound_for_state_change(true, AgentState::Working, AgentState::Idle), None ); } #[test] fn background_waiting_sets_attention_toast() { let mut state = app_with_workspaces(&["active", "background"]); state.active = Some(0); state.toast_config.enabled = true; let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); state.handle_app_event(AppEvent::StateChanged { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Blocked, }); let toast = state.toast.as_ref().unwrap(); assert_eq!(toast.kind, ToastKind::NeedsAttention); assert_eq!(toast.title, "pi needs attention"); assert_eq!(toast.context, "background · 2"); } #[test] fn background_idle_sets_finished_toast() { let mut state = app_with_workspaces(&["active", "background"]); state.active = Some(0); state.toast_config.enabled = true; let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); state.workspaces[1] .panes .get_mut(&bg_pane_id) .unwrap() .state = AgentState::Working; state.handle_app_event(AppEvent::StateChanged { pane_id: bg_pane_id, agent: Some(Agent::Droid), state: AgentState::Idle, }); let toast = state.toast.as_ref().unwrap(); assert_eq!(toast.kind, ToastKind::Finished); assert_eq!(toast.title, "droid finished"); assert_eq!(toast.context, "background · 2"); } #[test] fn background_toast_includes_tab_name_when_workspace_has_multiple_tabs() { let mut state = app_with_workspaces(&["active", "background"]); state.active = Some(0); state.toast_config.enabled = true; state.workspaces[1].tabs[0].set_custom_name("main".into()); let second_tab = state.workspaces[1].test_add_tab(Some("logs")); let bg_pane_id = state.workspaces[1].tabs[second_tab].root_pane; state.handle_app_event(AppEvent::StateChanged { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Blocked, }); let toast = state.toast.as_ref().unwrap(); assert_eq!(toast.kind, ToastKind::NeedsAttention); assert_eq!(toast.title, "pi needs attention"); assert_eq!(toast.context, "background · 2 · logs"); } #[test] fn background_tab_in_active_workspace_still_sets_toast() { let mut state = app_with_workspaces(&["active"]); state.active = Some(0); state.toast_config.enabled = true; state.workspaces[0].tabs[0].set_custom_name("main".into()); let second_tab = state.workspaces[0].test_add_tab(Some("logs")); let bg_pane_id = state.workspaces[0].tabs[second_tab].root_pane; state.handle_app_event(AppEvent::StateChanged { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Blocked, }); let toast = state.toast.as_ref().unwrap(); assert_eq!(toast.kind, ToastKind::NeedsAttention); assert_eq!(toast.title, "pi needs attention"); assert_eq!(toast.context, "active · 1 · logs"); } #[test] fn active_workspace_active_tab_does_not_set_toast() { let mut state = app_with_workspaces(&["active"]); state.active = Some(0); state.toast_config.enabled = true; let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); state.handle_app_event(AppEvent::StateChanged { pane_id, agent: Some(Agent::Pi), state: AgentState::Blocked, }); assert!(state.toast.is_none()); } #[test] fn toggle_fullscreen_works() { let mut state = app_with_workspaces(&["test"]); state.workspaces[0].test_split(Direction::Horizontal); assert!(!state.workspaces[0].zoomed); state.toggle_fullscreen(); assert!(state.workspaces[0].zoomed); state.toggle_fullscreen(); assert!(!state.workspaces[0].zoomed); } #[test] fn toggle_fullscreen_single_pane_noop() { let mut state = app_with_workspaces(&["test"]); state.toggle_fullscreen(); assert!(!state.workspaces[0].zoomed); } #[test] fn close_pane_removes_from_workspace() { let mut state = app_with_workspaces(&["test"]); state.workspaces[0].test_split(Direction::Horizontal); assert_eq!(state.workspaces[0].panes.len(), 2); state.close_pane(); assert_eq!(state.workspaces[0].panes.len(), 1); } }