//! Pure state mutations on AppState. //! These don't need channels, async, or PTY runtime. use std::time::Instant; use tracing::{info, warn}; use crate::detect::{Agent, AgentState}; use crate::events::AppEvent; use crate::layout::PaneId; #[cfg(test)] use crate::layout::{find_in_direction, NavDirection}; use crate::selection::Selection; use crate::terminal::{EffectiveStateChange, TerminalStateMutation}; use crate::workspace::WorkspaceGitStatus; use super::api_helpers::pane_agent_status; use super::state::{ navigator_display_index_of_row, navigator_display_lines, navigator_first_row_at_or_after, text_matches_query, AgentNotificationDelivery, AppState, Mode, NavigatorRow, NavigatorStateFilter, NavigatorTarget, PaneFocusTarget, PendingAgentNotification, ToastKind, ToastNotification, ToastTarget, ViewLayout, }; fn is_background_completion_transition(prev_state: AgentState, new_state: AgentState) -> bool { matches!(new_state, AgentState::Idle) && matches!(prev_state, AgentState::Working | AgentState::Blocked) } fn is_completion_transition(change: &EffectiveStateChange) -> bool { is_completion_transition_parts( change.previous_state, change.state, change.previous_agent_label.as_deref(), change.agent_label.as_deref(), ) } fn public_tab_id_for_index(ws: &crate::workspace::Workspace, tab_idx: usize) -> Option { let tab_number = ws.public_tab_number(tab_idx)?; Some(crate::workspace::public_tab_id_for_number( &ws.id, tab_number, )) } pub fn is_completion_transition_parts( previous_state: AgentState, state: AgentState, previous_agent_label: Option<&str>, agent_label: Option<&str>, ) -> bool { is_background_completion_transition(previous_state, state) || (previous_state == AgentState::Unknown && state == AgentState::Idle && previous_agent_label.is_some() && previous_agent_label == agent_label) } pub fn active_tab_suppresses_notifications( is_active_tab: bool, outer_terminal_focus: Option, ) -> bool { is_active_tab && outer_terminal_focus != Some(false) } #[cfg(test)] pub fn notification_sound_for_state_change( suppress_active_tab_notifications: 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 is_background_completion_transition(prev_state, new_state) && !suppress_active_tab_notifications => { Some(crate::sound::Sound::Done) } _ => None, } } pub fn notification_sound_for_state_change_with_agent_labels( suppress_active_tab_notifications: bool, prev_state: AgentState, new_state: AgentState, previous_agent_label: Option<&str>, agent_label: Option<&str>, ) -> Option { if new_state == prev_state { return None; } match new_state { AgentState::Blocked => Some(crate::sound::Sound::Request), AgentState::Idle if is_completion_transition_parts( prev_state, new_state, previous_agent_label, agent_label, ) && !suppress_active_tab_notifications => { Some(crate::sound::Sound::Done) } _ => None, } } fn notification_sound_for_effective_state_change( suppress_active_tab_notifications: bool, change: &EffectiveStateChange, ) -> Option { if change.state == change.previous_state { return None; } match change.state { AgentState::Blocked => Some(crate::sound::Sound::Request), AgentState::Idle if is_completion_transition(change) && !suppress_active_tab_notifications => { Some(crate::sound::Sound::Done) } _ => None, } } pub fn notification_toast_for_state_change_with_agent_labels( suppress_active_tab_notifications: bool, prev_state: AgentState, new_state: AgentState, previous_agent_label: Option<&str>, agent_label: Option<&str>, ) -> Option { if suppress_active_tab_notifications || new_state == prev_state { return None; } match new_state { AgentState::Blocked => Some(ToastKind::NeedsAttention), AgentState::Idle if is_completion_transition_parts( prev_state, new_state, previous_agent_label, agent_label, ) => { Some(ToastKind::Finished) } _ => None, } } fn notification_toast_for_effective_state_change( suppress_active_tab_notifications: bool, change: &EffectiveStateChange, ) -> Option { if suppress_active_tab_notifications || change.state == change.previous_state { return None; } match change.state { AgentState::Blocked => Some(ToastKind::NeedsAttention), AgentState::Idle if is_completion_transition(change) => Some(ToastKind::Finished), _ => None, } } pub fn notification_toast_for_pane_state_update( suppress_active_tab_notifications: bool, update: &PaneStateUpdate, ) -> Option { if suppress_active_tab_notifications || update.state == update.previous_state { return None; } notification_toast_for_state_change_with_agent_labels( suppress_active_tab_notifications, update.previous_state, update.state, update.previous_agent_label.as_deref(), update.agent_label.as_deref(), ) } fn toast_agent_label(agent_label: &str) -> &str { agent_label } fn toast_event_text(kind: ToastKind) -> &'static str { match kind { ToastKind::NeedsAttention => "needs attention", ToastKind::Finished => "finished", ToastKind::UpdateInstalled => "updated", } } fn sound_for_toast_kind( kind: ToastKind, suppress_active_tab_notifications: bool, ) -> Option { match kind { ToastKind::NeedsAttention => Some(crate::sound::Sound::Request), ToastKind::Finished if !suppress_active_tab_notifications => { Some(crate::sound::Sound::Done) } ToastKind::Finished | ToastKind::UpdateInstalled => None, } } pub fn notification_context( ws: &crate::workspace::Workspace, workspace_label: &str, ws_idx: usize, pane_id: PaneId, ) -> String { let mut context = format!("{} · {}", workspace_label, ws_idx + 1); if ws.tabs.len() > 1 { if let Some(tab_idx) = ws.find_tab_index_for_pane(pane_id) { if let Some(label) = ws.tab_display_name(tab_idx) { context.push_str(&format!(" · {label}")); } } } context } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PaneStateUpdate { pub pane_id: PaneId, pub ws_idx: usize, pub previous_agent_label: Option, pub previous_known_agent: Option, pub previous_state: AgentState, pub previous_seen: bool, pub previous_presentation: crate::terminal::EffectivePresentation, pub agent_label: Option, pub known_agent: Option, pub state: AgentState, pub seen: bool, pub presentation: crate::terminal::EffectivePresentation, pub agent_name_changed: bool, pub agent_released: bool, pub agent_release_status: Option, } // --------------------------------------------------------------------------- // Navigator operations // --------------------------------------------------------------------------- impl AppState { pub(crate) fn current_pane_focus_target(&self) -> Option { let ws_idx = self.active?; let ws = self.workspaces.get(ws_idx)?; let pane_id = ws.focused_pane_id()?; Some(PaneFocusTarget { workspace_id: ws.id.clone(), pane_id, }) } pub(crate) fn pane_focus_target_indices( &self, target: &PaneFocusTarget, ) -> Option<(usize, usize)> { let ws_idx = self .workspaces .iter() .position(|ws| ws.id == target.workspace_id)?; let tab_idx = self.workspaces[ws_idx].find_tab_index_for_pane(target.pane_id)?; Some((ws_idx, tab_idx)) } pub(crate) fn record_pane_focus_change( &mut self, previous: Option, ws_idx: usize, pane_id: PaneId, ) { let Some(ws) = self.workspaces.get(ws_idx) else { return; }; let target = PaneFocusTarget { workspace_id: ws.id.clone(), pane_id, }; if previous.as_ref() != Some(&target) { self.previous_pane_focus = previous; } } fn record_pane_focus_after_navigation(&mut self, previous: Option) { let current = self.current_pane_focus_target(); if previous != current { self.previous_pane_focus = previous; } } fn sync_selection_after_focus_navigation(&mut self) { if self.copy_mode.is_some() { self.sync_copy_mode_with_focus(); } else { self.clear_selection(); } } pub(crate) fn focus_pane_in_workspace(&mut self, ws_idx: usize, pane_id: PaneId) -> bool { let Some(ws) = self.workspaces.get(ws_idx) else { return false; }; let Some(tab_idx) = ws.find_tab_index_for_pane(pane_id) else { return false; }; let previous = self.current_pane_focus_target(); let target = PaneFocusTarget { workspace_id: ws.id.clone(), pane_id, }; if previous.as_ref() == Some(&target) { return false; } if self.copy_mode.is_some() { self.clear_copy_mode_selection(); } self.switch_workspace_tab(ws_idx, tab_idx); if let Some(tab) = self .workspaces .get_mut(ws_idx) .and_then(|ws| ws.tabs.get_mut(tab_idx)) { tab.layout.focus_pane(pane_id); self.previous_pane_focus = previous; self.mark_session_dirty(); self.sync_copy_mode_with_focus(); return true; } false } #[cfg(test)] pub(crate) fn open_navigator(&mut self) { let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); self.open_navigator_from(&terminal_runtimes); } pub(crate) fn open_navigator_from( &mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, ) { self.navigator.query.clear(); self.navigator.search_focused = false; self.navigator.state_filter = None; self.navigator.scroll = 0; self.navigator.expanded_workspaces.clear(); for ws in &self.workspaces { self.navigator.expanded_workspaces.insert(ws.id.clone()); } self.mode = Mode::Navigator; self.navigator.selected = self .current_navigator_row_index_from(terminal_runtimes) .unwrap_or(0); self.ensure_navigator_selection_visible_from(terminal_runtimes); } #[cfg(test)] pub(crate) fn navigator_rows(&self) -> Vec { let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); self.navigator_rows_from(&terminal_runtimes) } pub(crate) fn navigator_rows_from( &self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, ) -> Vec { let query = self.navigator.query.trim().to_lowercase(); let query_kind = navigator_query_kind(&query, self.navigator.state_filter); let mut rows = Vec::new(); for (ws_idx, ws) in self.workspaces.iter().enumerate() { let workspace_label = ws.display_name_from(&self.terminals, terminal_runtimes); let activity = workspace_activity_summary(ws, &self.terminals); let workspace_search_text = format!("{workspace_label} {activity}").to_lowercase(); let workspace_matches = match query_kind { NavigatorQueryKind::Empty => true, NavigatorQueryKind::State(filter) => { let (state, seen) = ws.aggregate_state(&self.terminals); navigator_state_filter_matches(filter, state, seen) } NavigatorQueryKind::Text => navigator_matches(&query, &workspace_search_text), }; let child_rows = self.navigator_child_rows(ws_idx, query_kind, &query, workspace_matches); if !workspace_matches && child_rows.is_empty() { continue; } let expanded = !matches!(query_kind, NavigatorQueryKind::Empty) || self.navigator.expanded_workspaces.contains(&ws.id); let (state, seen) = ws.aggregate_state(&self.terminals); let pane_count = ws.tabs.iter().map(|tab| tab.panes.len()).sum::(); rows.push(NavigatorRow { target: NavigatorTarget::Workspace { ws_idx }, depth: 0, label: format!("{workspace_label} ({pane_count})"), meta: activity, status: state, seen, is_current: self.active == Some(ws_idx), is_workspace: true, is_tab: false, expanded, search_text: workspace_search_text, matched: workspace_matches, }); if expanded { rows.extend(child_rows); } } rows } fn navigator_child_rows( &self, ws_idx: usize, query_kind: NavigatorQueryKind, query: &str, workspace_matches: bool, ) -> Vec { let Some(ws) = self.workspaces.get(ws_idx) else { return Vec::new(); }; let multi_tab = ws.tabs.len() > 1; let mut rows = Vec::new(); for tab_idx in 0..ws.tabs.len() { let mut tab_row = self.navigator_tab_row(ws_idx, tab_idx); let tab_matches = match query_kind { NavigatorQueryKind::Empty => true, NavigatorQueryKind::State(filter) => { navigator_state_filter_matches(filter, tab_row.status, tab_row.seen) } NavigatorQueryKind::Text => navigator_matches( query, if multi_tab { &tab_row.search_text } else { &tab_row.label }, ), }; tab_row.matched = tab_matches; let show_tab_row = multi_tab || (matches!(query_kind, NavigatorQueryKind::Text) && tab_matches); let mut pane_rows = self.navigator_pane_rows_for_tab(ws_idx, tab_idx, show_tab_row); let filtered_panes = match query_kind { NavigatorQueryKind::Empty => pane_rows, NavigatorQueryKind::State(filter) => pane_rows .into_iter() .filter(|row| navigator_state_filter_matches(filter, row.status, row.seen)) .collect::>(), // A matching workspace or tab shows its whole subtree; panes // keep their own match flag so context rows can be dimmed. NavigatorQueryKind::Text if workspace_matches || tab_matches => { for row in pane_rows.iter_mut() { row.matched = navigator_matches(query, &row.search_text); } pane_rows } NavigatorQueryKind::Text => pane_rows .into_iter() .filter(|row| navigator_matches(query, &row.search_text)) .collect::>(), }; if show_tab_row && (tab_matches || !filtered_panes.is_empty()) { rows.push(tab_row); } rows.extend(filtered_panes); } rows } fn navigator_tab_row(&self, ws_idx: usize, tab_idx: usize) -> NavigatorRow { let ws = &self.workspaces[ws_idx]; let tab = &ws.tabs[tab_idx]; let label = ws .tab_display_name(tab_idx) .unwrap_or_else(|| (tab_idx + 1).to_string()); let (status, seen) = tab_aggregate_state(tab, &self.terminals); let activity = tab_activity_summary(tab, &self.terminals); let pane_count = tab.panes.len(); let meta = if activity.is_empty() { format!("{pane_count} panes") } else { format!("{pane_count} panes · {activity}") }; let search_text = format!("{label} {meta}").to_lowercase(); NavigatorRow { target: NavigatorTarget::Tab { ws_idx, tab_idx }, depth: 1, label, meta, status, seen, is_current: false, is_workspace: false, is_tab: true, expanded: true, search_text, matched: true, } } fn navigator_pane_rows_for_tab( &self, ws_idx: usize, tab_idx: usize, show_tab_row: bool, ) -> Vec { let Some(ws) = self.workspaces.get(ws_idx) else { return Vec::new(); }; let Some(tab) = ws.tabs.get(tab_idx) else { return Vec::new(); }; let mut rows = Vec::new(); for pane_id in tab.layout.pane_ids() { let Some(pane) = tab.panes.get(&pane_id) else { continue; }; let terminal = self.terminals.get(&pane.attached_terminal_id); let pane_number = ws.public_pane_number(pane_id).unwrap_or(0); let label = terminal .and_then(|terminal| terminal.effective_title()) .or_else(|| { terminal .and_then(|terminal| terminal.manual_label.as_deref().map(str::to_string)) }) .or_else(|| { terminal.and_then(|terminal| terminal.agent_name.as_deref().map(str::to_string)) }) .or_else(|| { terminal .and_then(|terminal| terminal.effective_agent_label().map(str::to_string)) }) .or_else(|| { launch_label(terminal.and_then(|terminal| terminal.launch_argv.as_ref())) }) .unwrap_or_else(|| format!("pane {pane_number}")); let display_agent = terminal.and_then(|terminal| terminal.effective_display_agent()); let agent_label = display_agent.as_deref().or_else(|| { terminal .and_then(|terminal| terminal.agent_name.as_deref()) .or_else(|| terminal.and_then(|terminal| terminal.effective_agent_label())) }); let state = terminal .map(|terminal| terminal.state) .unwrap_or(AgentState::Unknown); let status_label = terminal .map(|terminal| terminal.effective_presentation().state_labels) .and_then(|labels| labels.get(state_label_text(state, pane.seen)).cloned()); let status = status_label .or_else(|| agent_label.map(|_| state_label_text(state, pane.seen).to_string())); let meta = match (agent_label, status.as_deref()) { (Some(agent_label), Some(status)) => format!("{agent_label} · {status}"), (Some(agent_label), None) => agent_label.to_string(), (None, _) => "shell".to_string(), }; let is_current = self.is_active_pane(ws_idx, tab_idx, pane_id); let search_text = format!("{label} {meta}").to_lowercase(); rows.push(NavigatorRow { target: NavigatorTarget::Pane { ws_idx, tab_idx, pane_id, }, depth: if show_tab_row { 2 } else { 1 }, label, meta, status: state, seen: pane.seen, is_current, is_workspace: false, is_tab: false, expanded: false, search_text, matched: true, }); } rows } fn current_navigator_row_index_from( &self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, ) -> Option { let rows = self.navigator_rows_from(terminal_runtimes); rows.iter() .position(|row| matches!(row.target, NavigatorTarget::Pane { .. }) && row.is_current) .or_else(|| rows.iter().position(|row| row.is_current)) } pub(crate) fn ensure_navigator_selection_visible_from( &mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, ) { let body = self.navigator_body_rect(); let viewport = body.height as usize; if viewport == 0 { self.navigator.scroll = 0; return; } let lines = navigator_display_lines(&self.navigator_rows_from(terminal_runtimes)); let max_scroll = lines.len().saturating_sub(viewport); let selected_line = navigator_display_index_of_row(&lines, self.navigator.selected).unwrap_or(0); if selected_line < self.navigator.scroll { self.navigator.scroll = selected_line; } else if selected_line >= self.navigator.scroll.saturating_add(viewport) { self.navigator.scroll = selected_line.saturating_add(1).saturating_sub(viewport); } self.navigator.scroll = self.navigator.scroll.min(max_scroll); } pub(crate) fn navigator_max_scroll_from( &self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, viewport: usize, ) -> usize { if viewport == 0 { return 0; } navigator_display_lines(&self.navigator_rows_from(terminal_runtimes)) .len() .saturating_sub(viewport) } /// After a mouse-wheel scroll, snap the selection to the first selectable /// row at or below the top of the viewport. pub(crate) fn align_navigator_selection_to_scroll_from( &mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, ) { let lines = navigator_display_lines(&self.navigator_rows_from(terminal_runtimes)); if let Some(row_idx) = navigator_first_row_at_or_after(&lines, self.navigator.scroll) { self.navigator.selected = row_idx; } self.clamp_navigator_selection_from(terminal_runtimes); } pub(crate) fn move_navigator_selection_from( &mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, delta: isize, ) { let count = self.navigator_rows_from(terminal_runtimes).len(); if count == 0 { self.navigator.selected = 0; self.navigator.scroll = 0; return; } let current = self.navigator.selected.min(count - 1) as isize; self.navigator.selected = (current + delta).clamp(0, count as isize - 1) as usize; self.ensure_navigator_selection_visible_from(terminal_runtimes); } /// Move the selection by a distance measured in display lines (used for /// half-page jumps), landing on the nearest selectable row in the move /// direction. pub(crate) fn move_navigator_selection_by_lines_from( &mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, delta_lines: isize, ) { let rows = self.navigator_rows_from(terminal_runtimes); if rows.is_empty() { self.navigator.selected = 0; self.navigator.scroll = 0; return; } let lines = navigator_display_lines(&rows); let current_line = navigator_display_index_of_row(&lines, self.navigator.selected.min(rows.len() - 1)) .unwrap_or(0); let target_line = (current_line as isize + delta_lines).clamp(0, lines.len() as isize - 1) as usize; let row_idx = if delta_lines >= 0 { navigator_first_row_at_or_after(&lines, target_line) } else { lines[..=target_line] .iter() .rev() .find_map(|line| match line { super::state::NavigatorDisplayLine::Row(idx) => Some(*idx), super::state::NavigatorDisplayLine::Spacer => None, }) }; if let Some(row_idx) = row_idx { self.navigator.selected = row_idx; } self.ensure_navigator_selection_visible_from(terminal_runtimes); } /// After the query or state filter changes, select the first row that /// itself matched the filter, so enter immediately accepts the best match. /// State filters prefer pane matches over aggregate workspace/tab matches. pub(crate) fn select_first_navigator_match_from( &mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, ) { let query = self.navigator.query.trim().to_lowercase(); let query_kind = navigator_query_kind(&query, self.navigator.state_filter); if !matches!(query_kind, NavigatorQueryKind::Empty) { let rows = self.navigator_rows_from(terminal_runtimes); let idx = if matches!(query_kind, NavigatorQueryKind::State(_)) { rows.iter() .position(|row| { row.matched && matches!(row.target, NavigatorTarget::Pane { .. }) }) .or_else(|| rows.iter().position(|row| row.matched)) } else { rows.iter().position(|row| row.matched) }; if let Some(idx) = idx { self.navigator.selected = idx; } } self.clamp_navigator_selection_from(terminal_runtimes); } pub(crate) fn clamp_navigator_selection_from( &mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, ) { let count = self.navigator_rows_from(terminal_runtimes).len(); self.navigator.selected = self.navigator.selected.min(count.saturating_sub(1)); self.ensure_navigator_selection_visible_from(terminal_runtimes); } pub(crate) fn toggle_selected_navigator_workspace_from( &mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, ) { let Some(row) = self .navigator_rows_from(terminal_runtimes) .get(self.navigator.selected) .cloned() else { return; }; let NavigatorTarget::Workspace { ws_idx } = row.target else { return; }; let Some(workspace_id) = self.workspaces.get(ws_idx).map(|ws| ws.id.clone()) else { return; }; if self.navigator.expanded_workspaces.contains(&workspace_id) { self.navigator.expanded_workspaces.remove(&workspace_id); } else { self.navigator.expanded_workspaces.insert(workspace_id); } self.clamp_navigator_selection_from(terminal_runtimes); } #[cfg(test)] pub(crate) fn accept_navigator_selection(&mut self) -> bool { let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); self.accept_navigator_selection_from(&terminal_runtimes) } pub(crate) fn accept_navigator_selection_from( &mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, ) -> bool { let Some(row) = self .navigator_rows_from(terminal_runtimes) .get(self.navigator.selected) .cloned() else { return false; }; self.focus_navigator_target(row.target) } pub(crate) fn focus_navigator_target(&mut self, target: NavigatorTarget) -> bool { match target { NavigatorTarget::Workspace { ws_idx } => { if ws_idx >= self.workspaces.len() { return false; } self.switch_workspace(ws_idx); self.mode = Mode::Terminal; true } NavigatorTarget::Tab { ws_idx, tab_idx } => { if ws_idx >= self.workspaces.len() { return false; } let tab_exists = self .workspaces .get(ws_idx) .is_some_and(|ws| tab_idx < ws.tabs.len()); if !tab_exists { return false; } self.switch_workspace_tab(ws_idx, tab_idx); self.mode = Mode::Terminal; true } NavigatorTarget::Pane { ws_idx, tab_idx, pane_id, } => { if ws_idx >= self.workspaces.len() { return false; } if self .workspaces .get(ws_idx) .and_then(|ws| ws.tabs.get(tab_idx)) .is_some_and(|tab| tab.panes.contains_key(&pane_id)) { self.focus_pane_in_workspace(ws_idx, pane_id); self.mode = Mode::Terminal; return true; } false } } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum NavigatorQueryKind { Empty, Text, State(NavigatorStateFilter), } fn navigator_query_kind( query: &str, state_filter: Option, ) -> NavigatorQueryKind { if let Some(filter) = state_filter { return NavigatorQueryKind::State(filter); } if query.is_empty() { NavigatorQueryKind::Empty } else { NavigatorQueryKind::Text } } fn navigator_state_filter_matches( filter: NavigatorStateFilter, state: AgentState, seen: bool, ) -> bool { match filter { NavigatorStateFilter::Blocked => state == AgentState::Blocked, NavigatorStateFilter::Working => state == AgentState::Working, NavigatorStateFilter::Idle => state == AgentState::Idle && seen, NavigatorStateFilter::Done => state == AgentState::Idle && !seen, } } fn navigator_matches(query: &str, text: &str) -> bool { text_matches_query(query, text) } fn launch_label(argv: Option<&Vec>) -> Option { let argv = argv?; let command = argv.first()?; std::path::Path::new(command) .file_name() .and_then(|name| name.to_str()) .map(str::to_string) .or_else(|| Some(command.clone())) } fn state_label_text(state: AgentState, seen: bool) -> &'static str { match (state, seen) { (AgentState::Blocked, _) => "blocked", (AgentState::Working, _) => "working", (AgentState::Idle, false) => "done", (AgentState::Idle, true) => "idle", (AgentState::Unknown, _) => "unknown", } } fn tab_aggregate_state( tab: &crate::workspace::Tab, terminals: &std::collections::HashMap< crate::terminal::TerminalId, crate::terminal::TerminalState, >, ) -> (AgentState, bool) { let mut aggregate = AgentState::Unknown; let mut seen = true; for pane in tab.panes.values() { let Some(terminal) = terminals.get(&pane.attached_terminal_id) else { continue; }; if state_priority(terminal.state, pane.seen) > state_priority(aggregate, seen) { aggregate = terminal.state; seen = pane.seen; } } (aggregate, seen) } fn state_priority(state: AgentState, seen: bool) -> u8 { match (state, seen) { (AgentState::Blocked, _) => 5, (AgentState::Working, _) => 4, (AgentState::Idle, false) => 3, (AgentState::Idle, true) => 2, (AgentState::Unknown, _) => 1, } } fn tab_activity_summary( tab: &crate::workspace::Tab, terminals: &std::collections::HashMap< crate::terminal::TerminalId, crate::terminal::TerminalState, >, ) -> String { activity_summary_for_panes(tab.panes.values(), terminals) } fn workspace_activity_summary( ws: &crate::workspace::Workspace, terminals: &std::collections::HashMap< crate::terminal::TerminalId, crate::terminal::TerminalState, >, ) -> String { activity_summary_for_panes(ws.tabs.iter().flat_map(|tab| tab.panes.values()), terminals) } fn activity_summary_for_panes<'a>( panes: impl Iterator, terminals: &std::collections::HashMap< crate::terminal::TerminalId, crate::terminal::TerminalState, >, ) -> String { let mut blocked = 0usize; let mut working = 0usize; let mut done = 0usize; for pane in panes { let Some(terminal) = terminals.get(&pane.attached_terminal_id) else { continue; }; match (terminal.state, pane.seen) { (AgentState::Blocked, _) => blocked += 1, (AgentState::Working, _) => working += 1, (AgentState::Idle, false) => done += 1, _ => {} } } let mut parts = Vec::new(); if blocked > 0 { parts.push(format!("{blocked} blocked")); } if working > 0 { parts.push(format!("{working} working")); } if done > 0 { parts.push(format!("{done} done")); } parts.join(" · ") } // --------------------------------------------------------------------------- // Workspace operations // --------------------------------------------------------------------------- impl AppState { pub(crate) fn next_agent_metadata_expiry(&self) -> Option { self.terminals .values() .filter_map(|terminal| terminal.next_agent_metadata_expiry()) .chain( self.terminals .values() .filter_map(|terminal| terminal.metadata_tokens.next_expiry()), ) .chain( self.workspaces .iter() .filter_map(|workspace| workspace.metadata_tokens.next_expiry()), ) .min() } pub(crate) fn expire_agent_metadata_at( &mut self, scheduled_deadline: std::time::Instant, now: std::time::Instant, ) -> Vec { let pane_terminals: Vec<_> = self .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| { ws.pane_state(pane_id) .map(|pane| (ws_idx, pane_id, pane.attached_terminal_id.clone())) }) }) }) .collect(); pane_terminals .into_iter() .filter_map(|(ws_idx, pane_id, terminal_id)| { let previous_seen = self.workspaces[ws_idx].pane_state(pane_id)?.seen; let mutation = self .terminals .get_mut(&terminal_id)? .expire_agent_metadata_at(scheduled_deadline, now)?; let change = mutation.effective_state_change?; let seen = self.apply_pane_state_change(ws_idx, pane_id, &change)?; let update = PaneStateUpdate { pane_id, ws_idx, previous_agent_label: change.previous_agent_label.clone(), previous_known_agent: change.previous_known_agent, previous_state: change.previous_state, previous_seen, previous_presentation: change.previous_presentation.clone(), agent_label: change.agent_label.clone(), known_agent: change.known_agent, state: change.state, seen, presentation: change.presentation.clone(), agent_name_changed: false, agent_released: false, agent_release_status: None, }; Some(update) }) .collect() } pub(crate) fn expire_metadata_tokens( &mut self, now: std::time::Instant, ) -> (Vec<(usize, PaneId)>, Vec) { let pane_terminals = self .workspaces .iter() .enumerate() .flat_map(|(ws_idx, workspace)| { workspace.tabs.iter().flat_map(move |tab| { tab.layout .pane_ids() .into_iter() .filter_map(move |pane_id| { workspace .pane_state(pane_id) .map(|pane| (ws_idx, pane_id, pane.attached_terminal_id.clone())) }) }) }) .collect::>(); let changed_panes = pane_terminals .into_iter() .filter_map(|(ws_idx, pane_id, terminal_id)| { let terminal = self.terminals.get_mut(&terminal_id)?; terminal.metadata_tokens.expire_at(now).then(|| { terminal.revision = terminal.revision.saturating_add(1); (ws_idx, pane_id) }) }) .collect(); let changed_workspaces = self .workspaces .iter_mut() .enumerate() .filter_map(|(ws_idx, workspace)| { workspace.metadata_tokens.expire_at(now).then_some(ws_idx) }) .collect(); (changed_panes, changed_workspaces) } pub(crate) 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() { let previous_focus = self.current_pane_focus_target(); self.active = Some(idx); self.selected = idx; let workspace_id = self.workspaces[idx].id.clone(); crate::logging::workspace_focused(&workspace_id); self.mark_session_dirty(); self.ensure_workspace_visible(idx); if let Some(ws) = self.workspaces.get_mut(idx) { let active_tab = ws.active_tab; ws.switch_tab(active_tab); let tab_id = public_tab_id_for_index(ws, active_tab).unwrap_or_else(|| workspace_id.clone()); crate::logging::tab_focused(&workspace_id, &tab_id); } self.tab_scroll_follow_active = true; self.refresh_tab_bar_view(); self.record_pane_focus_after_navigation(previous_focus); self.sync_selection_after_focus_navigation(); } } pub(crate) fn switch_workspace_tab(&mut self, ws_idx: usize, tab_idx: usize) -> bool { if ws_idx >= self.workspaces.len() { return false; } if self .workspaces .get(ws_idx) .is_none_or(|ws| tab_idx >= ws.tabs.len()) { return false; } let previous_focus = self.current_pane_focus_target(); let workspace_changed = self.active != Some(ws_idx); self.active = Some(ws_idx); self.selected = ws_idx; let workspace_id = self.workspaces[ws_idx].id.clone(); if workspace_changed { crate::logging::workspace_focused(&workspace_id); } self.mark_session_dirty(); self.ensure_workspace_visible(ws_idx); if let Some(ws) = self.workspaces.get_mut(ws_idx) { ws.switch_tab(tab_idx); let tab_id = public_tab_id_for_index(ws, tab_idx).unwrap_or_else(|| workspace_id.clone()); crate::logging::tab_focused(&workspace_id, &tab_id); } self.tab_scroll_follow_active = true; self.refresh_tab_bar_view(); self.record_pane_focus_after_navigation(previous_focus); self.sync_selection_after_focus_navigation(); true } pub(crate) fn ensure_workspace_visible(&mut self, idx: usize) { if idx >= self.workspaces.len() { return; } if self.view.layout == ViewLayout::Mobile && self.mode == Mode::Navigate { self.ensure_mobile_workspace_visible(idx); return; } if self.sidebar_collapsed { return; } let entries = crate::ui::workspace_list_entries(self); let Some(target_entry_idx) = entries.iter().position(|entry| { matches!( entry, crate::ui::WorkspaceListEntry::Workspace { ws_idx, .. } if *ws_idx == idx ) }) else { return; }; self.workspace_scroll = crate::ui::normalized_workspace_scroll( self, self.view.sidebar_rect, self.workspace_scroll, ); let mut cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect); if cards.iter().any(|card| card.ws_idx == idx) { return; } if target_entry_idx < self.workspace_scroll { self.workspace_scroll = target_entry_idx; return; } while !cards.iter().any(|card| card.ws_idx == idx) { let previous_scroll = self.workspace_scroll; self.workspace_scroll = self.workspace_scroll.saturating_add(1); if self.workspace_scroll == previous_scroll { break; } self.workspace_scroll = crate::ui::normalized_workspace_scroll( self, self.view.sidebar_rect, self.workspace_scroll, ); if self.workspace_scroll == previous_scroll { break; } cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect); if cards.is_empty() { break; } } } fn ensure_mobile_workspace_visible(&mut self, idx: usize) { let viewport = crate::ui::mobile_switcher_areas(self).viewport; if viewport.height == 0 { return; } let row_range = crate::ui::mobile_switcher_workspace_doc_range(self, idx); let visible_start = self.mobile_switcher_scroll; let visible_end = visible_start.saturating_add(viewport.height as usize); if row_range.start < visible_start { self.mobile_switcher_scroll = row_range.start; } else if row_range.end > visible_end { self.mobile_switcher_scroll = row_range.end.saturating_sub(viewport.height as usize); } self.mobile_switcher_scroll = self .mobile_switcher_scroll .min(crate::ui::mobile_switcher_max_scroll(self)); } #[cfg(test)] pub fn switch_tab(&mut self, idx: usize) { if let Some(ws_idx) = self.active { let previous_focus = self.current_pane_focus_target(); let Some(ws) = self.workspaces.get_mut(ws_idx) else { return; }; ws.switch_tab(idx); let workspace_id = ws.id.clone(); let tab_id = public_tab_id_for_index(ws, idx).unwrap_or_else(|| workspace_id.clone()); crate::logging::tab_focused(&workspace_id, &tab_id); self.mark_session_dirty(); self.tab_scroll_follow_active = true; self.refresh_tab_bar_view(); self.record_pane_focus_after_navigation(previous_focus); self.sync_selection_after_focus_navigation(); } } pub(crate) fn mark_active_tab_seen(&mut self) -> bool { let Some(ws_idx) = self.active else { return false; }; let Some(tab) = self .workspaces .get_mut(ws_idx) .and_then(crate::workspace::Workspace::active_tab_mut) else { return false; }; let mut changed = false; for pane in tab.panes.values_mut() { if !pane.seen { pane.seen = true; changed = true; } } changed } pub(crate) fn visible_workspace_order(&self) -> Vec { // Mobile always shows the worktree tree expanded, so its visible order // must ignore collapse state to match what the switcher renders. let entries = if self.view.layout == ViewLayout::Mobile { crate::ui::workspace_list_entries_expanded(self) } else { crate::ui::workspace_list_entries(self) }; let order = entries .into_iter() .map(|entry| match entry { crate::ui::WorkspaceListEntry::Workspace { ws_idx, .. } => ws_idx, }) .collect::>(); if order.is_empty() { (0..self.workspaces.len()).collect() } else { order } } pub(crate) fn workspace_at_visible_position(&self, position: usize) -> Option { self.visible_workspace_order().get(position).copied() } pub(crate) fn move_selected_workspace_by_visible_delta(&mut self, delta: isize) { if self.workspaces.is_empty() { return; } let order = self.visible_workspace_order(); let current_pos = order .iter() .position(|idx| *idx == self.selected) .unwrap_or(0); let target_pos = current_pos .saturating_add_signed(delta) .min(order.len().saturating_sub(1)); if let Some(ws_idx) = order.get(target_pos).copied() { self.selected = ws_idx; self.ensure_workspace_visible(ws_idx); } } #[cfg(test)] pub fn next_workspace(&mut self) { if self.workspaces.is_empty() { return; } let current = self.active.unwrap_or(self.selected); let order = self.visible_workspace_order(); let current_pos = order.iter().position(|idx| *idx == current).unwrap_or(0); let next = order[(current_pos + 1) % order.len()]; self.switch_workspace(next); } #[cfg(test)] pub fn previous_workspace(&mut self) { if self.workspaces.is_empty() { return; } let current = self.active.unwrap_or(self.selected); let order = self.visible_workspace_order(); let current_pos = order.iter().position(|idx| *idx == current).unwrap_or(0); let prev = if current_pos == 0 { order[order.len() - 1] } else { order[current_pos - 1] }; self.switch_workspace(prev); } pub fn move_workspace(&mut self, source_idx: usize, insert_idx: usize) -> bool { if source_idx >= self.workspaces.len() || insert_idx > self.workspaces.len() { return false; } let target_idx = if source_idx < insert_idx { insert_idx - 1 } else { insert_idx }; if source_idx == target_idx { return false; } self.mark_session_dirty(); 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); 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); self.ensure_workspace_visible(self.selected); true } pub fn move_workspace_block( &mut self, workspace_ids: &[String], before_workspace_id: Option<&str>, ) -> bool { let moved_ids = workspace_ids .iter() .map(String::as_str) .collect::>(); if moved_ids.is_empty() || moved_ids.len() != workspace_ids.len() || !workspace_ids .iter() .all(|id| self.workspaces.iter().any(|workspace| workspace.id == *id)) || before_workspace_id.is_some_and(|id| { moved_ids.contains(id) || !self.workspaces.iter().any(|workspace| workspace.id == id) }) { return false; } let mut desired_ids = self .workspaces .iter() .filter(|workspace| !moved_ids.contains(workspace.id.as_str())) .map(|workspace| workspace.id.clone()) .collect::>(); let insert_idx = before_workspace_id .and_then(|id| desired_ids.iter().position(|candidate| candidate == id)) .unwrap_or(desired_ids.len()); desired_ids.splice(insert_idx..insert_idx, workspace_ids.iter().cloned()); if self .workspaces .iter() .map(|workspace| workspace.id.as_str()) .eq(desired_ids.iter().map(String::as_str)) { return false; } 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 desired_positions = desired_ids .iter() .enumerate() .map(|(index, id)| (id.clone(), index)) .collect::>(); self.mark_session_dirty(); self.workspaces.sort_by_key(|workspace| { desired_positions .get(&workspace.id) .copied() .unwrap_or(usize::MAX) }); 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); self.ensure_workspace_visible(self.selected); true } pub fn scroll_tabs_left(&mut self) { self.tab_scroll_follow_active = false; self.tab_scroll = self.tab_scroll.saturating_sub(1); self.refresh_tab_bar_view(); } pub fn scroll_tabs_right(&mut self) { self.tab_scroll_follow_active = false; self.tab_scroll = self.tab_scroll.saturating_add(1); self.refresh_tab_bar_view(); } #[cfg(test)] pub fn next_tab(&mut self) { if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { if !ws.tabs.is_empty() { let next = (ws.active_tab + 1) % ws.tabs.len(); self.switch_tab(next); } } } #[cfg(test)] pub fn previous_tab(&mut self) { if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { if !ws.tabs.is_empty() { let prev = if ws.active_tab == 0 { ws.tabs.len() - 1 } else { ws.active_tab - 1 }; self.switch_tab(prev); } } } #[cfg(test)] pub fn next_agent(&mut self) { self.cycle_agent_entry(true); } #[cfg(test)] pub fn previous_agent(&mut self) { self.cycle_agent_entry(false); } #[cfg(test)] pub fn focus_agent_entry(&mut self, idx: usize) -> bool { let entries = crate::ui::agent_panel_entries(self); let Some(target) = entries.get(idx) else { return false; }; let ws_idx = target.ws_idx; let pane_id = target.pane_id; if self.active == Some(ws_idx) && self.workspaces[ws_idx].focused_pane_id() == Some(pane_id) { self.ensure_agent_panel_entry_visible(idx); return true; } if self.focus_pane_in_workspace(ws_idx, pane_id) { self.ensure_agent_panel_entry_visible(idx); return true; } false } #[cfg(test)] fn cycle_agent_entry(&mut self, forward: bool) { let entries = crate::ui::agent_panel_entries(self); if entries.is_empty() { return; } let focused = self .active .and_then(|idx| self.workspaces.get(idx)) .and_then(crate::workspace::Workspace::focused_pane_id); let current_idx = focused.and_then(|pane_id| entries.iter().position(|entry| entry.pane_id == pane_id)); let target_idx = match (current_idx, forward) { (Some(idx), true) => (idx + 1) % entries.len(), (Some(0), false) => entries.len() - 1, (Some(idx), false) => idx - 1, (None, true) => 0, (None, false) => entries.len() - 1, }; self.focus_agent_entry(target_idx); } pub(crate) fn ensure_agent_panel_entry_visible(&mut self, idx: usize) { if self.sidebar_collapsed { return; } let (_, detail_area) = crate::ui::expanded_sidebar_sections( self.view.sidebar_rect, self.sidebar_section_split, ); self.agent_panel_scroll = crate::ui::agent_panel_scroll_for_target( self, detail_area, self.agent_panel_scroll, idx, ); } pub(crate) fn terminal_ids_for_workspace( &self, ws_idx: usize, ) -> Vec { 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 pane_ids_for_workspace(&self, ws_idx: usize) -> Vec { self.workspaces .get(ws_idx) .into_iter() .flat_map(|ws| &ws.tabs) .flat_map(|tab| tab.layout.pane_ids()) .collect() } pub(crate) fn terminal_ids_for_tab( &self, ws_idx: usize, tab_idx: usize, ) -> Vec { 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 pane_ids_for_tab(&self, ws_idx: usize, tab_idx: usize) -> Vec { self.workspaces .get(ws_idx) .and_then(|ws| ws.tabs.get(tab_idx)) .map(|tab| tab.layout.pane_ids()) .unwrap_or_default() } pub(crate) fn terminal_id_for_pane( &self, ws_idx: usize, pane_id: PaneId, ) -> Option { 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, ) { 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).is_some() && !self.terminal_runtime_shutdowns.contains(&terminal_id) { self.terminal_runtime_shutdowns.push(terminal_id); } } } pub(crate) fn remove_plugin_pane_records( &mut self, pane_ids: impl IntoIterator, ) { let pane_ids = pane_ids.into_iter().collect::>(); self.clear_copy_mode_for_removed_panes(pane_ids.iter().copied()); if self .previous_pane_focus .as_ref() .is_some_and(|focus| pane_ids.contains(&focus.pane_id)) { self.previous_pane_focus = None; } for pane_id in pane_ids { self.plugin_panes.remove(&pane_id); } } pub fn close_selected_workspace(&mut self) { if self.workspaces.is_empty() { return; } self.selection = None; self.selection_autoscroll = None; self.mark_session_dirty(); let close_indices = self .workspaces .get(self.selected) .and_then(|ws| ws.worktree_space()) .filter(|space| !space.is_linked_worktree) .map(|space| { self.workspaces .iter() .enumerate() .filter_map(|(idx, ws)| { ws.worktree_space() .is_some_and(|member| member.key == space.key) .then_some(idx) }) .collect::>() }) .filter(|indices| indices.len() >= 2) .unwrap_or_else(|| vec![self.selected]); let mut terminal_ids = Vec::new(); let mut pane_ids = Vec::new(); for idx in &close_indices { terminal_ids.extend(self.terminal_ids_for_workspace(*idx)); pane_ids.extend(self.pane_ids_for_workspace(*idx)); if let Some(workspace_id) = self.workspaces.get(*idx).map(|ws| ws.id.clone()) { crate::logging::workspace_closed(&workspace_id); } } let active_workspace_id = self .active .and_then(|idx| self.workspaces.get(idx)) .map(|ws| ws.id.clone()); self.remove_plugin_pane_records(pane_ids); for idx in close_indices.iter().rev() { self.workspaces.remove(*idx); } self.remove_unattached_terminal_ids(terminal_ids); if self.workspaces.is_empty() { self.active = None; self.selected = 0; self.workspace_scroll = 0; self.tab_scroll = 0; self.tab_scroll_follow_active = true; } else { // Keep focus on the previously focused workspace if let Some(id) = active_workspace_id { if let Some(idx) = self.workspaces.iter().position(|ws| ws.id == id) { self.selected = idx; } } if self.selected >= self.workspaces.len() { self.selected = self.workspaces.len() - 1; } self.active = Some(self.selected); self.workspace_scroll = self .workspace_scroll .min(self.workspaces.len().saturating_sub(1)); self.ensure_workspace_visible(self.selected); self.tab_scroll_follow_active = true; self.refresh_tab_bar_view(); } } pub(crate) fn refresh_tab_bar_view(&mut self) { let area = self.view.tab_bar_rect; let Some(ws) = self.active.and_then(|idx| self.workspaces.get(idx)) else { self.tab_scroll = 0; self.view.tab_hit_areas.clear(); self.view.tab_scroll_left_hit_area = ratatui::layout::Rect::default(); self.view.tab_scroll_right_hit_area = ratatui::layout::Rect::default(); self.view.new_tab_hit_area = ratatui::layout::Rect::default(); return; }; let layout = crate::ui::compute_tab_bar_view( ws, crate::ui::tab_bar_content_area(self, area), self.tab_scroll, self.tab_scroll_follow_active, self.mouse_capture, ); self.tab_scroll = layout.scroll; self.view.tab_hit_areas = layout.tab_hit_areas; self.view.tab_scroll_left_hit_area = layout.scroll_left_hit_area; self.view.tab_scroll_right_hit_area = layout.scroll_right_hit_area; self.view.new_tab_hit_area = layout.new_tab_hit_area; } } // --------------------------------------------------------------------------- // Pane operations // --------------------------------------------------------------------------- #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PaneZoomCommand { Toggle, On, Off, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PaneZoomNoopReason { SinglePane, AlreadyZoomed, AlreadyUnzoomed, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct PaneZoomOutcome { pub changed: bool, pub focus_changed: bool, pub reason: Option, pub zoomed: bool, } impl AppState { #[cfg(test)] pub fn navigate_pane(&mut self, direction: NavDirection) { let Some(ws_idx) = self.active else { return; }; let Some(tab) = self.workspaces.get(ws_idx).and_then(|ws| ws.active_tab()) else { return; }; let panes = if tab.zoomed { tab.layout.panes(self.view.terminal_area) } else { self.view.pane_infos.clone() }; if let Some(focused) = panes.iter().find(|p| p.is_focused) { if let Some(target) = find_in_direction(focused, direction, &panes) { self.focus_pane_in_workspace(ws_idx, target); } } } #[cfg(test)] pub fn swap_pane(&mut self, direction: NavDirection) -> bool { let Some(ws_idx) = self.active else { return false; }; let Some(tab) = self.workspaces.get(ws_idx).and_then(|ws| ws.active_tab()) else { return false; }; let panes = if tab.zoomed { tab.layout.panes(self.view.terminal_area) } else { self.view.pane_infos.clone() }; let Some(focused) = panes.iter().find(|p| p.is_focused) else { return false; }; let Some(target) = find_in_direction(focused, direction, &panes) else { return false; }; let source = focused.id; let Some(tab) = self .workspaces .get_mut(ws_idx) .and_then(|ws| ws.active_tab_mut()) else { return false; }; if tab.layout.swap_panes(source, target) { self.mark_session_dirty(); true } else { false } } #[cfg(test)] 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); self.mark_session_dirty(); } } } #[cfg(test)] pub fn cycle_pane(&mut self, reverse: bool) { let Some(ws_idx) = self.active else { return; }; let Some(tab) = self.workspaces.get(ws_idx).and_then(|ws| ws.active_tab()) else { return; }; let ids = tab.layout.pane_ids(); if let Some(pos) = ids.iter().position(|id| *id == tab.layout.focused()) { let target = if reverse { ids[(pos + ids.len() - 1) % ids.len()] } else { ids[(pos + 1) % ids.len()] }; self.focus_pane_in_workspace(ws_idx, target); } } #[cfg(test)] pub fn last_pane(&mut self) { let Some(target) = self.previous_pane_focus.clone() else { return; }; let Some((ws_idx, tab_idx)) = self.pane_focus_target_indices(&target) else { self.previous_pane_focus = None; return; }; let current = self.current_pane_focus_target(); if current.as_ref() == Some(&target) { self.previous_pane_focus = None; return; } self.switch_workspace_tab(ws_idx, tab_idx); if let Some(tab) = self .workspaces .get_mut(ws_idx) .and_then(|ws| ws.tabs.get_mut(tab_idx)) { tab.layout.focus_pane(target.pane_id); self.previous_pane_focus = current; self.mark_session_dirty(); } } pub(crate) fn apply_pane_zoom( &mut self, ws_idx: usize, pane_id: PaneId, command: PaneZoomCommand, ) -> Option { let tab_idx = self .workspaces .get(ws_idx)? .find_tab_index_for_pane(pane_id)?; let focus_changed = self.focus_pane_in_workspace(ws_idx, pane_id); let tab = self .workspaces .get_mut(ws_idx) .and_then(|ws| ws.tabs.get_mut(tab_idx))?; if tab.layout.pane_count() <= 1 { return Some(PaneZoomOutcome { changed: false, focus_changed, reason: Some(PaneZoomNoopReason::SinglePane), zoomed: tab.zoomed, }); } let desired = match command { PaneZoomCommand::Toggle => !tab.zoomed, PaneZoomCommand::On => true, PaneZoomCommand::Off => false, }; let reason = match (command, tab.zoomed) { (PaneZoomCommand::On, true) => Some(PaneZoomNoopReason::AlreadyZoomed), (PaneZoomCommand::Off, false) => Some(PaneZoomNoopReason::AlreadyUnzoomed), _ => None, }; if reason.is_some() { return Some(PaneZoomOutcome { changed: false, focus_changed, reason, zoomed: tab.zoomed, }); } tab.zoomed = desired; let zoomed = tab.zoomed; self.mark_session_dirty(); Some(PaneZoomOutcome { changed: true, focus_changed, reason: None, zoomed, }) } #[cfg(test)] pub fn toggle_zoom(&mut self) { let Some(ws_idx) = self.active else { return; }; let Some(pane_id) = self .workspaces .get(ws_idx) .and_then(crate::workspace::Workspace::focused_pane_id) else { return; }; self.apply_pane_zoom(ws_idx, pane_id, PaneZoomCommand::Toggle); } pub(crate) fn workspace_close_would_close_worktree_group(&self, ws_idx: usize) -> bool { self.workspaces .get(ws_idx) .and_then(|ws| ws.worktree_space()) .filter(|space| !space.is_linked_worktree) .is_some_and(|space| { self.workspaces .iter() .filter(|ws| { ws.worktree_space() .is_some_and(|member| member.key == space.key) }) .count() >= 2 }) } pub(crate) fn confirm_implicit_worktree_group_close(&mut self, ws_idx: usize) -> bool { if self.confirm_close && self.workspace_close_would_close_worktree_group(ws_idx) { self.selected = ws_idx; self.mode = Mode::ConfirmClose; true } else { false } } #[cfg(test)] fn close_focused_pane_would_close_workspace(&self, ws_idx: usize) -> bool { self.workspaces.get(ws_idx).is_some_and(|ws| { let pane_count = ws .active_tab() .map(|tab| tab.layout.pane_count()) .unwrap_or(0); pane_count <= 1 && ws.tabs.len() <= 1 }) } pub(crate) fn close_pane_would_close_workspace(&self, ws_idx: usize, pane_id: PaneId) -> bool { self.workspaces.get(ws_idx).is_some_and(|ws| { ws.find_tab_index_for_pane(pane_id).is_some_and(|tab_idx| { ws.tabs[tab_idx].layout.pane_count() <= 1 && ws.tabs.len() <= 1 }) }) } #[cfg(test)] /// Close the focused pane. Returns true when the close was deferred to confirmation. pub fn close_pane(&mut self) -> bool { let active = self.active; if active.is_some_and(|ws_idx| { self.close_focused_pane_would_close_workspace(ws_idx) && self.workspace_close_would_close_worktree_group(ws_idx) }) { if let Some(ws_idx) = active { if self.confirm_implicit_worktree_group_close(ws_idx) { return true; } } } self.selection = None; self.selection_autoscroll = None; self.mark_session_dirty(); 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::>(); let pane_ids = active .and_then(|i| self.workspaces.get(i).and_then(|ws| ws.focused_pane_id())) .into_iter() .collect::>(); let should_close_workspace = active .and_then(|i| self.workspaces.get_mut(i)) .is_some_and(|ws| ws.close_focused()); self.remove_plugin_pane_records(pane_ids); if should_close_workspace { if let Some(active) = active { self.selected = active; } self.close_selected_workspace(); } else { self.remove_unattached_terminal_ids(terminal_ids); } false } #[cfg(test)] /// Close the active tab. Returns true when the close was deferred to confirmation. pub fn close_tab(&mut self) -> bool { if self.active.is_some_and(|ws_idx| { self.workspaces .get(ws_idx) .is_some_and(|ws| ws.tabs.len() <= 1) && self.workspace_close_would_close_worktree_group(ws_idx) }) { if let Some(ws_idx) = self.active { if self.confirm_implicit_worktree_group_close(ws_idx) { return true; } } } self.selection = None; self.selection_autoscroll = None; self.mark_session_dirty(); 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 { if let Some(active) = self.active { self.selected = active; } self.close_selected_workspace(); return false; } 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 pane_ids = self .workspaces .get(ws_idx) .map(|ws| self.pane_ids_for_tab(ws_idx, ws.active_tab)) .unwrap_or_default(); let Some(ws) = self.workspaces.get_mut(ws_idx) else { return false; }; let workspace_id = ws.id.clone(); let closing_tab_id = public_tab_id_for_index(ws, ws.active_tab).unwrap_or_else(|| workspace_id.clone()); ws.close_active_tab(); self.remove_plugin_pane_records(pane_ids); 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(); } false } } // --------------------------------------------------------------------------- // Selection // --------------------------------------------------------------------------- impl AppState { pub fn clear_selection(&mut self) { self.selection = None; self.selection_autoscroll = None; } pub(crate) fn stop_selection_autoscroll_state(&mut self) { self.selection_autoscroll = None; } pub(crate) fn select_word_at_pane_cell( &mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, pane_id: crate::layout::PaneId, viewport_row: u16, col: u16, ) -> bool { // Resolve the active pane cell the double-click landed on. let Some(ws_idx) = self .active .filter(|idx| self.workspaces.get(*idx).is_some()) else { return false; }; let Some(info) = self.pane_info_by_id(pane_id) else { return false; }; if viewport_row >= info.inner_rect.height || col >= info.inner_rect.width { return false; } // Leave mouse input to terminal apps that requested it. let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) else { return false; }; if rt .input_state() .is_some_and(crate::pane::InputState::mouse_reporting_enabled) { return false; } // Read the visible row and identify the clicked token bounds. let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id); let row_selection = Selection::range( pane_id, viewport_row, 0, info.inner_rect.width.saturating_sub(1), metrics, ); let Some(row_text) = rt.extract_selection(&row_selection) else { return false; }; let Some((start_col, end_col)) = word_bounds_at_column(&row_text, col) else { return false; }; let mut selection = Selection::range(pane_id, viewport_row, start_col, end_col, metrics); if !selection.finish() { return false; } let text = if self.copy_on_select { let Some(text) = rt .extract_selection(&selection) .filter(|text| !text.is_empty()) else { self.clear_selection(); return false; }; Some(text) } else { None }; self.selection = Some(selection); self.selection_autoscroll = None; if let Some(text) = text { self.request_clipboard_write = Some(text.into_bytes()); info!("copied double-clicked token to clipboard"); } true } pub(crate) fn url_at_pane_cell( &self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, pane_id: crate::layout::PaneId, viewport_row: u16, col: u16, ) -> Option { let ws_idx = self .active .filter(|idx| self.workspaces.get(*idx).is_some())?; let info = self.pane_info_by_id(pane_id)?; if viewport_row >= info.inner_rect.height || col >= info.inner_rect.width { return None; } let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)?; let screen_col = info.inner_rect.x.saturating_add(col); let screen_row = info.inner_rect.y.saturating_add(viewport_row); if let Some((_, _, uri)) = rt .visible_hyperlinks(info.inner_rect) .into_iter() .find(|((x, y), _, _)| *x == screen_col && *y == screen_row) { return safe_web_url(&uri).map(str::to_owned); } let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id); let visible_selection = Selection::line_range( pane_id, Selection::absolute_row_for_viewport(0, metrics), Selection::absolute_row_for_viewport(info.inner_rect.height.saturating_sub(1), metrics), info.inner_rect.width.saturating_sub(1), ); let visible_text = rt.extract_selection(&visible_selection)?; let logical_cell = logical_cell_for_visible_cell(&visible_text, info.inner_rect.width, viewport_row, col)?; let line_start = visible_text[..logical_cell.byte_index] .rfind('\n') .map_or(0, |idx| idx + 1); let line_end = visible_text[logical_cell.byte_index..] .find('\n') .map_or(visible_text.len(), |idx| logical_cell.byte_index + idx); let line = visible_text.get(line_start..line_end)?; url_at_column(line, logical_cell.logical_col).map(str::to_owned) } pub fn copy_selection(&mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry) { let mut sel = match self.selection.take() { Some(sel) => sel, None => return, }; if !sel.is_finalized() && !sel.finish() { return; } 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(terminal_runtimes, 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()); info!("copied selection to clipboard"); } } self.clear_selection(); } } pub(crate) fn safe_web_url(url: &str) -> Option<&str> { (url.starts_with("http://") || url.starts_with("https://")).then_some(url) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct TextCell { ch: char, start_col: u16, end_col: u16, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct CellSpan { start: usize, end: usize, } impl CellSpan { fn contains(self, idx: usize) -> bool { idx >= self.start && idx <= self.end } fn columns(self, cells: &[TextCell]) -> (u16, u16) { (cells[self.start].start_col, cells[self.end].end_col) } } /// Finds the terminal display-column bounds for the token under a double-click. /// /// The algorithm first maps text to terminal cells so wide characters and /// zero-width marks use display columns, then prefers structured spans that /// users expect to copy whole (URLs and quoted paths), and finally falls back /// to a separator-delimited token. fn word_bounds_at_column(row: &str, col: u16) -> Option<(u16, u16)> { // Map the row into display cells before doing any word-boundary work. let cells = text_cells(row); let clicked_idx = cell_index_at_column(&cells, col)?; // Prefer spans that can legally include punctuation or spaces. let span = url_span_at_column(&cells, clicked_idx) .or_else(|| quoted_path_span_at_column(&cells, clicked_idx)) .or_else(|| token_span_at_column(&cells, clicked_idx))?; // Convert the internal cell span back to inclusive terminal columns. Some(span.columns(&cells)) } pub(crate) fn url_at_column(row: &str, col: u16) -> Option<&str> { let cells = text_cells(row); let clicked_idx = cell_index_at_column(&cells, col)?; let span = url_spans(&cells) .into_iter() .find(|span| span.contains(clicked_idx))?; let start_byte = byte_index_for_cell(row, span.start); let end_byte = byte_index_after_cell(row, span.end); safe_web_url(row.get(start_byte..end_byte)?) } fn url_spans(cells: &[TextCell]) -> Vec { let mut spans = Vec::new(); let mut start = 0; while start < cells.len() { if starts_with_chars(&cells[start..], "http://") || starts_with_chars(&cells[start..], "https://") { let mut end = start; while end + 1 < cells.len() && !cells[end + 1].ch.is_whitespace() { end += 1; } if let Some(span) = trim_url_edges(cells, CellSpan { start, end }) { spans.push(span); } start = end + 1; } else { start += 1; } } spans } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct VisibleTextCell { pub(crate) byte_index: usize, pub(crate) ch: char, pub(crate) logical_col: u16, pub(crate) screen_row: u16, pub(crate) screen_col: u16, } pub(crate) fn visible_text_cells(text: &str, pane_width: u16) -> Vec { if pane_width == 0 { return Vec::new(); } let mut cells = Vec::new(); let mut screen_row = 0u16; let mut screen_col = 0u16; let mut logical_col = 0u16; let mut pending_wrap = false; for (byte_index, ch) in text.char_indices() { if ch == '\n' { screen_row = screen_row.saturating_add(1); screen_col = 0; logical_col = 0; pending_wrap = false; continue; } if pending_wrap { screen_row = screen_row.saturating_add(1); screen_col = 0; pending_wrap = false; } let width = u16::from(crate::ghostty::unicode_codepoint_width(ch as u32)); cells.push(VisibleTextCell { byte_index, ch, logical_col, screen_row, screen_col, }); logical_col = logical_col.saturating_add(width); screen_col = screen_col.saturating_add(width); while screen_col > pane_width { screen_col -= pane_width; screen_row = screen_row.saturating_add(1); } if width > 0 && screen_col == pane_width { pending_wrap = true; screen_col = pane_width.saturating_sub(1); } } cells } pub(crate) fn logical_cell_for_visible_cell( text: &str, pane_width: u16, target_row: u16, target_col: u16, ) -> Option { visible_text_cells(text, pane_width) .into_iter() .find(|cell| { let width = u16::from(crate::ghostty::unicode_codepoint_width(cell.ch as u32)); cell.screen_row == target_row && if width == 0 { target_col == cell.screen_col } else { target_col >= cell.screen_col && target_col < cell.screen_col.saturating_add(width) } }) } fn token_span_at_column(cells: &[TextCell], clicked_idx: usize) -> Option { if is_word_separator(cells[clicked_idx].ch) { return None; } let mut start = clicked_idx; while start > 0 && !is_word_separator(cells[start - 1].ch) { start -= 1; } let mut end = clicked_idx; while end + 1 < cells.len() && !is_word_separator(cells[end + 1].ch) { end += 1; } trim_token_edges(cells, CellSpan { start, end }).filter(|span| span.contains(clicked_idx)) } fn text_cells(row: &str) -> Vec { let mut next_col = 0u16; row.chars() .map(|ch| { let width = u16::from(crate::ghostty::unicode_codepoint_width(ch as u32)); let start_col = if width == 0 { next_col.saturating_sub(1) } else { next_col }; if width > 0 { next_col = next_col.saturating_add(width); } TextCell { ch, start_col, end_col: next_col.saturating_sub(1), } }) .collect() } fn cell_index_at_column(cells: &[TextCell], col: u16) -> Option { cells .iter() .position(|cell| cell.start_col <= col && col <= cell.end_col) } fn byte_index_for_cell(row: &str, cell_idx: usize) -> usize { row.char_indices() .nth(cell_idx) .map(|(idx, _)| idx) .unwrap_or(row.len()) } fn byte_index_after_cell(row: &str, cell_idx: usize) -> usize { row.char_indices() .nth(cell_idx.saturating_add(1)) .map(|(idx, _)| idx) .unwrap_or(row.len()) } fn url_span_at_column(cells: &[TextCell], clicked_idx: usize) -> Option { let mut start = 0; while start < cells.len() { if starts_with_chars(&cells[start..], "http://") || starts_with_chars(&cells[start..], "https://") { let mut end = start; while end + 1 < cells.len() && !cells[end + 1].ch.is_whitespace() { end += 1; } if clicked_idx >= start && clicked_idx <= end { let span = trim_url_edges(cells, CellSpan { start, end })?; return span.contains(clicked_idx).then_some(span); } start = end + 1; } else { start += 1; } } None } fn trim_url_edges(cells: &[TextCell], span: CellSpan) -> Option { let start = span.start; let mut end = span.end; while start <= end && should_trim_trailing_url_cell(cells, start, end) { if end == 0 { return None; } end -= 1; } (start <= end).then_some(CellSpan { start, end }) } fn should_trim_trailing_url_cell(cells: &[TextCell], start: usize, end: usize) -> bool { match cells[end].ch { '"' | '\'' | '`' | '.' | ',' | ';' | ':' | '!' | '?' => true, ')' => !trailing_url_closer_is_balanced(cells, start, end, '(', ')'), ']' => !trailing_url_closer_is_balanced(cells, start, end, '[', ']'), '}' => !trailing_url_closer_is_balanced(cells, start, end, '{', '}'), _ => false, } } fn trailing_url_closer_is_balanced( cells: &[TextCell], start: usize, end: usize, open: char, close: char, ) -> bool { let mut balance = 0i32; for cell in &cells[start..end] { if cell.ch == open { balance += 1; } else if cell.ch == close { balance -= 1; } } balance > 0 } fn quoted_path_span_at_column(cells: &[TextCell], clicked_idx: usize) -> Option { let clicked = cells.get(clicked_idx)?.ch; if clicked == '"' || clicked == '\'' || clicked == '`' { return None; } for quote in ['"', '\'', '`'] { let mut start = None; for (idx, cell) in cells.iter().copied().enumerate() { let ch = cell.ch; if ch != quote || is_escaped(cells, idx) { continue; } if let Some(open) = start { if clicked_idx > open && clicked_idx < idx && cells[open + 1..idx].iter().any(|cell| cell.ch == '/') { return Some(CellSpan { start: open + 1, end: idx - 1, }); } start = None; } else { start = Some(idx); } } } None } fn is_escaped(cells: &[TextCell], idx: usize) -> bool { let mut slashes = 0; let mut cursor = idx; while cursor > 0 && cells[cursor - 1].ch == '\\' { slashes += 1; cursor -= 1; } slashes % 2 == 1 } fn starts_with_chars(cells: &[TextCell], prefix: &str) -> bool { prefix .chars() .enumerate() .all(|(idx, expected)| cells.get(idx).is_some_and(|cell| cell.ch == expected)) } fn is_word_separator(ch: char) -> bool { ch.is_whitespace() || matches!( ch, '|' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';' | '!' ) } fn trim_token_edges(cells: &[TextCell], span: CellSpan) -> Option { let mut start = span.start; let mut end = span.end; while start <= end && is_leading_token_wrapper(cells[start].ch) { start += 1; } if start < end && cells[end].ch == '$' && is_trailing_token_wrapper(cells[end - 1].ch) { end -= 1; } while start <= end && is_trailing_token_wrapper(cells[end].ch) { if end == 0 { return None; } end -= 1; } (start <= end).then_some(CellSpan { start, end }) } fn is_leading_token_wrapper(ch: char) -> bool { matches!(ch, '(' | '[' | '{' | '<' | '"' | '\'' | '`') } fn is_trailing_token_wrapper(ch: char) -> bool { matches!( ch, ')' | ']' | '}' | '>' | '"' | '\'' | '`' | '.' | ',' | ';' | ':' | '!' | '?' ) } // --------------------------------------------------------------------------- // Event handling // --------------------------------------------------------------------------- impl AppState { pub fn apply_workspace_git_statuses( &mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, results: Vec, ) -> bool { let mut changed = false; for result in results { let Some(ws_idx) = self .workspaces .iter() .position(|ws| ws.id == result.workspace_id) else { continue; }; if self.workspaces[ws_idx] .resolved_identity_cwd_from(&self.terminals, terminal_runtimes) .as_ref() != Some(&result.resolved_identity_cwd) { continue; } let ws = &mut self.workspaces[ws_idx]; if ws.cached_identity_cwd != result.resolved_identity_cwd { ws.cached_identity_cwd = result.resolved_identity_cwd; } if ws.cached_auto_label != result.auto_label { ws.cached_auto_label = result.auto_label; changed |= ws.custom_name.is_none(); } if ws.cached_git_status_key != result.status_cache_key { ws.cached_git_status_key = result.status_cache_key; } if result.demand.branch && ws.cached_git_branch != result.branch { ws.cached_git_branch = result.branch; changed = true; } if result.demand.ahead_behind && ws.cached_git_ahead_behind != result.ahead_behind { ws.cached_git_ahead_behind = result.ahead_behind; changed = true; } if ws.cached_git_space != result.space { ws.cached_git_space = result.space; changed = true; } } changed } 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, install_command, } => { self.update_available = Some(version.clone()); self.update_install_command = install_command.clone(); self.latest_release_notes_available = true; self.update_dismissed = true; if matches!( self.toast_config.delivery, crate::config::ToastDelivery::Herdr ) { self.toast = Some(ToastNotification { kind: ToastKind::UpdateInstalled, title: format!("v{version} available"), context: crate::update::update_install_instruction(&install_command), position: None, target: None, }); } Vec::new() } AppEvent::AgentDetectionManifestsUpdated { updated, status } => { self.agent_manifest_update_status = status; self.refresh_agent_manifest_summaries(); if !updated.is_empty() && matches!( self.toast_config.delivery, crate::config::ToastDelivery::Herdr ) { let agent_list = updated .iter() .map(|item| { format!( "{} {}", crate::detect::agent_label(item.agent), item.version ) }) .collect::>() .join(", "); self.toast = Some(ToastNotification { kind: ToastKind::UpdateInstalled, title: "Agent detection rules updated".to_string(), context: agent_list, position: None, target: None, }); } Vec::new() } AppEvent::StateChanged { pane_id, agent, state, visible_blocker, visible_working, process_exited, observed_at, } => self .update_terminal_state(pane_id, |terminal| { Some(terminal.set_detected_state_with_screen_signals_at( agent, state, visible_blocker, false, visible_working, process_exited, observed_at, )) }) .into_iter() .collect(), AppEvent::HookStateReported { pane_id, source, agent_label, state, message, seq, session_ref, } => { if crate::agent_resume::is_reserved_native_state_source(&source, &agent_label) { self.update_terminal_state(pane_id, |terminal| { terminal.set_agent_session_ref(source, agent_label, session_ref, seq) }) .into_iter() .collect() } else { self.update_terminal_state(pane_id, |terminal| { terminal.set_hook_authority_with_session_ref( source, agent_label, state, message, session_ref, seq, ) }) .into_iter() .collect() } } AppEvent::AgentSessionReported { pane_id, source, agent_label, seq, session_ref, session_start_source, } => self .update_terminal_state(pane_id, |terminal| { terminal.set_agent_session_ref_for_session_start( source, agent_label, session_ref, seq, session_start_source, ) }) .into_iter() .collect(), AppEvent::HookMetadataReported { pane_id, source, agent_label, applies_to_source, title, display_agent, state_labels, clear_title, clear_display_agent, clear_state_labels, seq, ttl, } => self .update_terminal_state(pane_id, |terminal| { terminal.set_agent_metadata(crate::terminal::AgentMetadataReport { source, agent_label, applies_to_source, title, display_agent, state_labels, clear_title, clear_display_agent, clear_state_labels, ttl, seq, }) }) .into_iter() .collect(), AppEvent::HookAuthorityCleared { pane_id, source, seq, } => self .update_terminal_state(pane_id, |terminal| { terminal.clear_hook_authority_with_mutation(source.as_deref(), seq) }) .into_iter() .collect(), AppEvent::HookAgentReleased { pane_id, source, agent_label, seq, .. } => { if crate::agent_resume::is_official_agent_source(&source, &agent_label) { Vec::new() } else { self.update_terminal_state(pane_id, |terminal| { terminal.release_agent_with_mutation(&source, &agent_label, seq) }) .into_iter() .collect() } } // Intercepted before this dispatch — in App::handle_internal_event (monolithic) // or via HeadlessServer forwarding to the foreground client (server); never touch // AppState. Kept for AppEvent exhaustiveness. AppEvent::TerminalBell { .. } => Vec::new(), AppEvent::ClipboardWrite { .. } => Vec::new(), AppEvent::PrefixInputSource { .. } => Vec::new(), AppEvent::TerminalCwdReported { pane_id, cwd } => { if !cwd.is_absolute() || !cwd.is_dir() { return Vec::new(); } let Some(terminal_id) = self.workspaces.iter().find_map(|ws| { ws.pane_state(pane_id) .map(|pane| pane.attached_terminal_id.clone()) }) else { return Vec::new(); }; let Some(terminal) = self.terminals.get_mut(&terminal_id) else { return Vec::new(); }; if terminal.cwd != cwd { terminal.cwd = cwd; self.mark_session_dirty(); } Vec::new() } AppEvent::GitStatusRefreshed { results, cache_updates, } => { let _ = results; let _ = cache_updates; Vec::new() } AppEvent::WorktreeAddFinished(_) => Vec::new(), AppEvent::WorktreeRemoveFinished(_) => Vec::new(), AppEvent::TabBarCommandFinished { .. } => Vec::new(), AppEvent::PluginCommandFinished { .. } => Vec::new(), } } fn update_terminal_state(&mut self, pane_id: PaneId, update: F) -> Option where F: FnOnce(&mut crate::terminal::TerminalState) -> Option, { 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 previous_seen = self.workspaces[ws_idx].pane_state(pane_id)?.seen; let now = Instant::now(); let (mutation, managed_changed, agent_name_changed, unchanged_change) = { let terminal = self.terminals.get_mut(&terminal_id)?; let previous_agent_name = terminal.agent_name.clone(); let mutation = update(terminal)?; let managed_changed = terminal.reconcile_managed_agent_at(now, false); let agent_name_changed = terminal.agent_name != previous_agent_name; let unchanged_change = (mutation.agent_released || agent_name_changed) .then(|| terminal.unchanged_effective_state_change_at(now)); ( mutation, managed_changed, agent_name_changed, unchanged_change, ) }; if mutation.session_ref_changed || managed_changed || agent_name_changed { self.mark_session_dirty(); } let agent_released = mutation.agent_released; let change = mutation.effective_state_change.or(unchanged_change)?; if change.previous_state != change.state { self.next_agent_state_change_seq += 1; if let Some(terminal) = self.terminals.get_mut(&terminal_id) { terminal.last_agent_state_change_seq = Some(self.next_agent_state_change_seq); } } let seen = self.apply_pane_state_change(ws_idx, pane_id, &change)?; let update = PaneStateUpdate { pane_id, ws_idx, previous_agent_label: change.previous_agent_label.clone(), previous_known_agent: change.previous_known_agent, previous_state: change.previous_state, previous_seen, previous_presentation: change.previous_presentation.clone(), agent_label: if agent_released { change.previous_agent_label.clone() } else { change.agent_label.clone() }, known_agent: if agent_released { change.previous_known_agent } else { change.known_agent }, state: change.state, seen, presentation: change.presentation.clone(), agent_name_changed, agent_released, agent_release_status: agent_released.then(|| pane_agent_status(change.state, seen)), }; Some(update) } pub(crate) fn next_managed_agent_deadline(&self) -> Option { self.terminals .values() .filter_map(crate::terminal::TerminalState::next_managed_agent_deadline) .min() } pub(crate) fn reconcile_managed_agents_at(&mut self, now: Instant) -> Vec<(usize, PaneId)> { let mut changed_terminals = std::collections::HashSet::new(); for (terminal_id, terminal) in &mut self.terminals { if terminal.reconcile_managed_agent_at(now, false) { changed_terminals.insert(terminal_id.clone()); } } if changed_terminals.is_empty() { return Vec::new(); } self.mark_session_dirty(); self.workspaces .iter() .enumerate() .flat_map(|(ws_idx, workspace)| { let changed_terminals = &changed_terminals; workspace.tabs.iter().flat_map(move |tab| { tab.panes.iter().filter_map(move |(&pane_id, pane)| { changed_terminals .contains(&pane.attached_terminal_id) .then_some((ws_idx, pane_id)) }) }) }) .collect() } pub(crate) fn publish_pane_process_exit_if_agent( &mut self, pane_id: PaneId, ) -> Option { let observed_at = std::time::Instant::now(); let update = self.update_terminal_state(pane_id, |terminal| { let agent = terminal.effective_known_agent().or(terminal.detected_agent); if agent.is_none() && !terminal.full_lifecycle_hook_authority_active() { return None; } Some(terminal.set_detected_state_with_screen_signals_at( agent, AgentState::Idle, false, true, false, true, observed_at, )) })?; update.agent_released.then_some(update) } fn apply_pane_state_change( &mut self, ws_idx: usize, pane_id: PaneId, change: &EffectiveStateChange, ) -> Option { let is_active_tab = self.pane_is_in_active_tab(ws_idx, pane_id); let suppress_active_tab_notifications = active_tab_suppresses_notifications(is_active_tab, self.outer_terminal_focus); let pane = self.workspaces[ws_idx] .tabs .iter_mut() .find_map(|tab| tab.panes.get_mut(&pane_id))?; if change.state != AgentState::Idle { pane.seen = true; } else if is_completion_transition(change) { pane.seen = suppress_active_tab_notifications; } let seen = pane.seen; if let Some(delivery) = self.record_or_deliver_agent_notification(ws_idx, pane_id, change) { self.apply_agent_notification_delivery(&delivery); } Some(seen) } fn record_or_deliver_agent_notification( &mut self, ws_idx: usize, pane_id: PaneId, change: &EffectiveStateChange, ) -> Option { self.pending_agent_notifications.remove(&pane_id); let is_active_tab = self.pane_is_in_active_tab(ws_idx, pane_id); let suppress_active_tab_notifications = active_tab_suppresses_notifications(is_active_tab, self.outer_terminal_focus); let client_notification_kind = notification_toast_for_effective_state_change( suppress_active_tab_notifications, change, ); let sound = notification_sound_for_effective_state_change( suppress_active_tab_notifications, change, ); if client_notification_kind.is_none() && sound.is_none() { return None; } let agent_label = change .agent_label .clone() .or_else(|| change.previous_agent_label.clone())?; let known_agent = change.known_agent.or(change.previous_known_agent); let kind = client_notification_kind.unwrap_or(match sound { Some(crate::sound::Sound::Request) => ToastKind::NeedsAttention, Some(crate::sound::Sound::Done) | None => ToastKind::Finished, }); let workspace_id = self.workspaces[ws_idx].id.clone(); if self.toast_config.delay_seconds == 0 { return self.agent_notification_delivery( ws_idx, pane_id, workspace_id, agent_label, known_agent, kind, change.state, ); } self.pending_agent_notifications.insert( pane_id, PendingAgentNotification { pane_id, workspace_id, agent_label, known_agent, kind, state: change.state, deadline: { let now = std::time::Instant::now(); let delay_seconds = self .toast_config .delay_seconds .min(crate::config::MAX_TOAST_DELAY_SECONDS); now.checked_add(std::time::Duration::from_secs(delay_seconds)) .unwrap_or(now) }, }, ); None } fn agent_notification_delivery( &self, ws_idx: usize, pane_id: PaneId, workspace_id: String, agent_label: String, known_agent: Option, kind: ToastKind, expected_state: AgentState, ) -> Option { let terminal_state = self .workspaces .get(ws_idx)? .pane_state(pane_id) .and_then(|pane| self.terminals.get(&pane.attached_terminal_id))?; if terminal_state.state != expected_state { return None; } if terminal_state .effective_agent_label() .is_some_and(|current| current != agent_label) { return None; } let is_active_tab = self.pane_is_in_active_tab(ws_idx, pane_id); let suppress_active_tab_notifications = active_tab_suppresses_notifications(is_active_tab, self.outer_terminal_focus); let sound = sound_for_toast_kind(kind, suppress_active_tab_notifications) .filter(|_| self.sound.allows(known_agent)); let build_toast = || { let workspace_label = self.workspaces[ws_idx].display_name_from_terminals(&self.terminals); let context = notification_context(&self.workspaces[ws_idx], &workspace_label, ws_idx, pane_id); ToastNotification { kind, title: format!( "{} {}", toast_agent_label(&agent_label), toast_event_text(kind) ), context, position: None, target: Some(ToastTarget { workspace_id: workspace_id.clone(), pane_id, }), } }; let toast = (!is_active_tab).then(build_toast); let client_notification = (!suppress_active_tab_notifications).then(build_toast); if toast.is_none() && client_notification.is_none() && sound.is_none() { return None; } Some(AgentNotificationDelivery { pane_id, workspace_id, agent_label, known_agent, kind, toast, client_notification, sound, }) } fn apply_agent_notification_delivery(&mut self, delivery: &AgentNotificationDelivery) { if self.local_sound_playback { if let Some(sound) = delivery.sound { crate::sound::play(sound, &self.sound); } } if matches!( self.toast_config.delivery, crate::config::ToastDelivery::Herdr ) { if let Some(toast) = delivery.toast.clone() { self.toast = Some(toast); } } } pub fn next_pending_agent_notification_deadline(&self) -> Option { self.pending_agent_notifications .values() .map(|pending| pending.deadline) .min() } pub fn drain_due_agent_notifications( &mut self, now: std::time::Instant, ) -> Vec { let due_panes: Vec = self .pending_agent_notifications .iter() .filter_map(|(&pane_id, pending)| (pending.deadline <= now).then_some(pane_id)) .collect(); let mut deliveries = Vec::new(); for pane_id in due_panes { let Some(pending) = self.pending_agent_notifications.remove(&pane_id) else { continue; }; let Some(ws_idx) = self .workspaces .iter() .position(|ws| ws.id == pending.workspace_id) else { continue; }; let Some(delivery) = self.agent_notification_delivery( ws_idx, pending.pane_id, pending.workspace_id, pending.agent_label, pending.known_agent, pending.kind, pending.state, ) else { continue; }; self.apply_agent_notification_delivery(&delivery); deliveries.push(delivery); } deliveries } fn handle_pane_died(&mut self, pane_id: PaneId) { self.pending_agent_notifications.remove(&pane_id); self.remove_plugin_pane_records([pane_id]); 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; }; if self .selection .as_ref() .is_some_and(|s| s.pane_id == pane_id) { self.selection = None; self.selection_autoscroll = None; } let pane_terminal_id = self.terminal_id_for_pane(ws_idx, pane_id); let workspace_terminal_ids = self.terminal_ids_for_workspace(ws_idx); self.pane_id_aliases.retain(|_, alias| *alias != pane_id); self.public_pane_id_aliases .retain(|_, alias| *alias != pane_id); let should_close_workspace = { let ws = &mut self.workspaces[ws_idx]; ws.remove_pane(pane_id) }; self.mark_session_dirty(); if should_close_workspace { let active_workspace_id = self .active .and_then(|idx| self.workspaces.get(idx)) .map(|ws| ws.id.clone()); let selected_workspace_id = self.workspaces.get(self.selected).map(|ws| ws.id.clone()); self.workspaces.remove(ws_idx); self.remove_unattached_terminal_ids(workspace_terminal_ids); if self.workspaces.is_empty() { self.active = None; self.selected = 0; if self.mode == Mode::Terminal { self.mode = Mode::Navigate; } } else { // Keep focus on the previously focused workspace if let Some(id) = active_workspace_id { if let Some(idx) = self.workspaces.iter().position(|ws| ws.id == id) { self.active = Some(idx); } } if let Some(active) = self.active { if active >= self.workspaces.len() { self.active = Some(self.workspaces.len() - 1); } } if let Some(id) = selected_workspace_id { if let Some(idx) = self.workspaces.iter().position(|ws| ws.id == id) { self.selected = idx; } } if self.selected >= self.workspaces.len() { self.selected = self.workspaces.len() - 1; } self.workspace_scroll = self .workspace_scroll .min(self.workspaces.len().saturating_sub(1)); self.ensure_workspace_visible(self.selected); } } else { self.remove_unattached_terminal_ids(pane_terminal_id); } } } // --------------------------------------------------------------------------- // 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(); state.toast_config.delay_seconds = 0; for name in names { 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; } state } fn mark_linked_worktree(state: &mut AppState, ws_idx: usize) { state.workspaces[ws_idx].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { key: "repo-key".into(), label: "herdr".into(), repo_root: "/repo/herdr".into(), checkout_path: format!("/repo/worktree-{ws_idx}").into(), is_linked_worktree: true, }); } fn mark_parent_worktree(state: &mut AppState, ws_idx: usize) { state.workspaces[ws_idx].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { key: "repo-key".into(), label: "herdr".into(), repo_root: "/repo/herdr".into(), checkout_path: "/repo/herdr".into(), is_linked_worktree: false, }); } #[test] fn notification_context_formats_resolved_workspace_label() { let state = app_with_workspaces(&["stale"]); let root = state.workspaces[0].tabs[0].root_pane; assert_eq!( notification_context(&state.workspaces[0], "__herdr_projects__", 0, root), "__herdr_projects__ · 1" ); } fn selected_word(row: &str, col: u16) -> Option { let (start, end) = word_bounds_at_column(row, col)?; Some(text_in_cell_range(row, start, end)) } fn selected_url<'a>(row: &'a str, click: &str) -> Option<&'a str> { url_at_column(row, col_of(row, click)) } fn text_in_cell_range(row: &str, start_col: u16, end_col: u16) -> String { text_cells(row) .into_iter() .filter(|cell| cell.start_col >= start_col && cell.end_col <= end_col) .map(|cell| cell.ch) .collect() } fn col_of(row: &str, needle: &str) -> u16 { let byte_idx = row .find(needle) .unwrap_or_else(|| panic!("{needle:?} not found in {row:?}")); let prefix = &row[..byte_idx]; prefix .chars() .map(|ch| u16::from(crate::ghostty::unicode_codepoint_width(ch as u32))) .sum() } fn assert_selects(row: &str, click: &str, expected: &str) { assert_eq!( selected_word(row, col_of(row, click)).as_deref(), Some(expected), "row={row:?}, click={click:?}" ); } fn assert_selects_nothing(row: &str, click: &str) { assert_eq!( selected_word(row, col_of(row, click)), None, "row={row:?}, click={click:?}" ); } #[test] fn double_click_word_bounds_cover_terminal_text() { let cases = [ ( "see https://example.com/a-b_c?q=x@y.", "example.com", "https://example.com/a-b_c?q=x@y", ), ( "open \"https://example.com/a,b;c?q=x\";", "example.com", "https://example.com/a,b;c?q=x", ), ( "see https://en.wikipedia.org/wiki/Foo_(bar_(baz)),", "wikipedia", "https://en.wikipedia.org/wiki/Foo_(bar_(baz))", ), ( "see https://example.com/a(b[c{d}e]f),", "example.com", "https://example.com/a(b[c{d}e]f)", ), ( "see (https://example.com/a(b(c)d)))", "example.com", "https://example.com/a(b(c)d)", ), ( "open /tmp/foo-bar/baz_qux/", "foo-bar", "/tmp/foo-bar/baz_qux/", ), ( "open ./src/app/actions.rs:795", "actions", "./src/app/actions.rs:795", ), ( "open ../herdr-worktrees/issue-1", "herdr", "../herdr-worktrees/issue-1", ), ( "edit src/app/actions.rs,then", "actions", "src/app/actions.rs", ), ( "cat \"/tmp/build output/log.txt\"", "output", "/tmp/build output/log.txt", ), ( "cat '/Users/me/Library/Application Support/app/config.json'", "Support", "/Users/me/Library/Application Support/app/config.json", ), ("echo 你好-world done", "好", "你好-world"), ("先跑 cargo test", "cargo", "cargo"), ( "export PATH=$HOME/.cargo/bin:$PATH", "$HOME", "PATH=$HOME/.cargo/bin:$PATH", ), ( "git checkout feature/foo-bar_baz", "foo", "feature/foo-bar_baz", ), ("refs #123 and @owner/name", "#123", "#123"), ("refs #123 and @owner/name", "owner", "@owner/name"), ("cargo test --package=herdr", "--package", "--package=herdr"), ( "cargo test app::actions::tests", "app::", "app::actions::tests", ), ( "image ghcr.io/org/app:latest", "ghcr", "ghcr.io/org/app:latest", ), ("ERROR [worker-1] request_id=abc-123", "worker", "worker-1"), ( "tmux|newhoo|fixhoo|newmoo|notification|window_bell|herdr", "newhoo", "newhoo", ), ( "render_status_line(app, area)", "render", "render_status_line", ), ("render_status_line(app, area)", "app", "app"), ("render_status_line(app, area)", "area", "area"), ("if !enabled {", "enabled", "enabled"), ("println!(\"hi\")", "println", "println"), ("( master)$", "master", "master"), ("regex foo$", "foo", "foo$"), ]; for (row, click, expected) in cases { assert_selects(row, click, expected); } let row = "echo 你好-world done"; assert_eq!( selected_word(row, col_of(row, "好") + 1).as_deref(), Some("你好-world") ); } #[test] fn double_click_word_bounds_ignore_delimiters() { for (row, click) in [ ( "tmux|newhoo|fixhoo|newmoo|notification|window_bell|herdr", "|", ), ("alpha,beta;gamma", ","), ("alpha,beta;gamma", ";"), ("render_status_line(app, area)", "("), ("render_status_line(app, area)", ")"), ("if !enabled {", "!"), ("if !enabled {", "{"), ("(done).", "("), ("(done).", "."), ] { assert_selects_nothing(row, click); } } #[test] fn url_at_column_returns_safe_visible_url_only() { assert_eq!( selected_url("see https://example.com/a(b)c.", "example"), Some("https://example.com/a(b)c") ); assert_eq!( selected_url("[docs](https://example.com/docs),", "example"), Some("https://example.com/docs") ); assert_eq!( selected_url("[docs](https://example.com/docs)", "docs"), None ); assert_eq!(selected_url("open file:///tmp/report", "file"), None); } #[test] fn navigator_rows_show_tab_nodes_only_for_multi_tab_workspaces() { let mut state = app_with_workspaces(&["single", "multi"]); state.workspaces[1].test_add_tab(Some("tests")); state.ensure_test_terminals(); state.open_navigator(); let rows = state.navigator_rows(); assert!(!rows.iter().any(|row| matches!( row.target, crate::app::state::NavigatorTarget::Tab { ws_idx: 0, .. } ))); assert!(rows.iter().any(|row| matches!( row.target, crate::app::state::NavigatorTarget::Tab { ws_idx: 1, tab_idx: 0 } ))); assert!(rows.iter().any(|row| matches!( row.target, crate::app::state::NavigatorTarget::Tab { ws_idx: 1, tab_idx: 1 } ))); } #[test] fn navigator_search_matches_named_tabs_in_single_tab_workspaces() { let mut state = app_with_workspaces(&["multi", "single"]); state.workspaces[0].tabs[0].custom_name = Some("Foo".into()); state.workspaces[0].test_add_tab(Some("Bar")); state.workspaces[1].tabs[0].custom_name = Some("Baz".into()); state.ensure_test_terminals(); state.open_navigator(); state.navigator.query = "foo".into(); assert!(state.navigator_rows().iter().any(|row| { row.matched && matches!( row.target, crate::app::state::NavigatorTarget::Tab { ws_idx: 0, tab_idx: 0 } ) })); state.navigator.query = "baz".into(); state.select_first_navigator_match_from(&crate::terminal::TerminalRuntimeRegistry::new()); let rows = state.navigator_rows(); assert!(rows .get(state.navigator.selected) .is_some_and(|row| matches!( row.target, crate::app::state::NavigatorTarget::Tab { ws_idx: 1, tab_idx: 0 } ))); assert!(!rows.iter().any(|row| matches!( row.target, crate::app::state::NavigatorTarget::Workspace { ws_idx: 0 } | crate::app::state::NavigatorTarget::Tab { ws_idx: 0, .. } | crate::app::state::NavigatorTarget::Pane { ws_idx: 0, .. } ))); assert!(state.accept_navigator_selection()); assert_eq!(state.active, Some(1)); assert_eq!(state.workspaces[1].active_tab_index(), 0); assert_eq!(state.mode, Mode::Terminal); } #[tokio::test] async fn navigator_rows_match_live_root_runtime_cwd_workspace_label() { let unique = format!( "herdr-navigator-runtime-cwd-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() ); let root = std::env::temp_dir().join(unique); let stale_cwd = root.join("issue-264-nix-support"); let live_cwd = root.join("herdr"); std::fs::create_dir_all(stale_cwd.join(".git")).unwrap(); std::fs::create_dir_all(live_cwd.join(".git")).unwrap(); let mut state = AppState::test_new(); let mut workspace = Workspace::test_new("stale-name"); workspace.custom_name = None; workspace.identity_cwd = stale_cwd.clone(); let pane = workspace.tabs[0].root_pane; state.workspaces = vec![workspace]; state.ensure_test_terminals(); let terminal_id = state.workspaces[0].terminal_id(pane).cloned().unwrap(); state.terminals.get_mut(&terminal_id).unwrap().cwd = stale_cwd; let (events, _) = tokio::sync::mpsc::channel(4); let runtime = crate::terminal::TerminalRuntime::spawn( pane, 24, 80, live_cwd.clone(), 0, crate::terminal_theme::TerminalTheme::default(), None, crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), &crate::pane::PaneLaunchEnv::default(), events, std::sync::Arc::new(tokio::sync::Notify::new()), std::sync::Arc::new(crate::render_signal::RenderSignal::new()), ) .unwrap(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); while runtime.cwd() != Some(live_cwd.clone()) && std::time::Instant::now() < deadline { tokio::time::sleep(std::time::Duration::from_millis(10)).await; } let mut runtime_registry = crate::terminal::TerminalRuntimeRegistry::new(); runtime_registry.insert(terminal_id, runtime); state.open_navigator_from(&runtime_registry); state.navigator.query = "herdr".into(); let rows = state.navigator_rows_from(&runtime_registry); for (_, runtime) in runtime_registry.drain() { runtime.shutdown(); } let _ = std::fs::remove_dir_all(root); // The workspace matched by its live cwd label; its subtree cascades in // as context. assert_eq!(rows.len(), 2); assert_eq!(rows[0].label, "herdr (1)"); assert!(rows[0].matched); assert!(!rows[1].matched); } #[test] fn navigator_rows_include_shell_and_agent_panes() { let mut state = app_with_workspaces(&["one"]); let shell = state.workspaces[0].tabs[0].root_pane; let agent = state.workspaces[0].test_split(Direction::Horizontal); state.ensure_test_terminals(); let agent_terminal_id = state.workspaces[0].terminal_id(agent).cloned().unwrap(); let terminal = state.terminals.get_mut(&agent_terminal_id).unwrap(); terminal.set_detected_state(Some(Agent::Claude), AgentState::Working); state.open_navigator(); let rows = state.navigator_rows(); assert!(rows.iter().any(|row| matches!( row.target, crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell ))); assert!(rows.iter().any(|row| matches!( row.target, crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == agent ) && row.meta.contains("claude"))); } #[test] fn opening_navigator_selects_current_pane_and_expands_attention_workspaces() { let mut state = app_with_workspaces(&["one", "two"]); let blocked = state.workspaces[1].tabs[0].root_pane; let blocked_terminal_id = state.workspaces[1].terminal_id(blocked).cloned().unwrap(); state .terminals .get_mut(&blocked_terminal_id) .unwrap() .set_detected_state(Some(Agent::Codex), AgentState::Blocked); state.open_navigator(); let selected = state.navigator_rows()[state.navigator.selected].clone(); assert!(selected.is_current); assert!(state .navigator .expanded_workspaces .contains(&state.workspaces[0].id)); assert!(state .navigator .expanded_workspaces .contains(&state.workspaces[1].id)); } #[test] fn accepting_navigator_pane_switches_workspace_tab_and_focus() { let mut state = app_with_workspaces(&["one", "two"]); let target = state.workspaces[1].tabs[0].root_pane; state.open_navigator(); state .navigator .expanded_workspaces .insert(state.workspaces[1].id.clone()); state.navigator.selected = state .navigator_rows() .iter() .position(|row| { matches!( row.target, crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == target ) }) .unwrap(); assert!(state.accept_navigator_selection()); assert_eq!(state.active, Some(1)); assert_eq!(state.workspaces[1].focused_pane_id(), Some(target)); assert_eq!(state.mode, Mode::Terminal); } #[test] fn navigator_idle_search_matches_idle_agents_not_plain_shells() { let mut state = app_with_workspaces(&["one"]); let shell = state.workspaces[0].tabs[0].root_pane; let agent = state.workspaces[0].test_split(Direction::Horizontal); state.ensure_test_terminals(); let agent_terminal_id = state.workspaces[0].terminal_id(agent).cloned().unwrap(); state .terminals .get_mut(&agent_terminal_id) .unwrap() .set_detected_state(Some(Agent::Claude), AgentState::Idle); state.open_navigator(); state.navigator.query = "idle".into(); let rows = state.navigator_rows(); assert!(rows.iter().any(|row| matches!( row.target, crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == agent ))); assert!(!rows.iter().any(|row| matches!( row.target, crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell ))); } #[test] fn navigator_search_only_matches_visible_row_text() { let mut state = app_with_workspaces(&["one"]); state.workspaces[0].identity_cwd = "/tmp/herdr-worktrees/issue-work".into(); state.open_navigator(); state.navigator.query = "work".into(); assert!(state.navigator_rows().is_empty()); } #[test] fn navigator_state_filter_is_separate_from_text_search() { let mut state = app_with_workspaces(&["one"]); let shell = state.workspaces[0].tabs[0].root_pane; let working = state.workspaces[0].test_split(Direction::Horizontal); state.ensure_test_terminals(); let shell_terminal_id = state.workspaces[0].terminal_id(shell).cloned().unwrap(); state .terminals .get_mut(&shell_terminal_id) .unwrap() .set_manual_label("wheel notes".into()); let working_terminal_id = state.workspaces[0].terminal_id(working).cloned().unwrap(); state .terminals .get_mut(&working_terminal_id) .unwrap() .set_detected_state(Some(Agent::Codex), AgentState::Working); state.open_navigator(); state.navigator.state_filter = Some(NavigatorStateFilter::Working); let state_rows = state.navigator_rows(); assert!(state_rows.iter().any(|row| matches!( row.target, crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == working ))); assert!(!state_rows.iter().any(|row| matches!( row.target, crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell ))); state.navigator.state_filter = None; state.navigator.query = "w".into(); let text_rows = state.navigator_rows(); assert!(text_rows.iter().any(|row| matches!( row.target, crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell ))); assert!( text_rows.iter().any(|row| matches!( row.target, crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == working )), "literal one-letter search may still match visible state text" ); } #[test] fn navigator_search_filters_panes_but_keeps_workspace_context() { let mut state = app_with_workspaces(&["one"]); let root = state.workspaces[0].tabs[0].root_pane; let terminal_id = state.workspaces[0].terminal_id(root).cloned().unwrap(); state .terminals .get_mut(&terminal_id) .unwrap() .set_manual_label("weekly review".into()); state.open_navigator(); state.navigator.query = "weekly".into(); let rows = state.navigator_rows(); assert!(rows.iter().any(|row| row.is_workspace)); assert!(rows .iter() .any(|row| !row.is_workspace && row.label.contains("weekly"))); } #[test] fn navigator_workspace_match_cascades_full_subtree() { let mut state = app_with_workspaces(&["one", "two"]); let root = state.workspaces[0].tabs[0].root_pane; let extra = state.workspaces[0].test_split(Direction::Horizontal); state.ensure_test_terminals(); for pane in [root, extra] { let terminal_id = state.workspaces[0].terminal_id(pane).cloned().unwrap(); state .terminals .get_mut(&terminal_id) .unwrap() .set_manual_label("unrelated".into()); } state.open_navigator(); state.navigator.query = "one".into(); let rows = state.navigator_rows(); // Both panes cascade in even though only the workspace label matched, // and only the workspace carries the matched flag. let pane_rows: Vec<_> = rows.iter().filter(|row| !row.is_workspace).collect(); assert_eq!(pane_rows.len(), 2); assert!(pane_rows.iter().all(|row| !row.matched)); assert!(rows.iter().any(|row| row.is_workspace && row.matched)); assert!(!rows.iter().any(|row| row.label.starts_with("two"))); } #[test] fn navigator_search_selects_first_self_match() { let mut state = app_with_workspaces(&["one", "two"]); let pane = state.workspaces[1].tabs[0].root_pane; state.ensure_test_terminals(); let terminal_id = state.workspaces[1].terminal_id(pane).cloned().unwrap(); state .terminals .get_mut(&terminal_id) .unwrap() .set_manual_label("pi ui build".into()); let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); state.open_navigator_from(&terminal_runtimes); state.navigator.query = "ui".into(); state.select_first_navigator_match_from(&terminal_runtimes); let rows = state.navigator_rows_from(&terminal_runtimes); let selected = &rows[state.navigator.selected]; assert!(matches!( selected.target, crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == pane )); } #[test] fn navigator_state_filter_selects_matching_pane_over_workspace() { let mut state = app_with_workspaces(&["one"]); let working = state.workspaces[0].test_split(Direction::Horizontal); state.ensure_test_terminals(); let terminal_id = state.workspaces[0].terminal_id(working).cloned().unwrap(); state .terminals .get_mut(&terminal_id) .unwrap() .set_detected_state(Some(Agent::Codex), AgentState::Working); let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); state.open_navigator_from(&terminal_runtimes); state.navigator.state_filter = Some(NavigatorStateFilter::Working); state.select_first_navigator_match_from(&terminal_runtimes); let rows = state.navigator_rows_from(&terminal_runtimes); let selected = &rows[state.navigator.selected]; assert!(matches!( selected.target, crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == working )); } #[test] fn apply_workspace_git_statuses_updates_matching_workspace() { let mut state = app_with_workspaces(&["one", "two"]); let first_id = state.workspaces[0].id.clone(); let first_cwd = state.workspaces[0].resolved_identity_cwd().unwrap(); let second_id = state.workspaces[1].id.clone(); let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); let changed = state.apply_workspace_git_statuses( &terminal_runtimes, vec![WorkspaceGitStatus { workspace_id: first_id, resolved_identity_cwd: first_cwd.clone(), status_cache_key: first_cwd, demand: crate::workspace::GitStatusRefreshDemand::ALL, auto_label: "one".into(), branch: Some("main".into()), ahead_behind: Some((2, 1)), space: None, }], ); assert!(changed); assert_eq!(state.workspaces[0].branch().as_deref(), Some("main")); assert_eq!(state.workspaces[0].git_ahead_behind(), Some((2, 1))); assert_eq!(state.workspaces[1].id, second_id); assert_eq!(state.workspaces[1].git_ahead_behind(), None); } #[test] fn apply_workspace_git_statuses_ignores_stale_cwd() { let mut state = app_with_workspaces(&["one"]); let workspace_id = state.workspaces[0].id.clone(); state.workspaces[0].cached_git_branch = Some("old".into()); state.workspaces[0].cached_git_ahead_behind = Some((1, 0)); let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); let changed = state.apply_workspace_git_statuses( &terminal_runtimes, vec![WorkspaceGitStatus { workspace_id, resolved_identity_cwd: std::path::PathBuf::from("/definitely/not/current"), status_cache_key: std::path::PathBuf::from("/definitely/not/current"), demand: crate::workspace::GitStatusRefreshDemand::ALL, auto_label: "stale".into(), branch: Some("main".into()), ahead_behind: Some((0, 1)), space: None, }], ); assert!(!changed); assert_eq!(state.workspaces[0].branch().as_deref(), Some("old")); assert_eq!(state.workspaces[0].git_ahead_behind(), Some((1, 0))); } #[test] fn apply_workspace_git_statuses_ignores_unrequested_branch_changes() { let mut state = app_with_workspaces(&["one"]); let workspace_id = state.workspaces[0].id.clone(); let cwd = state.workspaces[0].resolved_identity_cwd().unwrap(); state.workspaces[0].cached_auto_label = "one".into(); state.workspaces[0].cached_git_branch = Some("old".into()); let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); let changed = state.apply_workspace_git_statuses( &terminal_runtimes, vec![WorkspaceGitStatus { workspace_id, resolved_identity_cwd: cwd.clone(), status_cache_key: cwd, demand: crate::workspace::GitStatusRefreshDemand { branch: false, ahead_behind: true, }, auto_label: "one".into(), branch: Some("new".into()), ahead_behind: None, space: None, }], ); assert!(!changed); assert_eq!(state.workspaces[0].branch().as_deref(), Some("old")); } #[test] fn apply_workspace_git_statuses_clears_missing_git_status() { let mut state = app_with_workspaces(&["one"]); let workspace_id = state.workspaces[0].id.clone(); let cwd = state.workspaces[0].resolved_identity_cwd().unwrap(); state.workspaces[0].cached_git_branch = Some("main".into()); state.workspaces[0].cached_git_ahead_behind = Some((1, 2)); let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); let changed = state.apply_workspace_git_statuses( &terminal_runtimes, vec![WorkspaceGitStatus { workspace_id, resolved_identity_cwd: cwd.clone(), status_cache_key: cwd, demand: crate::workspace::GitStatusRefreshDemand::ALL, auto_label: "one".into(), branch: None, ahead_behind: None, space: None, }], ); assert!(changed); assert_eq!(state.workspaces[0].branch(), None); assert_eq!(state.workspaces[0].git_ahead_behind(), None); } #[test] fn apply_workspace_git_statuses_does_not_change_worktree_membership() { let mut state = app_with_workspaces(&["one"]); mark_linked_worktree(&mut state, 0); let workspace_id = state.workspaces[0].id.clone(); let cwd = state.workspaces[0].resolved_identity_cwd().unwrap(); let membership = state.workspaces[0].worktree_space().cloned(); let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); let changed = state.apply_workspace_git_statuses( &terminal_runtimes, vec![WorkspaceGitStatus { workspace_id, resolved_identity_cwd: cwd.clone(), status_cache_key: cwd, demand: crate::workspace::GitStatusRefreshDemand::ALL, auto_label: "other".into(), branch: Some("scratch".into()), ahead_behind: None, space: Some(crate::workspace::GitSpaceMetadata { key: "other-repo-key".into(), checkout_key: "/other/checkout".into(), repo_name: "other".into(), repo_root: "/other/repo".into(), is_linked_worktree: false, }), }], ); assert!(changed); assert_eq!(state.workspaces[0].worktree_space().cloned(), membership); } fn mark_agent(state: &mut AppState, ws_idx: usize, tab_idx: usize, pane_id: PaneId) { set_agent_state(state, ws_idx, tab_idx, pane_id, AgentState::Idle); } fn set_agent_state( state: &mut AppState, ws_idx: usize, tab_idx: usize, pane_id: PaneId, agent_state: AgentState, ) { state.ensure_test_terminals(); let terminal_id = state.workspaces[ws_idx].tabs[tab_idx] .panes .get(&pane_id) .unwrap() .attached_terminal_id .clone(); if let Some(terminal) = state.terminals.get_mut(&terminal_id) { terminal.set_detected_state(Some(Agent::Pi), agent_state); } } fn transition_agent_state(state: &mut AppState, pane_id: PaneId, agent_state: AgentState) { state .update_terminal_state(pane_id, |terminal| { Some(terminal.set_detected_state_with_screen_signals_at( Some(Agent::Pi), agent_state, matches!(agent_state, AgentState::Blocked), false, false, false, std::time::Instant::now(), )) }) .expect("agent state transition should update pane state"); } #[test] fn next_agent_cycles_agent_panel_entries() { let mut first = Workspace::test_new("one"); let first_root = first.tabs[0].root_pane; let first_second = first.test_split(Direction::Horizontal); first.tabs[0].layout.focus_pane(first_root); let second = Workspace::test_new("two"); let second_root = second.tabs[0].root_pane; 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; mark_agent(&mut state, 0, 0, first_root); mark_agent(&mut state, 0, 0, first_second); mark_agent(&mut state, 1, 0, second_root); state.next_agent(); assert_eq!(state.active, Some(0)); assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_second)); state.next_agent(); assert_eq!(state.active, Some(1)); assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root)); state.previous_agent(); assert_eq!(state.active, Some(0)); assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_second)); state.assert_invariants_for_test(); } #[test] fn focus_agent_entry_uses_agent_panel_order() { let mut first = Workspace::test_new("one"); let first_root = first.tabs[0].root_pane; let first_second = first.test_split(Direction::Horizontal); first.tabs[0].layout.focus_pane(first_root); let second = Workspace::test_new("two"); let second_root = second.tabs[0].root_pane; let mut state = AppState::test_new(); state.workspaces = vec![first, second]; state.active = Some(0); state.selected = 0; state.mode = Mode::Terminal; mark_agent(&mut state, 0, 0, first_root); mark_agent(&mut state, 0, 0, first_second); mark_agent(&mut state, 1, 0, second_root); assert!(state.focus_agent_entry(2)); assert_eq!(state.active, Some(1)); assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root)); state.assert_invariants_for_test(); } #[test] fn focus_agent_entry_succeeds_for_already_focused_agent() { let mut state = app_with_workspaces(&["one"]); let root = state.workspaces[0].tabs[0].root_pane; mark_agent(&mut state, 0, 0, root); assert!(state.focus_agent_entry(0)); assert_eq!(state.active, Some(0)); assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); state.assert_invariants_for_test(); } #[test] fn next_agent_cycles_priority_sorted_agent_panel_entries() { let mut first = Workspace::test_new("one"); let first_root = first.tabs[0].root_pane; let first_second = first.test_split(Direction::Horizontal); first.tabs[0].layout.focus_pane(first_root); let second = Workspace::test_new("two"); let second_root = second.tabs[0].root_pane; 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; state.agent_panel_sort = crate::app::state::AgentPanelSort::Priority; set_agent_state(&mut state, 0, 0, first_root, AgentState::Idle); set_agent_state(&mut state, 0, 0, first_second, AgentState::Working); set_agent_state(&mut state, 1, 0, second_root, AgentState::Blocked); state.next_agent(); assert_eq!(state.active, Some(1)); assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root)); state.assert_invariants_for_test(); } #[test] fn priority_sort_keeps_recently_changed_idle_agent_above_older_idle_agent() { let mut workspace = Workspace::test_new("one"); let first = workspace.tabs[0].root_pane; let second = workspace.test_split(Direction::Horizontal); workspace.tabs[0].layout.focus_pane(first); 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; state.agent_panel_sort = crate::app::state::AgentPanelSort::Priority; transition_agent_state(&mut state, first, AgentState::Idle); transition_agent_state(&mut state, second, AgentState::Working); assert_eq!(crate::ui::agent_panel_entries(&state)[0].pane_id, second); transition_agent_state(&mut state, second, AgentState::Idle); assert_eq!(crate::ui::agent_panel_entries(&state)[0].pane_id, second); state.assert_invariants_for_test(); } #[test] fn previous_agent_keeps_wrapped_target_visible_in_agent_panel() { let mut workspace = Workspace::test_new("one"); let root = workspace.tabs[0].root_pane; for idx in 1..8 { workspace.test_add_tab(Some(&format!("tab-{idx}"))); } 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; for tab_idx in 0..state.workspaces[0].tabs.len() { let pane_id = state.workspaces[0].tabs[tab_idx].root_pane; mark_agent(&mut state, 0, tab_idx, pane_id); } state.workspaces[0].tabs[0].layout.focus_pane(root); crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 14)); state.previous_agent(); let last_idx = state.workspaces[0].tabs.len() - 1; assert_eq!(state.workspaces[0].active_tab, last_idx); assert!(state.agent_panel_scroll > 0); state.assert_invariants_for_test(); } #[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 last_pane_toggles_to_previous_focus_in_active_tab() { let mut state = app_with_workspaces(&["test"]); let root = state.workspaces[0].tabs[0].root_pane; let right = state.workspaces[0].test_split(Direction::Horizontal); state.focus_pane_in_workspace(0, root); state.focus_pane_in_workspace(0, right); state.last_pane(); assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); state.last_pane(); assert_eq!(state.workspaces[0].focused_pane_id(), Some(right)); } #[test] fn removing_background_pane_preserves_last_pane_history() { let mut state = app_with_workspaces(&["test"]); let root = state.workspaces[0].tabs[0].root_pane; let right = state.workspaces[0].test_split(Direction::Horizontal); let background = state.workspaces[0].test_split(Direction::Horizontal); state.focus_pane_in_workspace(0, root); state.focus_pane_in_workspace(0, right); state.workspaces[0].remove_pane(background); state.last_pane(); assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); } #[test] fn last_pane_jumps_across_workspaces_and_tabs() { let mut state = app_with_workspaces(&["one", "two"]); let first_root = state.workspaces[0].tabs[0].root_pane; let second_tab = state.workspaces[1].test_add_tab(Some("logs")); let second_tab_root = state.workspaces[1].tabs[second_tab].root_pane; state.focus_pane_in_workspace(0, first_root); state.focus_pane_in_workspace(1, second_tab_root); state.last_pane(); assert_eq!(state.active, Some(0)); assert_eq!(state.workspaces[0].active_tab, 0); assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root)); state.last_pane(); assert_eq!(state.active, Some(1)); assert_eq!(state.workspaces[1].active_tab, second_tab); assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_tab_root)); } #[test] fn last_pane_tracks_tab_and_workspace_switches() { let mut state = app_with_workspaces(&["one", "two"]); let first_root = state.workspaces[0].tabs[0].root_pane; let first_second_tab = state.workspaces[0].test_add_tab(Some("logs")); let first_second_root = state.workspaces[0].tabs[first_second_tab].root_pane; let second_root = state.workspaces[1].tabs[0].root_pane; state.switch_tab(first_second_tab); state.last_pane(); assert_eq!(state.active, Some(0)); assert_eq!(state.workspaces[0].active_tab, 0); assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root)); state.last_pane(); assert_eq!(state.active, Some(0)); assert_eq!(state.workspaces[0].active_tab, first_second_tab); assert_eq!( state.workspaces[0].focused_pane_id(), Some(first_second_root) ); state.switch_workspace(1); state.last_pane(); assert_eq!(state.active, Some(0)); assert_eq!(state.workspaces[0].active_tab, first_second_tab); assert_eq!( state.workspaces[0].focused_pane_id(), Some(first_second_root) ); state.last_pane(); assert_eq!(state.active, Some(1)); assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root)); } #[test] fn last_pane_tracks_cross_workspace_tab_selection() { let mut state = app_with_workspaces(&["one", "two"]); let first_root = state.workspaces[0].tabs[0].root_pane; let second_first_root = state.workspaces[1].tabs[0].root_pane; let second_tab = state.workspaces[1].test_add_tab(Some("logs")); let second_tab_root = state.workspaces[1].tabs[second_tab].root_pane; state.switch_workspace_tab(1, second_tab); state.last_pane(); assert_eq!(state.active, Some(0)); assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root)); state.last_pane(); assert_eq!(state.active, Some(1)); assert_eq!(state.workspaces[1].active_tab, second_tab); assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_tab_root)); assert_ne!(second_first_root, second_tab_root); } #[test] fn switch_workspace_keeps_selected_visible_in_scrolled_sidebar() { let mut state = app_with_workspaces(&["a", "b", "c", "d", "e", "f", "g", "h"]); crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 14)); state.switch_workspace(7); crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 14)); assert!(state .view .workspace_card_areas .iter() .any(|card| card.ws_idx == 7)); } #[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 move_workspace_block_collects_non_contiguous_members() { let mut state = app_with_workspaces(&["child-one", "normal", "parent", "child-two", "tail"]); let parent_id = state.workspaces[2].id.clone(); let child_one_id = state.workspaces[0].id.clone(); let child_two_id = state.workspaces[3].id.clone(); let tail_id = state.workspaces[4].id.clone(); state.active = Some(0); state.selected = 4; assert!(state.move_workspace_block( &[parent_id, child_one_id.clone(), child_two_id], Some(&tail_id), )); let names = state .workspaces .iter() .map(|workspace| workspace.display_name()) .collect::>(); assert_eq!( names, ["normal", "parent", "child-one", "child-two", "tail"] ); assert_eq!(state.workspaces[state.active.unwrap()].id, child_one_id); assert_eq!(state.workspaces[state.selected].id, tail_id); } #[test] fn move_workspace_block_rejects_invalid_and_noop_orders() { let mut state = app_with_workspaces(&["a", "b", "c"]); let ids = state .workspaces .iter() .map(|workspace| workspace.id.clone()) .collect::>(); assert!(!state.move_workspace_block(&[], None)); assert!(!state.move_workspace_block(&[ids[0].clone(), ids[0].clone()], None)); assert!(!state.move_workspace_block(&["missing".into()], None)); assert!(!state.move_workspace_block(&[ids[0].clone()], Some(&ids[0]))); assert!(!state.move_workspace_block(&[ids[0].clone()], Some(&ids[1]))); assert_eq!( state .workspaces .iter() .map(|workspace| workspace.display_name()) .collect::>(), ["a", "b", "c"] ); } #[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_parent_worktree_workspace_closes_group() { let mut state = app_with_workspaces(&["main", "issue", "notes"]); state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { key: "repo-key".into(), label: "herdr".into(), repo_root: "/repo/herdr".into(), checkout_path: "/repo/herdr".into(), is_linked_worktree: false, }); state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { key: "repo-key".into(), label: "herdr".into(), repo_root: "/repo/herdr".into(), checkout_path: "/repo/herdr-issue".into(), is_linked_worktree: true, }); state.selected = 0; state.active = Some(0); state.close_selected_workspace(); assert_eq!(state.workspaces.len(), 1); assert_eq!(state.workspaces[0].display_name(), "notes"); assert_eq!(state.active, Some(0)); assert_eq!(state.selected, 0); } #[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 close_non_focused_workspace_keeps_focus() { let mut state = app_with_workspaces(&["a", "b", "c"]); state.selected = 1; state.active = Some(0); state.close_selected_workspace(); assert_eq!(state.workspaces.len(), 2); assert_eq!(state.workspaces[0].display_name(), "a"); assert_eq!(state.workspaces[1].display_name(), "c"); assert_eq!(state.selected, 0); assert_eq!(state.active, Some(0)); state.assert_invariants_for_test(); } #[test] fn pane_died_self_closing_earlier_workspace_keeps_focus() { let names = (0..20).map(|i| format!("ws{i:02}")).collect::>(); let name_refs = names.iter().map(String::as_str).collect::>(); let mut state = app_with_workspaces(&name_refs); state.selected = 1; state.active = Some(1); // Constrain the sidebar viewport so the focused workspace starts // off-screen: 20 workspaces cannot all fit in 12 rows, so index 1 is // hidden at maximum scroll regardless of individual card height. state.view.sidebar_rect = ratatui::layout::Rect::new(0, 0, 30, 12); state.workspace_scroll = crate::ui::normalized_workspace_scroll(&state, state.view.sidebar_rect, usize::MAX / 2); let cards = crate::ui::compute_workspace_card_areas(&state, state.view.sidebar_rect); assert!(cards.iter().all(|card| card.ws_idx != 1)); let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); state.handle_pane_died(pane_id); assert_eq!(state.workspaces.len(), 19); assert_eq!(state.workspaces[0].display_name(), "ws01"); assert_eq!(state.selected, 0); assert_eq!(state.active, Some(0)); let cards = crate::ui::compute_workspace_card_areas(&state, state.view.sidebar_rect); assert!(cards.iter().any(|card| card.ws_idx == 0)); state.assert_invariants_for_test(); } #[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")); state.assert_invariants_for_test(); } #[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); state.assert_invariants_for_test(); } #[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.ensure_test_terminals(); state.handle_pane_died(second_id); assert_eq!(state.workspaces.len(), 1); assert_eq!(state.workspaces[0].panes.len(), 1); state.assert_invariants_for_test(); } #[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); state.assert_invariants_for_test(); } #[test] fn pane_died_unrelated_pane_preserves_selection() { // Two workspaces; user is selecting text in workspace 0. // A pane in workspace 1 dies — selection must be preserved. let mut state = app_with_workspaces(&["active", "bg"]); let active_pane = *state.workspaces[0].panes.keys().next().unwrap(); let bg_pane = *state.workspaces[1].panes.keys().next().unwrap(); state.selection = Some(crate::selection::Selection::anchor(active_pane, 0, 0, None)); state.selection_autoscroll = Some(crate::app::state::SelectionAutoscroll { direction: crate::app::state::SelectionAutoscrollDirection::Down, last_mouse_screen_col: 0, last_mouse_screen_row: 23, inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), }); state.handle_pane_died(bg_pane); assert!(state.selection.is_some()); assert!(state.selection_autoscroll.is_some()); state.assert_invariants_for_test(); } #[test] fn pane_died_same_pane_clears_selection() { let mut state = app_with_workspaces(&["test"]); let first_id = state.workspaces[0].tabs[0].root_pane; let second_id = state.workspaces[0].test_split(Direction::Horizontal); state.ensure_test_terminals(); state.selection = Some(crate::selection::Selection::anchor(second_id, 0, 0, None)); state.selection_autoscroll = Some(crate::app::state::SelectionAutoscroll { direction: crate::app::state::SelectionAutoscrollDirection::Down, last_mouse_screen_col: 0, last_mouse_screen_row: 23, inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), }); state.handle_pane_died(second_id); // first_id still alive, workspace stays, but selection was on the dying pane assert!(state.selection.is_none()); assert!(state.selection_autoscroll.is_none()); assert_eq!(state.workspaces[0].panes.len(), 1); assert_eq!(state.workspaces[0].panes.keys().next().unwrap(), &first_id); state.assert_invariants_for_test(); } #[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, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); 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] fn state_changed_idle_in_background_marks_unseen() { let mut state = app_with_workspaces(&["active", "background"]); state.toast_config.delivery = crate::config::ToastDelivery::Herdr; state.active = Some(0); let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); // First set it to Working let bg_terminal_id = state.workspaces[1] .panes .get(&bg_pane_id) .unwrap() .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 { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Idle, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); let pane = state.workspaces[1].panes.get(&bg_pane_id).unwrap(); assert!(!pane.seen); assert!(matches!( state.toast.as_ref().map(|toast| toast.kind), Some(ToastKind::Finished) )); } #[test] fn active_tab_completion_marks_pane_seen() { let mut state = app_with_workspaces(&["active"]); state.active = Some(0); state.outer_terminal_focus = Some(true); let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); 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, agent: Some(Agent::Pi), state: AgentState::Idle, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); 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!(pane.seen); } #[test] fn initial_idle_in_background_stays_seen() { let mut state = app_with_workspaces(&["active", "background"]); state.active = Some(0); 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::Idle, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); let pane = state.workspaces[1].panes.get(&bg_pane_id).unwrap(); assert!(pane.seen); } #[test] fn idle_after_known_unknown_agent_in_background_marks_done() { let mut state = app_with_workspaces(&["active", "background"]); state.toast_config.delivery = crate::config::ToastDelivery::Herdr; state.active = Some(0); 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::Unknown, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); state.handle_app_event(AppEvent::StateChanged { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Idle, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); 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 ); assert_eq!( notification_sound_for_state_change(false, AgentState::Unknown, 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.delivery = crate::config::ToastDelivery::Herdr; 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, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); 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 delayed_background_waiting_schedules_before_toast() { let mut state = app_with_workspaces(&["active", "background"]); state.active = Some(0); state.toast_config.delivery = crate::config::ToastDelivery::Herdr; state.toast_config.delay_seconds = 1; 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, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); assert!(state.toast.is_none()); assert!(state.pending_agent_notifications.contains_key(&bg_pane_id)); let deadline = state.next_pending_agent_notification_deadline().unwrap(); let deliveries = state.drain_due_agent_notifications(deadline); assert_eq!(deliveries.len(), 1); 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"); assert!(state.pending_agent_notifications.is_empty()); } #[test] fn delayed_background_waiting_cancels_when_agent_resumes_working() { let mut state = app_with_workspaces(&["active", "background"]); state.active = Some(0); state.toast_config.delivery = crate::config::ToastDelivery::Herdr; state.toast_config.delay_seconds = 1; 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, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); let deadline = state.next_pending_agent_notification_deadline().unwrap(); state.handle_app_event(AppEvent::StateChanged { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Working, visible_blocker: false, visible_working: true, process_exited: false, observed_at: std::time::Instant::now(), }); assert!(state.pending_agent_notifications.is_empty()); assert!(state.drain_due_agent_notifications(deadline).is_empty()); assert!(state.toast.is_none()); } #[test] fn delayed_background_waiting_is_suppressed_if_pane_becomes_active() { let mut state = app_with_workspaces(&["active", "background"]); state.active = Some(0); state.toast_config.delivery = crate::config::ToastDelivery::Herdr; state.toast_config.delay_seconds = 1; 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, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); let deadline = state.next_pending_agent_notification_deadline().unwrap(); state.active = Some(1); assert!(state.drain_due_agent_notifications(deadline).is_empty()); assert!(state.toast.is_none()); } #[test] fn delayed_active_tab_unfocused_keeps_client_notification_available() { let mut state = app_with_workspaces(&["active"]); state.active = Some(0); state.outer_terminal_focus = Some(false); state.toast_config.delivery = crate::config::ToastDelivery::System; state.toast_config.delay_seconds = 1; 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, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); let deadline = state.next_pending_agent_notification_deadline().unwrap(); let deliveries = state.drain_due_agent_notifications(deadline); assert_eq!(deliveries.len(), 1); assert!(deliveries[0].toast.is_none()); assert!(deliveries[0].client_notification.is_some()); assert!(state.toast.is_none()); } #[test] fn delayed_background_waiting_is_cleared_when_pane_dies() { let mut state = app_with_workspaces(&["active", "background"]); state.active = Some(0); state.toast_config.delivery = crate::config::ToastDelivery::Herdr; state.toast_config.delay_seconds = 1; 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, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); let deadline = state.next_pending_agent_notification_deadline().unwrap(); state.handle_app_event(AppEvent::PaneDied { pane_id: bg_pane_id, }); assert!(state.pending_agent_notifications.is_empty()); assert!(state.drain_due_agent_notifications(deadline).is_empty()); assert!(state.toast.is_none()); } #[test] fn hook_reported_unknown_agent_sets_toast_title_from_label() { let mut state = app_with_workspaces(&["active", "background"]); state.active = Some(0); state.toast_config.delivery = crate::config::ToastDelivery::Herdr; let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); state.handle_app_event(AppEvent::HookStateReported { pane_id: bg_pane_id, source: "custom:hermes".into(), agent_label: "hermes".into(), state: AgentState::Blocked, message: None, seq: None, session_ref: None, }); let toast = state.toast.as_ref().unwrap(); assert_eq!(toast.kind, ToastKind::NeedsAttention); assert_eq!(toast.title, "hermes needs attention"); assert_eq!(toast.context, "background · 2"); } #[test] fn visible_blocker_overrides_hook_working_and_notifies() { let mut state = app_with_workspaces(&["active", "background"]); state.active = Some(0); state.toast_config.delivery = crate::config::ToastDelivery::Herdr; let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); let bg_terminal_id = state.workspaces[1] .panes .get(&bg_pane_id) .unwrap() .attached_terminal_id .clone(); state.handle_app_event(AppEvent::StateChanged { pane_id: bg_pane_id, agent: Some(Agent::Codex), state: AgentState::Idle, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); state.handle_app_event(AppEvent::HookStateReported { pane_id: bg_pane_id, source: "herdr:codex".into(), agent_label: "codex".into(), state: AgentState::Working, message: None, seq: Some(1), session_ref: None, }); state.handle_app_event(AppEvent::StateChanged { pane_id: bg_pane_id, agent: Some(Agent::Codex), state: AgentState::Blocked, visible_blocker: true, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); let terminal = state.terminals.get(&bg_terminal_id).unwrap(); assert_eq!(terminal.state, AgentState::Blocked); let toast = state.toast.as_ref().unwrap(); assert_eq!(toast.kind, ToastKind::NeedsAttention); assert_eq!(toast.title, "codex needs attention"); } #[test] fn reserved_native_state_report_does_not_override_screen_state() { let mut state = app_with_workspaces(&["active"]); state.active = Some(0); state.toast_config.delivery = crate::config::ToastDelivery::Herdr; let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); let terminal_id = state.workspaces[0] .panes .get(&pane_id) .unwrap() .attached_terminal_id .clone(); state.handle_app_event(AppEvent::StateChanged { pane_id, agent: Some(Agent::Claude), state: AgentState::Working, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); state.handle_app_event(AppEvent::HookStateReported { pane_id, source: "herdr:claude".into(), agent_label: "claude".into(), state: AgentState::Blocked, message: None, seq: Some(1), session_ref: crate::agent_resume::AgentSessionRef::id("claude-session"), }); let terminal = state.terminals.get(&terminal_id).unwrap(); assert_eq!(terminal.state, AgentState::Working); assert!(terminal.hook_authority.is_none()); assert!(terminal.persisted_agent_session.is_some()); state.handle_app_event(AppEvent::StateChanged { pane_id, agent: Some(Agent::Claude), state: AgentState::Idle, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); let terminal = state.terminals.get(&terminal_id).unwrap(); assert_eq!(terminal.state, AgentState::Idle); assert!(state.toast.is_none()); } #[test] fn official_release_preserves_process_owned_agent_identity() { let mut state = app_with_workspaces(&["active"]); let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); let terminal_id = state.workspaces[0] .panes .get(&pane_id) .unwrap() .attached_terminal_id .clone(); state.handle_app_event(AppEvent::StateChanged { pane_id, agent: Some(Agent::Pi), state: AgentState::Working, visible_blocker: false, visible_working: true, process_exited: false, observed_at: std::time::Instant::now(), }); let terminal = state.terminals.get_mut(&terminal_id).unwrap(); terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { source: "herdr:pi".into(), agent: "pi".into(), session_ref: crate::agent_resume::AgentSessionRef::path( std::env::current_dir() .unwrap() .join("release-session.jsonl") .display() .to_string(), ) .unwrap(), }); terminal.set_hook_authority( "herdr:pi".into(), "pi".into(), AgentState::Working, None, Some(1), ); terminal.set_agent_name("reviewer".into()); state.session_dirty = false; let updates = state.handle_app_event(AppEvent::HookAgentReleased { pane_id, source: "herdr:pi".into(), agent_label: "pi".into(), known_agent: Some(Agent::Pi), seq: Some(2), }); assert!(updates.is_empty()); let terminal = &state.terminals[&terminal_id]; assert_eq!(terminal.state, AgentState::Working); assert_eq!(terminal.detected_agent, Some(Agent::Pi)); assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); assert!(terminal.full_lifecycle_hook_authority_active()); assert!(!state.session_dirty); } #[test] fn devin_state_report_refreshes_session_without_overriding_screen_state() { let mut state = app_with_workspaces(&["active"]); let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); let terminal_id = state.workspaces[0] .panes .get(&pane_id) .unwrap() .attached_terminal_id .clone(); state.handle_app_event(AppEvent::StateChanged { pane_id, agent: Some(Agent::Devin), state: AgentState::Idle, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); state.handle_app_event(AppEvent::HookStateReported { pane_id, source: "herdr:devin".into(), agent_label: "devin".into(), state: AgentState::Working, message: None, seq: Some(1), session_ref: crate::agent_resume::AgentSessionRef::id("devin-session"), }); let terminal = state.terminals.get(&terminal_id).unwrap(); assert_eq!(terminal.state, AgentState::Idle); assert!(terminal.hook_authority.is_none()); assert!(terminal.persisted_agent_session.is_some()); } #[test] fn hidden_custom_session_ref_only_update_marks_session_dirty_without_visible_update() { let mut state = app_with_workspaces(&["active"]); let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); let test_dir = std::env::current_dir().unwrap(); let first_session = test_dir.join("one.jsonl").display().to_string(); let second_session = test_dir.join("two.jsonl").display().to_string(); let first_updates = state.handle_app_event(AppEvent::HookStateReported { pane_id, source: "custom:pi".into(), agent_label: "pi".into(), state: AgentState::Working, message: None, seq: Some(20), session_ref: crate::agent_resume::AgentSessionRef::path(first_session), }); assert_eq!(first_updates.len(), 1); state.session_dirty = false; let second_updates = state.handle_app_event(AppEvent::HookStateReported { pane_id, source: "custom:pi".into(), agent_label: "pi".into(), state: AgentState::Working, message: None, seq: Some(21), session_ref: crate::agent_resume::AgentSessionRef::path(second_session), }); assert!(second_updates.is_empty()); assert!(state.session_dirty); } #[test] fn custom_release_clears_report_owned_agent() { let mut state = app_with_workspaces(&["active"]); let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); let terminal_id = state.workspaces[0] .pane_state(pane_id) .unwrap() .attached_terminal_id .clone(); state .terminals .get_mut(&terminal_id) .unwrap() .set_hook_authority( "custom:agent".into(), "custom-agent".into(), AgentState::Working, None, Some(1), ); state.handle_app_event(AppEvent::HookAgentReleased { pane_id, source: "custom:agent".into(), agent_label: "custom-agent".into(), known_agent: None, seq: Some(2), }); let terminal = &state.terminals[&terminal_id]; assert!(terminal.hook_authority.is_none()); assert_eq!(terminal.state, AgentState::Unknown); } #[test] fn terminal_cwd_report_updates_terminal_cwd_and_marks_session_dirty() { let mut state = app_with_workspaces(&["active"]); let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); let terminal_id = state.workspaces[0] .pane_state(pane_id) .unwrap() .attached_terminal_id .clone(); let cwd = std::env::temp_dir().join(format!("herdr-cwd-report-test-{}", std::process::id())); std::fs::create_dir_all(&cwd).unwrap(); state.session_dirty = false; let updates = state.handle_app_event(AppEvent::TerminalCwdReported { pane_id, cwd: cwd.clone(), }); assert!(updates.is_empty()); assert_eq!(state.terminals.get(&terminal_id).unwrap().cwd, cwd); assert!(state.session_dirty); let _ = std::fs::remove_dir_all(cwd); } #[test] fn background_idle_sets_finished_toast() { let mut state = app_with_workspaces(&["active", "background"]); state.active = Some(0); state.toast_config.delivery = crate::config::ToastDelivery::Herdr; let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); let bg_terminal_id = state.workspaces[1] .panes .get(&bg_pane_id) .unwrap() .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, agent: Some(Agent::Droid), state: AgentState::Idle, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); 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"); let target = toast.target.as_ref().expect("toast target"); assert_eq!(&target.workspace_id, &state.workspaces[1].id); assert_eq!(target.pane_id, bg_pane_id); } #[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.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 { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Blocked, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); 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.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 { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Blocked, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); 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.delivery = crate::config::ToastDelivery::Herdr; 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, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); assert!(state.toast.is_none()); } #[test] fn active_workspace_active_tab_keeps_herdr_toast_suppressed_when_outer_terminal_is_unfocused() { let mut state = app_with_workspaces(&["active"]); state.active = Some(0); state.outer_terminal_focus = Some(false); state.toast_config.delivery = crate::config::ToastDelivery::Herdr; 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, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); assert!(state.toast.is_none()); } #[test] fn active_tab_suppression_preserves_unknown_focus_behavior() { assert!(active_tab_suppresses_notifications(true, None)); assert!(active_tab_suppresses_notifications(true, Some(true))); assert!(!active_tab_suppresses_notifications(true, Some(false))); assert!(!active_tab_suppresses_notifications(false, None)); } #[test] fn update_ready_sets_manual_update_toast() { let mut state = AppState::test_new(); state.toast_config.delivery = crate::config::ToastDelivery::Herdr; let updates = state.handle_app_event(AppEvent::UpdateReady { version: "0.5.0".into(), install_command: "herdr update".into(), }); assert!(updates.is_empty()); assert_eq!(state.update_available.as_deref(), Some("0.5.0")); assert!(state.latest_release_notes_available); assert!(state.update_dismissed); let toast = state.toast.as_ref().expect("update toast"); assert_eq!(toast.kind, ToastKind::UpdateInstalled); assert_eq!(toast.title, "v0.5.0 available"); assert_eq!( toast.context, "detach, run `herdr update`, then follow its restart guidance" ); } #[test] fn update_ready_uses_event_install_command_in_toast() { let mut state = AppState::test_new(); state.toast_config.delivery = crate::config::ToastDelivery::Herdr; state.handle_app_event(AppEvent::UpdateReady { version: "0.5.0".into(), install_command: "brew update && brew upgrade herdr".into(), }); assert_eq!( state.update_install_command, "brew update && brew upgrade herdr" ); let toast = state.toast.as_ref().expect("update toast"); assert_eq!( toast.context, "detach, run `brew update && brew upgrade herdr`, then restart this Herdr session when ready" ); } #[test] fn agent_detection_manifest_update_event_updates_status_and_toast() { let mut state = AppState::test_new(); state.toast_config.delivery = crate::config::ToastDelivery::Herdr; let status = crate::detect::manifest_update::ManifestUpdateStatus { last_result: Some("checked".to_string()), ..Default::default() }; let updates = state.handle_app_event(AppEvent::AgentDetectionManifestsUpdated { updated: vec![crate::detect::manifest_update::ManifestUpdateCommit { agent: Agent::Codex, version: crate::detect::manifest_update::ManifestVersion::parse("2026.06.10.1") .unwrap(), }], status, }); assert!(updates.is_empty()); assert_eq!( state.agent_manifest_update_status.last_result.as_deref(), Some("checked") ); let toast = state.toast.as_ref().expect("manifest update toast"); assert_eq!(toast.kind, ToastKind::UpdateInstalled); assert_eq!(toast.title, "Agent detection rules updated"); assert_eq!(toast.context, "codex 2026.06.10.1"); } #[test] fn toggle_zoom_works() { let mut state = app_with_workspaces(&["test"]); state.workspaces[0].test_split(Direction::Horizontal); assert!(!state.workspaces[0].zoomed); state.toggle_zoom(); assert!(state.workspaces[0].zoomed); state.toggle_zoom(); assert!(!state.workspaces[0].zoomed); } #[test] fn toggle_zoom_single_pane_noop() { let mut state = app_with_workspaces(&["test"]); state.toggle_zoom(); assert!(!state.workspaces[0].zoomed); } #[test] fn navigate_pane_changes_focus_while_zoomed() { let mut state = app_with_workspaces(&["test"]); let root = state.workspaces[0].tabs[0].root_pane; let right = state.workspaces[0].test_split(Direction::Horizontal); state.workspaces[0].layout.focus_pane(root); state.workspaces[0].zoomed = true; crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); assert_eq!(state.view.pane_infos.len(), 1); assert_eq!(state.view.pane_infos[0].id, root); state.navigate_pane(NavDirection::Right); crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); assert!(state.workspaces[0].zoomed); assert_eq!(state.workspaces[0].focused_pane_id(), Some(right)); assert_eq!(state.view.pane_infos.len(), 1); assert_eq!(state.view.pane_infos[0].id, right); assert!(state.view.pane_infos[0].inner_rect.x > state.view.pane_infos[0].rect.x); } #[test] fn swap_pane_direction_preserves_focus_and_swaps_layout_cells() { let mut state = app_with_workspaces(&["test"]); let root = state.workspaces[0].tabs[0].root_pane; let right = state.workspaces[0].test_split(Direction::Horizontal); state.workspaces[0].layout.focus_pane(root); crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); let before_root_rect = state .view .pane_infos .iter() .find(|info| info.id == root) .unwrap() .rect; let before_right_rect = state .view .pane_infos .iter() .find(|info| info.id == right) .unwrap() .rect; assert!(state.swap_pane(NavDirection::Right)); crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); assert_eq!( state .view .pane_infos .iter() .find(|info| info.id == root) .unwrap() .rect, before_right_rect ); assert_eq!( state .view .pane_infos .iter() .find(|info| info.id == right) .unwrap() .rect, before_root_rect ); } #[test] fn swap_pane_direction_stays_zoomed_and_mutates_hidden_layout() { let mut state = app_with_workspaces(&["test"]); let root = state.workspaces[0].tabs[0].root_pane; let right = state.workspaces[0].test_split(Direction::Horizontal); state.workspaces[0].layout.focus_pane(root); state.workspaces[0].zoomed = true; crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); assert!(state.swap_pane(NavDirection::Right)); crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); assert!(state.workspaces[0].zoomed); assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); assert_eq!(state.view.pane_infos.len(), 1); assert_eq!(state.view.pane_infos[0].id, root); state.workspaces[0].zoomed = false; crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); let root_rect = state .view .pane_infos .iter() .find(|info| info.id == root) .unwrap() .rect; let right_rect = state .view .pane_infos .iter() .find(|info| info.id == right) .unwrap() .rect; assert!(root_rect.x > right_rect.x); } #[test] fn close_pane_removes_from_workspace() { let mut state = app_with_workspaces(&["test"]); let closed = state.workspaces[0].test_split(Direction::Horizontal); state.ensure_test_terminals(); assert_eq!(state.workspaces[0].panes.len(), 2); state.plugin_panes.insert( closed, crate::app::state::PluginPaneRecord { plugin_id: "example.pane".into(), entrypoint: "board".into(), }, ); state.close_pane(); assert_eq!(state.workspaces[0].panes.len(), 1); assert!(!state.plugin_panes.contains_key(&closed)); state.assert_invariants_for_test(); } #[test] fn pane_process_exit_publish_marks_agent_idle_before_pane_removal() { let mut state = app_with_workspaces(&["active", "background"]); state.toast_config.delivery = crate::config::ToastDelivery::Herdr; state.active = Some(1); state.ensure_test_terminals(); let pane_id = state.workspaces[0].tabs[0].root_pane; let terminal_id = state.terminal_id_for_pane(0, pane_id).unwrap(); state .terminals .get_mut(&terminal_id) .unwrap() .set_detected_state(Some(Agent::Pi), AgentState::Working); assert_eq!( state.terminals.get(&terminal_id).unwrap().state, AgentState::Working ); let update = state .publish_pane_process_exit_if_agent(pane_id) .expect("process exit update"); assert!(!state.pane_is_in_active_tab(update.ws_idx, pane_id)); assert_eq!(update.previous_state, AgentState::Working); assert_eq!(update.state, AgentState::Idle); assert_eq!(update.agent_label.as_deref(), Some("pi")); assert_eq!(update.known_agent, Some(Agent::Pi)); assert!(update.agent_released); assert_eq!( update.agent_release_status, Some(crate::api::schema::AgentStatus::Done) ); assert!(matches!( state.toast.as_ref().map(|toast| toast.kind), Some(ToastKind::Finished) )); } #[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)); state.assert_invariants_for_test(); } #[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.plugin_panes.insert( pane_id, crate::app::state::PluginPaneRecord { plugin_id: "example.pane".into(), entrypoint: "board".into(), }, ); state.close_tab(); assert!(!state.terminals.contains_key(&terminal_id)); assert!(!state.plugin_panes.contains_key(&pane_id)); state.assert_invariants_for_test(); } #[test] fn close_workspace_removes_unattached_terminal_states() { let mut state = app_with_workspaces(&["one", "two"]); let pane_id = state.workspaces[0].tabs[0].root_pane; let terminal_id = state.terminal_id_for_pane(0, pane_id).unwrap(); state.plugin_panes.insert( pane_id, crate::app::state::PluginPaneRecord { plugin_id: "example.pane".into(), entrypoint: "board".into(), }, ); state.close_selected_workspace(); assert!(!state.terminals.contains_key(&terminal_id)); assert!(!state.plugin_panes.contains_key(&pane_id)); state.assert_invariants_for_test(); } #[test] fn close_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)); state.assert_invariants_for_test(); } #[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)); state.assert_invariants_for_test(); } #[test] fn close_pane_last_pane_in_parent_worktree_group_prompts() { let mut state = app_with_workspaces(&["parent", "child"]); mark_parent_worktree(&mut state, 0); mark_linked_worktree(&mut state, 1); state.active = Some(0); state.selected = 1; let deferred = state.close_pane(); assert!(deferred); assert_eq!(state.mode, Mode::ConfirmClose); assert_eq!(state.selected, 0); assert_eq!(state.workspaces.len(), 2); } #[test] fn close_tab_in_linked_worktree_closes_workspace_only() { let mut state = app_with_workspaces(&["selected", "active"]); mark_linked_worktree(&mut state, 1); state.active = Some(1); state.selected = 0; state.close_tab(); assert_eq!(state.request_remove_linked_worktree, None); assert_eq!(state.workspaces.len(), 1); assert_eq!(state.workspaces[0].display_name(), "selected"); } #[test] fn close_tab_last_tab_in_parent_worktree_group_prompts() { let mut state = app_with_workspaces(&["parent", "child"]); mark_parent_worktree(&mut state, 0); mark_linked_worktree(&mut state, 1); state.active = Some(0); state.selected = 1; let deferred = state.close_tab(); assert!(deferred); assert_eq!(state.mode, Mode::ConfirmClose); assert_eq!(state.selected, 0); assert_eq!(state.workspaces.len(), 2); } #[test] fn close_pane_last_pane_in_linked_worktree_closes_workspace_only() { let mut state = app_with_workspaces(&["selected", "active"]); mark_linked_worktree(&mut state, 1); state.active = Some(1); state.selected = 0; state.close_pane(); assert_eq!(state.request_remove_linked_worktree, None); assert_eq!(state.workspaces.len(), 1); assert_eq!(state.workspaces[0].display_name(), "selected"); } #[test] fn close_pane_last_pane_in_parent_worktree_group_closes_when_confirmation_disabled() { let mut state = app_with_workspaces(&["parent", "child", "notes"]); mark_parent_worktree(&mut state, 0); mark_linked_worktree(&mut state, 1); state.confirm_close = false; state.active = Some(0); state.selected = 0; let deferred = state.close_pane(); assert!(!deferred); assert_eq!(state.workspaces.len(), 1); assert_eq!(state.workspaces[0].display_name(), "notes"); } }