use std::time::{Duration, Instant}; mod agent_view; mod agents; mod env; mod integrations; mod layouts; mod pane_graphics; mod panes; pub(crate) mod plugins; pub(super) mod responses; mod session; mod tabs; mod workspaces; mod worktrees; use super::{api_helpers::pane_agent_status, App, Mode, OverlayPaneState, ToastKind}; use crate::events::AppEvent; const API_NOTIFICATION_RATE_LIMIT: Duration = Duration::from_secs(1); #[cfg(windows)] const WINDOWS_POWERSHELL_AGENT_EXIT_RESPAWN_GRACE: Duration = Duration::from_secs(2); #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RuntimeExitAction { RespawnShell, ClosePane, } impl App { pub(crate) fn handle_internal_event_with_render_impact(&mut self, ev: AppEvent) -> bool { match ev { AppEvent::GitStatusRefreshed { results, cache_updates, } => self.handle_git_status_refreshed(results, cache_updates), AppEvent::TabBarCommandFinished { generation, segment_index, result, } => self.handle_tab_bar_command_finished(generation, segment_index, result), ev @ AppEvent::TerminalBell { .. } => { self.handle_internal_event(ev); false } ev => { self.handle_internal_event(ev); true } } } fn handle_git_status_refreshed( &mut self, results: Vec, cache_updates: Vec<(std::path::PathBuf, crate::workspace::GitStatusCacheEntry)>, ) -> bool { self.git_refresh_in_flight = false; for (key, entry) in cache_updates { self.git_status_cache.insert(key, entry); } if self.git_refresh_due_after_in_flight { self.mark_git_status_refresh_due(Instant::now()); self.git_refresh_due_after_in_flight = false; } else { self.last_git_remote_status_refresh = Instant::now(); } let changed = self .state .apply_workspace_git_statuses(&self.terminal_runtimes, results); if changed { self.render_dirty.request_generic(); self.render_notify.notify_one(); } changed } pub(crate) fn handle_internal_event(&mut self, ev: AppEvent) { let _ = self.handle_internal_event_with_pane_updates(ev); } pub(crate) fn handle_internal_event_with_pane_updates( &mut self, ev: AppEvent, ) -> Vec { let mut worktree_restore_failed = false; let ev = match ev { AppEvent::WorktreeRuntimeRestoreFailed { pane_id, operation_id, } => { if !self.claim_worktree_runtime_restore_failure(pane_id, operation_id) { return Vec::new(); } worktree_restore_failed = true; AppEvent::PaneDied { pane_id, exit_reason: crate::platform::ChildExitReason::Exited, } } ev => ev, }; if matches!( &ev, AppEvent::TerminalBell { .. } | AppEvent::ClipboardWrite { .. } ) { return Vec::new(); } if let AppEvent::GitStatusRefreshed { results, cache_updates, } = ev { self.handle_git_status_refreshed(results, cache_updates); return Vec::new(); } if let AppEvent::TabBarCommandFinished { generation, segment_index, result, } = ev { let _ = self.handle_tab_bar_command_finished(generation, segment_index, result); return Vec::new(); } if let AppEvent::PluginCommandFinished { log_id, finished_unix_ms, exit_code, stdout, stderr, error, } = ev { self.state.plugin_commands_in_flight = self.state.plugin_commands_in_flight.saturating_sub(1); if let Some(log) = self .state .plugin_command_logs .iter_mut() .find(|log| log.log_id == log_id) { log.finished_unix_ms = Some(finished_unix_ms); log.exit_code = exit_code; log.stdout = Some(stdout); log.stderr = Some(stderr); log.error = error; log.status = if log.error.is_none() && log.exit_code == Some(0) { crate::api::schema::PluginCommandStatus::Succeeded } else { crate::api::schema::PluginCommandStatus::Failed }; } return Vec::new(); } if let AppEvent::WorktreeAddFinished(result) = ev { self.handle_api_worktree_add_finished(*result); return Vec::new(); } if let AppEvent::WorktreeRemoveFinished(result) = ev { return self.handle_api_worktree_remove_finished(*result); } let mut worktree_restore_updates = Vec::new(); if let AppEvent::PaneDied { pane_id, .. } = &ev { if self .state .popup_pane .as_ref() .is_some_and(|popup| popup.pane_id == *pane_id) { self.close_popup_pane(); return Vec::new(); } if worktree_restore_failed { worktree_restore_updates .extend(self.publish_worktree_runtime_agent_release(*pane_id)); } else { let expected_exit = self .pending_worktree_remove_runtime_exits .get_mut(pane_id) .map(|remaining| { *remaining -= 1; *remaining == 0 }); if let Some(remove_entry) = expected_exit { let restore_failed = if remove_entry { self.pending_worktree_remove_runtime_exits.remove(pane_id); let restore_requested = self .pending_worktree_remove_runtime_restores .remove(pane_id) .is_some(); if restore_requested { worktree_restore_updates .extend(self.publish_worktree_runtime_agent_release(*pane_id)); } restore_requested && !self.respawn_shell_for_launch_pane(*pane_id, false) } else { false }; if !restore_failed { return worktree_restore_updates; } } let previous_toast = self.state.toast.clone(); if let Some(update) = self .state .publish_pane_process_exit_if_agent(*pane_id, false) { self.sync_full_lifecycle_authority_detection_pauses(); self.refresh_new_herdr_toast_context_for_update(&update, &previous_toast); self.emit_pane_state_update(&update); } if self.runtime_exit_action(*pane_id) == RuntimeExitAction::RespawnShell && self.respawn_shell_for_launch_pane(*pane_id, true) { self.overlay_panes.remove(pane_id); self.render_dirty.request_generic(); self.render_notify.notify_one(); return worktree_restore_updates; } } } let checkpointed_pane_exit = matches!( &ev, AppEvent::PaneDied { pane_id, exit_reason, } if exit_reason.requires_session_checkpoint() && self.find_pane(*pane_id).is_some() && !self.overlay_panes.contains_key(pane_id) ); if checkpointed_pane_exit { self.checkpoint_session_before_pane_exit(); } let overlay_state = if let AppEvent::PaneDied { pane_id, .. } = &ev { self.overlay_panes.remove(pane_id).map(|overlay| { let was_overlay_active = self.state .is_active_pane(overlay.ws_idx, overlay.tab_idx, *pane_id); let tab_before_exit = self .state .workspaces .get(overlay.ws_idx) .and_then(|ws| ws.tabs.get(overlay.tab_idx)); let was_overlay_focused_in_tab = tab_before_exit.is_some_and(|tab| tab.layout.focused() == *pane_id); let tab_zoomed_before_exit = tab_before_exit.map(|tab| tab.zoomed); ( overlay, was_overlay_active, was_overlay_focused_in_tab, tab_zoomed_before_exit, ) }) } else { None }; if let AppEvent::PaneDied { pane_id, .. } = &ev { if let Some((ws_idx, _)) = self.find_pane(*pane_id) { if let Some(public_pane_id) = self.public_pane_id(ws_idx, *pane_id) { self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::PaneExited, data: crate::api::schema::EventData::PaneExited { pane_id: public_pane_id, workspace_id: self.public_workspace_id(ws_idx), }, }); } } } let pane_exit_layout_target = if let AppEvent::PaneDied { pane_id, .. } = &ev { self.find_pane(*pane_id).and_then(|(ws_idx, _)| { self.layout_update_target_after_pane_removal(ws_idx, *pane_id) }) } else { None }; let released_agent = if let AppEvent::HookAgentReleased { pane_id, known_agent, .. } = &ev { known_agent.map(|agent| (*pane_id, agent)) } else { None }; let update_ready = if let AppEvent::UpdateReady { version, install_command, } = &ev { Some((version.clone(), install_command.clone())) } else { None }; let manifest_update_agents = if let AppEvent::AgentDetectionManifestsUpdated { activated, .. } = &ev { Some(activated.clone()) } else { None }; let terminal_cwd_reported = matches!(ev, AppEvent::TerminalCwdReported { .. }); let previous_toast = self.state.toast.clone(); let mut pane_updates = self.state.handle_app_event(ev); if update_ready.is_some() { self.state.latest_release_notes = crate::release_notes::load_latest(); } if checkpointed_pane_exit { self.finish_checkpointed_pane_exit(); } if let Some(agents) = manifest_update_agents { self.reset_agent_detection_for_agents(&agents); } if let Some((pane_id, agent)) = released_agent { if pane_updates.iter().any(|update| update.pane_id == pane_id) { if let Some((ws_idx, _)) = self.find_pane(pane_id) { if let Some(runtime) = self.state.runtime_for_pane_in_workspace( &self.terminal_runtimes, ws_idx, pane_id, ) { runtime.begin_graceful_release(agent); } } } } self.sync_full_lifecycle_authority_detection_pauses(); if terminal_cwd_reported { self.request_git_identity_refresh(Instant::now()); self.render_dirty.request_generic(); self.render_notify.notify_one(); } for update in &pane_updates { self.refresh_new_herdr_toast_context_for_update(update, &previous_toast); self.emit_pane_state_update(update); } self.sync_agent_metadata_deadline(); if let Some(( overlay, was_overlay_active, was_overlay_focused_in_tab, tab_zoomed_before_exit, )) = overlay_state { self.restore_overlay_after_exit( overlay, was_overlay_active, was_overlay_focused_in_tab, tab_zoomed_before_exit, ); } if let Some((ws_idx, tab_idx)) = pane_exit_layout_target { self.emit_layout_updated_event(ws_idx, tab_idx); } self.sync_toast_deadline(previous_toast); self.shutdown_detached_terminal_runtimes(); pane_updates.extend(worktree_restore_updates); pane_updates } fn reset_agent_detection_for_agents(&self, agents: &[crate::detect::Agent]) { if agents.is_empty() { return; } for (terminal_id, terminal) in &self.state.terminals { let Some(agent) = terminal.effective_known_agent().or(terminal.detected_agent) else { continue; }; if !agents.contains(&agent) { continue; } if let Some(runtime) = self.terminal_runtimes.get(terminal_id) { runtime.reset_agent_detection(); } } } fn reset_all_agent_detection_runtimes(&self) { for runtime in self.terminal_runtimes.values() { runtime.reset_agent_detection(); } } pub(crate) fn refresh_new_herdr_toast_context_for_update( &mut self, update: &crate::app::actions::PaneStateUpdate, previous_toast: &Option, ) { if !matches!( self.state.toast_config.delivery, crate::config::ToastDelivery::Herdr ) || self.state.toast == *previous_toast { return; } let Some(target) = self .state .toast .as_ref() .and_then(|toast| toast.target.as_ref()) else { return; }; if target.pane_id != update.pane_id { return; } let Some(ws) = self.state.workspaces.get(update.ws_idx) else { return; }; if ws.id != target.workspace_id { return; } let workspace_label = ws.display_name_from(&self.state.terminals, &self.terminal_runtimes); let context = crate::app::actions::notification_context( ws, &workspace_label, update.ws_idx, update.pane_id, ); if let Some(toast) = self.state.toast.as_mut() { toast.context = context; } } fn sync_full_lifecycle_authority_detection_pauses(&self) { for workspace in &self.state.workspaces { for tab in &workspace.tabs { for pane in tab.panes.values() { let Some(terminal) = self.state.terminals.get(&pane.attached_terminal_id) else { continue; }; let Some(runtime) = self.terminal_runtimes.get(&pane.attached_terminal_id) else { continue; }; runtime.set_full_lifecycle_authority_active( terminal.full_lifecycle_hook_authority_active(), ); } } } } fn restore_overlay_after_exit( &mut self, overlay: OverlayPaneState, was_overlay_active: bool, was_overlay_focused_in_tab: bool, tab_zoomed_before_exit: Option, ) { for temp_file in &overlay.temp_files { let _ = std::fs::remove_file(temp_file); } let Some(ws) = self.state.workspaces.get_mut(overlay.ws_idx) else { return; }; if overlay.tab_idx >= ws.tabs.len() { return; } if !was_overlay_focused_in_tab { if let Some(tab_zoomed_before_exit) = tab_zoomed_before_exit { ws.tabs[overlay.tab_idx].zoomed = tab_zoomed_before_exit; } return; } if was_overlay_active { ws.active_tab = overlay.tab_idx; } let tab = &mut ws.tabs[overlay.tab_idx]; if tab.panes.contains_key(&overlay.previous_focus) { tab.layout.focus_pane(overlay.previous_focus); } tab.zoomed = overlay.previous_zoomed; if was_overlay_active && self.state.active == Some(overlay.ws_idx) { self.state.mode = Mode::Terminal; } } fn runtime_exit_action(&self, pane_id: crate::layout::PaneId) -> RuntimeExitAction { let Some((_, pane_state)) = self.find_pane(pane_id) else { return RuntimeExitAction::ClosePane; }; let Some(terminal) = self.state.terminals.get(&pane_state.attached_terminal_id) else { return RuntimeExitAction::ClosePane; }; if terminal.respawn_shell_on_exit || self.should_respawn_shell_after_agent_exit(terminal) { RuntimeExitAction::RespawnShell } else { RuntimeExitAction::ClosePane } } fn should_respawn_shell_after_agent_exit( &self, terminal: &crate::terminal::TerminalState, ) -> bool { #[cfg(not(windows))] { let _ = terminal; false } #[cfg(windows)] { if !terminal.agent_process_exited_within( Instant::now(), WINDOWS_POWERSHELL_AGENT_EXIT_RESPAWN_GRACE, ) { return false; } crate::pane::uses_windows_powershell_pane_shell(crate::pane::PaneShellConfig::new( &self.state.default_shell, self.state.shell_mode, )) } } fn respawn_shell_for_launch_pane( &mut self, pane_id: crate::layout::PaneId, focus_pane: bool, ) -> bool { let Some((ws_idx, pane_state)) = self.find_pane(pane_id) else { return false; }; let terminal_id = pane_state.attached_terminal_id.clone(); let Some(terminal) = self.state.terminals.get(&terminal_id) else { return false; }; let cwd = terminal.cwd.clone(); let (rows, cols) = self .terminal_runtimes .get(&terminal_id) .map(|runtime| runtime.current_size()) .unwrap_or_else(|| self.state.estimate_pane_size()); let Some(launch_env) = self.pane_launch_env(ws_idx, pane_id, Vec::new()) else { return false; }; let runtime = match crate::terminal::TerminalRuntime::spawn( pane_id, rows, cols, cwd, self.state.pane_scrollback_limit_bytes, self.state.host_terminal_theme, self.state.host_terminal_appearance, crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode), &launch_env, self.event_tx.clone(), self.render_notify.clone(), self.render_dirty.clone(), ) { Ok(runtime) => runtime, Err(err) => { tracing::warn!( pane = pane_id.raw(), terminal = %terminal_id, err = %err, "failed to respawn shell after launch command exited" ); return false; } }; self.terminal_runtimes.insert(terminal_id.clone(), runtime); if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) { terminal.clear_agent_runtime_identity_after_respawn(); } if focus_pane { self.state.focus_pane_in_workspace(ws_idx, pane_id); } self.schedule_session_save(); true } pub(crate) fn claim_worktree_runtime_restore_failure( &mut self, pane_id: crate::layout::PaneId, operation_id: u64, ) -> bool { if self.pending_worktree_remove_runtime_restores.get(&pane_id) != Some(&operation_id) { return false; } self.pending_worktree_remove_runtime_restores .remove(&pane_id); self.pending_worktree_remove_runtime_exits.remove(&pane_id); true } pub(crate) fn publish_worktree_runtime_agent_release( &mut self, pane_id: crate::layout::PaneId, ) -> Option { self.state.pending_agent_notifications.remove(&pane_id); let previous_toast = self.state.toast.clone(); let update = self .state .publish_pane_process_exit_if_agent(pane_id, true)?; self.sync_full_lifecycle_authority_detection_pauses(); self.refresh_new_herdr_toast_context_for_update(&update, &previous_toast); self.emit_pane_state_update(&update); Some(update) } fn queue_worktree_runtime_restore_failed( &self, pane_id: crate::layout::PaneId, operation_id: u64, ) { let event_tx = self.event_tx.clone(); tokio::spawn(async move { let _ = event_tx .send(AppEvent::WorktreeRuntimeRestoreFailed { pane_id, operation_id, }) .await; }); } pub(crate) fn emit_pane_state_update(&mut self, update: &crate::app::actions::PaneStateUpdate) { let Some(pane_id) = self.public_pane_id(update.ws_idx, update.pane_id) else { return; }; let workspace_id = self.public_workspace_id(update.ws_idx); if update.agent_name_changed { self.emit_pane_updated(update.ws_idx, update.pane_id); } if update.previous_agent_label != update.agent_label || update.agent_released { self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::PaneAgentDetected, data: crate::api::schema::EventData::PaneAgentDetected { pane_id: pane_id.clone(), workspace_id: workspace_id.clone(), agent: update.agent_label.clone(), released: update.agent_released, final_status: update.agent_release_status, }, }); } let previous_agent_status = pane_agent_status(update.previous_state, update.previous_seen); let agent_status = self .state .workspaces .get(update.ws_idx) .and_then(|ws| ws.pane_state(update.pane_id)) .map(|pane| pane_agent_status(update.state, pane.seen)) .unwrap_or_else(|| pane_agent_status(update.state, update.seen)); if previous_agent_status != agent_status || update.previous_presentation != update.presentation { let presentation = update.presentation.clone(); self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::PaneAgentStatusChanged, data: crate::api::schema::EventData::PaneAgentStatusChanged { pane_id, workspace_id, agent_status, agent: update.agent_label.clone(), title: presentation.title, display_agent: presentation.display_agent, state_labels: presentation.state_labels, }, }); } } pub(crate) fn sync_toast_deadline( &mut self, previous_toast: Option, ) { if self.state.toast != previous_toast { self.toast_deadline = self.state.toast.as_ref().map(|toast| { let duration = match toast.kind { ToastKind::NeedsAttention => Duration::from_secs(8), ToastKind::Finished => Duration::from_secs(5), ToastKind::UpdateInstalled => Duration::from_secs(3), }; Instant::now() + duration }); } } pub(crate) fn refresh_agent_notification_delivery_contexts( &mut self, deliveries: &mut [crate::app::state::AgentNotificationDelivery], ) { for delivery in deliveries { let Some(ws_idx) = self .state .workspaces .iter() .position(|ws| ws.id == delivery.workspace_id) else { continue; }; let ws = &self.state.workspaces[ws_idx]; let workspace_label = ws.display_name_from(&self.state.terminals, &self.terminal_runtimes); let context = crate::app::actions::notification_context( ws, &workspace_label, ws_idx, delivery.pane_id, ); if let Some(toast) = delivery.toast.as_mut() { toast.context = context.clone(); } if let Some(toast) = delivery.client_notification.as_mut() { toast.context = context.clone(); } if let Some(toast) = self.state.toast.as_mut() { if toast.target.as_ref().is_some_and(|target| { target.workspace_id == delivery.workspace_id && target.pane_id == delivery.pane_id }) { toast.context = context; } } } } pub(super) fn emit_event(&mut self, event: crate::api::schema::EventEnvelope) { self.run_plugin_event_hooks(&event); self.event_hub.push(event); } pub(crate) fn emit_pane_updated(&mut self, ws_idx: usize, pane_id: crate::layout::PaneId) { if let Some(pane) = self.pane_info(ws_idx, pane_id) { self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::PaneUpdated, data: crate::api::schema::EventData::PaneUpdated { pane }, }); } } pub(crate) fn emit_workspace_token_updated(&mut self, ws_idx: usize) { // Token updates bypass plugin hooks so a hook cannot refresh its own // token and recursively trigger workspace.updated. self.event_hub.push(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::WorkspaceMetadataUpdated, data: crate::api::schema::EventData::WorkspaceMetadataUpdated { workspace: self.workspace_info(ws_idx), }, }); } pub(crate) fn sync_focus_events(&mut self) { self.sync_focus_events_with_outer_event(None); } pub(crate) fn accept_current_focus_without_events(&mut self) { self.last_focus = self.state.active.and_then(|idx| { self.state .workspaces .get(idx) .and_then(|workspace| workspace.focused_pane_id().map(|pane_id| (idx, pane_id))) }); } pub(crate) fn accept_current_focus_with_api_events(&mut self) { let current_focus = self.state.active.and_then(|idx| { self.state .workspaces .get(idx) .and_then(|workspace| workspace.focused_pane_id().map(|pane_id| (idx, pane_id))) }); if current_focus == self.last_focus { return; } self.last_focus = current_focus; if let Some((ws_idx, pane_id)) = current_focus { self.emit_focus_api_events(ws_idx, pane_id); } } pub(crate) fn emit_focus_api_events(&mut self, ws_idx: usize, pane_id: crate::layout::PaneId) { self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::WorkspaceFocused, data: crate::api::schema::EventData::WorkspaceFocused { workspace_id: self.public_workspace_id(ws_idx), }, }); if let Some(tab_id) = self.public_tab_id(ws_idx, self.state.workspaces[ws_idx].active_tab) { self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::TabFocused, data: crate::api::schema::EventData::TabFocused { tab_id, workspace_id: self.public_workspace_id(ws_idx), }, }); } if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::PaneFocused, data: crate::api::schema::EventData::PaneFocused { pane_id: public_pane_id, workspace_id: self.public_workspace_id(ws_idx), }, }); } } fn sync_focus_events_with_outer_event( &mut self, outer_event: Option, ) { let current_focus = self.state.active.and_then(|idx| { self.state .workspaces .get(idx) .and_then(|ws| ws.focused_pane_id().map(|pane_id| (idx, pane_id))) }); if current_focus == self.last_focus { if let (Some((ws_idx, pane_id)), Some(event)) = (current_focus, outer_event) { self.send_pane_focus_event(ws_idx, pane_id, event); } return; } if let Some((ws_idx, pane_id)) = self.last_focus { self.send_pane_focus_event(ws_idx, pane_id, crate::ghostty::FocusEvent::Lost); } if let Some((ws_idx, pane_id)) = current_focus { let event = outer_event.unwrap_or_else(|| { if self.state.outer_terminal_focus == Some(false) { crate::ghostty::FocusEvent::Lost } else { crate::ghostty::FocusEvent::Gained } }); self.send_pane_focus_event(ws_idx, pane_id, event); self.emit_focus_api_events(ws_idx, pane_id); } self.last_focus = current_focus; } pub(crate) fn send_pane_focus_event( &self, ws_idx: usize, pane_id: crate::layout::PaneId, event: crate::ghostty::FocusEvent, ) { let Some(runtime) = self.state.workspaces.get(ws_idx).and_then(|_| { self.state .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) }) else { return; }; runtime.try_send_focus_event(event); } #[cfg(test)] pub(crate) fn handle_api_request(&mut self, request: crate::api::schema::Request) -> String { self.drain_all_internal_events(); self.handle_api_request_after_internal_events_drained(request) } pub(crate) fn handle_api_request_after_internal_events_drained( &mut self, request: crate::api::schema::Request, ) -> String { self.sync_pending_terminal_titles(); use crate::api::schema::{ ErrorBody, ErrorResponse, Method, ResponseResult, SuccessResponse, }; let response = match request.method { Method::ServerStop(_) => { self.state.should_quit = true; SuccessResponse { id: request.id, result: ResponseResult::Ok {}, } } Method::ServerLiveHandoff(_) => { let response = ErrorResponse { id: request.id, error: ErrorBody { code: "unsupported_in_app_mode".into(), message: "live handoff is only supported by the headless server".into(), }, }; return serde_json::to_string(&response).unwrap_or_else(|_| "{}".to_string()); } Method::ServerReloadConfig(_) => { let report = self.reload_config(); SuccessResponse { id: request.id, result: ResponseResult::ConfigReload { status: report.status, diagnostics: report.diagnostics, }, } } Method::ServerAgentManifests(_) => { self.state.refresh_agent_manifest_summaries(); let update_status = crate::detect::manifest_update::load_status(); SuccessResponse { id: request.id, result: ResponseResult::AgentManifestStatus { last_check_unix: update_status.last_check_unix, last_result: update_status.last_result.clone(), manifests: self .state .agent_manifest_summaries .clone() .into_iter() .map(|summary| agent_manifest_info(summary, &update_status)) .collect(), }, } } Method::ServerReloadAgentManifests(_) => { let summaries = crate::detect::manifest::reload_manifests(); self.state.agent_manifest_summaries = summaries.clone(); let update_status = crate::detect::manifest_update::load_status(); self.reset_all_agent_detection_runtimes(); SuccessResponse { id: request.id, result: ResponseResult::AgentManifestReload { manifests: summaries .into_iter() .map(|summary| agent_manifest_info(summary, &update_status)) .collect(), }, } } Method::NotificationShow(params) => { return self.handle_notification_show(request.id, params); } Method::ReleaseNotesDismiss(params) => { let Some(notes) = self.state.latest_release_notes.as_ref() else { return responses::encode_error( request.id, "stale_release_notes", "the release notes are no longer current", ); }; if notes.version != params.version { return responses::encode_error( request.id, "stale_release_notes", "the release notes are no longer current", ); } let preview = notes.preview; self.mark_release_notes_seen(preview); return responses::encode_success(request.id, ResponseResult::Ok {}); } Method::ProductAnnouncementDismiss(params) => { let matches_current = self.state .product_announcement .as_ref() .is_some_and(|announcement| { announcement.version == params.version && announcement.id == params.id }); if !matches_current { return responses::encode_error( request.id, "stale_announcement", "the product announcement is no longer current", ); } self.dismiss_product_announcement(); return responses::encode_success(request.id, ResponseResult::Ok {}); } Method::CommandInvoke(params) => { return self.handle_command_invoke(request.id, params); } Method::ClientWindowTitleSet(_) | Method::ClientWindowTitleClear(_) => { return responses::encode_success( request.id, ResponseResult::ClientWindowTitle { changed: false, reason: crate::api::schema::ClientWindowTitleReason::NoForegroundClient, }, ); } Method::SessionSnapshot(_) => return self.handle_session_snapshot(request.id), Method::WorkspaceList(_) => return self.handle_workspace_list(request.id), Method::WorkspaceGet(target) => return self.handle_workspace_get(request.id, target), Method::WorkspaceCreate(params) => { return self.handle_workspace_create(request.id, params); } Method::WorkspaceFocus(target) => { return self.handle_workspace_focus(request.id, target); } Method::WorkspaceRename(params) => { return self.handle_workspace_rename(request.id, params); } Method::WorkspaceMove(params) => { return self.handle_workspace_move(request.id, params); } Method::WorkspaceMoveBlock(params) => { return self.handle_workspace_move_block(request.id, params); } Method::WorkspaceReportMetadata(params) => { return self.handle_workspace_report_metadata(request.id, params); } Method::WorkspaceClose(target) => { return self.handle_workspace_close(request.id, target); } Method::WorktreeList(params) => return self.handle_worktree_list(request.id, params), Method::WorktreeCreate(params) => { let _ = params; return responses::encode_error( request.id, "invalid_request", "worktree.create is handled asynchronously by the app runtime", ); } Method::WorktreeOpen(params) => return self.handle_worktree_open(request.id, params), Method::WorktreeRemove(params) => { let _ = params; return responses::encode_error( request.id, "invalid_request", "worktree.remove is handled asynchronously by the app runtime", ); } Method::TabList(params) => return self.handle_tab_list(request.id, params), Method::TabGet(target) => return self.handle_tab_get(request.id, target), Method::TabCreate(params) => return self.handle_tab_create(request.id, params), Method::TabFocus(target) => return self.handle_tab_focus(request.id, target), Method::TabRename(params) => return self.handle_tab_rename(request.id, params), Method::TabMove(params) => return self.handle_tab_move(request.id, params), Method::TabClose(target) => return self.handle_tab_close(request.id, target), Method::AgentList(_) => return self.handle_agent_list(request.id), Method::AgentGet(target) => return self.handle_agent_get(request.id, target), Method::AgentFocus(target) => return self.handle_agent_focus(request.id, target), Method::AgentRename(params) => return self.handle_agent_rename(request.id, params), Method::AgentViewSet(params) => return self.handle_agent_view_set(request.id, params), Method::AgentViewClear(params) => { return self.handle_agent_view_clear(request.id, params); } Method::AgentStart(params) => return self.handle_agent_start(request.id, params), Method::AgentPrompt(_) => { return responses::encode_error( request.id, "invalid_request", "agent.prompt is handled asynchronously by the app runtime", ); } Method::AgentWait(_) => { return responses::encode_error( request.id, "invalid_request", "agent.wait is handled by the api server", ); } Method::AgentRead(params) => return self.handle_agent_read(request.id, params), Method::AgentExplain(target) => return self.handle_agent_explain(request.id, target), Method::AgentSendKeys(params) => { return self.handle_agent_send_keys(request.id, params); } Method::PaneSplit(params) => return self.handle_pane_split(request.id, params), Method::PaneSwap(params) => return self.handle_pane_swap(request.id, params), Method::PaneMove(params) => return self.handle_pane_move(request.id, params), Method::PaneZoom(params) => return self.handle_pane_zoom(request.id, params), Method::PaneLayout(params) => return self.handle_pane_layout(request.id, params), Method::PaneProcessInfo(params) => { return self.handle_pane_process_info(request.id, params); } Method::LayoutExport(params) => return self.handle_layout_export(request.id, params), Method::LayoutApply(params) => return self.handle_layout_apply(request.id, params), Method::LayoutSetSplitRatio(params) => { return self.handle_layout_set_split_ratio(request.id, params); } Method::PaneNeighbor(params) => return self.handle_pane_neighbor(request.id, params), Method::PaneEdges(params) => return self.handle_pane_edges(request.id, params), Method::PaneFocusDirection(params) => { return self.handle_pane_focus_direction(request.id, params); } Method::PaneResize(params) => return self.handle_pane_resize(request.id, params), Method::PaneScroll(params) => return self.handle_pane_scroll(request.id, params), Method::PaneClear(target) => return self.handle_pane_clear(request.id, target), Method::PaneEditScrollback(target) => { return self.handle_pane_edit_scrollback(request.id, target); } Method::PaneSelectionRead(params) => { return self.handle_pane_selection_read(request.id, params); } Method::PaneCopyMotion(params) => { return self.handle_pane_copy_motion(request.id, params); } Method::PaneCopySearch(params) => { return self.handle_pane_copy_search(request.id, params); } Method::PaneList(params) => return self.handle_pane_list(request.id, params), Method::PaneCurrent(params) => return self.handle_pane_current(request.id, params), Method::PaneGet(target) => return self.handle_pane_get(request.id, target), Method::PaneFocus(target) => return self.handle_pane_focus(request.id, target), Method::PaneInputSet(params) => return self.handle_pane_input_set(request.id, params), Method::PaneLinkResolve(params) => { return self.handle_pane_link_resolve(request.id, params); } Method::PaneLinkActivate(params) => { return self.handle_pane_link_activate(request.id, params); } Method::PaneRename(params) => return self.handle_pane_rename(request.id, params), Method::PaneRead(params) => return self.handle_pane_read(request.id, params), Method::PaneGraphicsSet(params) => { return self.handle_pane_graphics_set(request.id, params); } Method::PaneGraphicsClear(params) => { return self.handle_pane_graphics_clear(request.id, params); } Method::PaneGraphicsInfo(params) => { return self.handle_pane_graphics_info(request.id, params); } Method::PaneGraphicsStream(_) => { return responses::encode_error( request.id, "stream_transport_required", "pane.graphics.stream requires the streaming socket transport", ); } Method::PaneGraphicsStreamSet(params) => { return self.handle_pane_graphics_stream_set(request.id, params); } Method::PaneGraphicsStreamDirect(params) => { return self.handle_pane_graphics_stream_direct(request.id, params); } Method::PaneGraphicsStreamOpen(params) => { return self.handle_pane_graphics_stream_open(request.id, params); } Method::PaneGraphicsStreamClose(params) => { return self.handle_pane_graphics_stream_close(request.id, params); } Method::PaneReportAgent(params) => { return self.handle_pane_report_agent(request.id, params); } Method::PaneReportAgentSession(params) => { return self.handle_pane_report_agent_session(request.id, params); } Method::PaneReportMetadata(params) => { return self.handle_pane_report_metadata(request.id, params); } Method::PaneClearAgentAuthority(params) => { return self.handle_pane_clear_agent_authority(request.id, params); } Method::PaneReleaseAgent(params) => { return self.handle_pane_release_agent(request.id, params); } Method::PaneSendText(params) => return self.handle_pane_send_text(request.id, params), Method::PaneSendInput(params) => { return self.handle_pane_send_input(request.id, params); } Method::PaneClose(target) => return self.handle_pane_close(request.id, target), Method::PopupClose(_) => { return if self.close_popup_pane() { responses::encode_success(request.id, ResponseResult::Ok {}) } else { responses::encode_error(request.id, "popup_not_open", "no popup is open") }; } Method::PaneSendKeys(params) => return self.handle_pane_send_keys(request.id, params), Method::IntegrationList(_) => { return self.handle_integration_list(request.id); } Method::IntegrationInstall(params) => { return self.handle_integration_install(request.id, params); } Method::IntegrationUninstall(params) => { return self.handle_integration_uninstall(request.id, params); } Method::PluginLink(params) => { return self.handle_plugin_link(request.id, params); } Method::PluginList(params) => { return self.handle_plugin_list(request.id, params); } Method::PluginUnlink(params) => { return self.handle_plugin_unlink(request.id, params); } Method::PluginEnable(params) => { return self.handle_plugin_enable(request.id, params); } Method::PluginDisable(params) => { return self.handle_plugin_disable(request.id, params); } Method::PluginActionList(params) => { return self.handle_plugin_action_list(request.id, params); } Method::PluginActionInvoke(params) => { return self.handle_plugin_action_invoke(request.id, params); } Method::PluginLogList(params) => { return self.handle_plugin_log_list(request.id, params); } Method::PluginPaneOpen(params) => { return self.handle_plugin_pane_open(request.id, params); } Method::PluginPaneFocus(params) => { return self.handle_plugin_pane_focus(request.id, params); } Method::PluginPaneClose(params) => { return self.handle_plugin_pane_close(request.id, params); } _ => { return responses::encode_error( request.id, "not_implemented", "method not implemented yet", ); } }; serde_json::to_string(&response).unwrap() } fn handle_notification_show( &mut self, id: String, params: crate::api::schema::NotificationShowParams, ) -> String { use crate::api::schema::{NotificationShowReason, ResponseResult}; let Some(title) = sanitized_notification_text(¶ms.title, 80) else { return responses::encode_error(id, "invalid_params", "notification title is empty"); }; let body = params .body .as_deref() .and_then(|body| sanitized_notification_text(body, 240)); let reason = match self.state.toast_config.delivery { crate::config::ToastDelivery::Off => NotificationShowReason::Disabled, crate::config::ToastDelivery::Herdr => { if self.state.toast.is_some() { NotificationShowReason::Busy } else if self.api_notification_rate_limited(Instant::now()) { NotificationShowReason::RateLimited } else { let previous_toast = self.state.toast.clone(); self.mark_api_notification_shown(Instant::now()); self.state.toast = Some(crate::app::state::ToastNotification { kind: ToastKind::UpdateInstalled, title, context: body.unwrap_or_default(), position: params.position, target: None, }); self.sync_toast_deadline(previous_toast); NotificationShowReason::Shown } } crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System => { NotificationShowReason::NoForegroundClient } }; responses::encode_success( id, ResponseResult::NotificationShow { shown: matches!(reason, NotificationShowReason::Shown), reason, }, ) } pub(crate) fn api_notification_rate_limited(&self, now: Instant) -> bool { self.last_api_notification_at .is_some_and(|last| now.duration_since(last) < API_NOTIFICATION_RATE_LIMIT) } pub(crate) fn mark_api_notification_shown(&mut self, now: Instant) { self.last_api_notification_at = Some(now); } } fn sanitized_notification_text(value: &str, max_chars: usize) -> Option { let mut sanitized = String::new(); let mut previous_space = false; for ch in value.chars() { let replacement = if ch == '\n' || ch == '\r' || ch == '\t' { Some(' ') } else if ch.is_control() { None } else { Some(ch) }; let Some(ch) = replacement else { continue; }; if ch.is_whitespace() { if previous_space { continue; } previous_space = true; sanitized.push(' '); } else { previous_space = false; sanitized.push(ch); } if sanitized.chars().count() >= max_chars { break; } } let sanitized = sanitized.trim().to_string(); (!sanitized.is_empty()).then_some(sanitized) } fn agent_manifest_info( summary: crate::detect::manifest::AgentManifestSummary, update_status: &crate::detect::manifest_update::ManifestUpdateStatus, ) -> crate::api::schema::AgentManifestInfo { let remote = update_status.agent_status(summary.agent); crate::api::schema::AgentManifestInfo { agent: crate::detect::agent_label(summary.agent).to_string(), source: summary.active_source.label(), source_kind: summary.active_source.kind().to_string(), active_version: summary.active_version, cached_remote_version: summary.cached_remote_version, local_override_shadowing_remote: summary.local_override_shadowing_remote, remote_update_result: remote.as_ref().map(|status| status.last_result.clone()), remote_update_error: remote.as_ref().and_then(|status| status.last_error.clone()), remote_last_checked_unix: remote.and_then(|status| status.last_checked_unix), warning: summary.warning, } } #[cfg(test)] pub(super) mod test_support { pub(crate) fn exiting_test_command() -> &'static str { #[cfg(windows)] { "C:\\Windows\\System32\\whoami.exe" } #[cfg(not(windows))] { "/usr/bin/true" } } pub(crate) fn shutdown_test_runtimes(app: &mut crate::app::App) { let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); for (_terminal_id, runtime) in runtimes { runtime.shutdown(); } } } #[cfg(test)] mod tests { use super::*; use crate::detect::{Agent, AgentState}; #[cfg(unix)] fn init_repo(path: &std::path::Path) { let status = std::process::Command::new("git") .args(["init", "-q"]) .current_dir(path) .status() .unwrap(); assert!(status.success(), "git init failed for {}", path.display()); } fn app_with_overlay( workspace: crate::workspace::Workspace, overlay_pane: crate::layout::PaneId, previous_focus: crate::layout::PaneId, previous_zoomed: bool, ) -> App { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), ); app.state.workspaces = vec![workspace]; app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.mode = Mode::Terminal; app.overlay_panes.insert( overlay_pane, OverlayPaneState { ws_idx: 0, tab_idx: 0, previous_focus, previous_zoomed, temp_files: Vec::new(), }, ); app } #[tokio::test] async fn manifest_activation_event_resets_matching_agent_detection_runtime() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), ); app.state.workspaces = vec![crate::workspace::Workspace::test_new("manifest-reset")]; app.state.ensure_test_terminals(); let pane_id = app.state.workspaces[0].tabs[0].root_pane; let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] .attached_terminal_id .clone(); app.state .terminals .get_mut(&terminal_id) .unwrap() .detected_agent = Some(Agent::Codex); let (runtime, _rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); let reset_notify = runtime.agent_detection_reset_notify_for_test(); app.terminal_runtimes.insert(terminal_id, runtime); app.handle_internal_event(AppEvent::AgentDetectionManifestsUpdated { updated: Vec::new(), activated: vec![Agent::Codex], status: crate::detect::manifest_update::ManifestUpdateStatus::default(), }); tokio::time::timeout( std::time::Duration::from_millis(50), reset_notify.notified(), ) .await .expect("matching agent detection runtime should be reset"); } #[test] fn product_announcement_dismiss_requires_current_identity() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), ); app.state.product_announcement = Some(crate::app::state::ProductAnnouncementState { version: "0.8.2".into(), id: "client-shell".into(), title: "Client shell".into(), body: "announcement".into(), scroll: 0, preview: true, }); let stale = app.handle_api_request(crate::api::schema::Request { id: "stale".into(), method: crate::api::schema::Method::ProductAnnouncementDismiss( crate::api::schema::ProductAnnouncementDismissParams { version: "0.8.2".into(), id: "old".into(), }, ), }); let stale: serde_json::Value = serde_json::from_str(&stale).unwrap(); assert_eq!(stale["error"]["code"], "stale_announcement"); assert!(app.state.product_announcement.is_some()); let dismissed = app.handle_api_request(crate::api::schema::Request { id: "dismiss".into(), method: crate::api::schema::Method::ProductAnnouncementDismiss( crate::api::schema::ProductAnnouncementDismissParams { version: "0.8.2".into(), id: "client-shell".into(), }, ), }); let dismissed: serde_json::Value = serde_json::from_str(&dismissed).unwrap(); assert_eq!(dismissed["result"]["type"], "ok"); assert!(app.state.product_announcement.is_none()); } #[test] fn release_notes_dismiss_requires_current_version() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), ); app.state.latest_release_notes = Some(crate::release_notes::ReleaseNotes { version: "99.99.99".into(), body: "release notes".into(), preview: true, }); let stale = app.handle_api_request(crate::api::schema::Request { id: "stale".into(), method: crate::api::schema::Method::ReleaseNotesDismiss( crate::api::schema::ReleaseNotesDismissParams { version: "0.8.2".into(), }, ), }); let stale: serde_json::Value = serde_json::from_str(&stale).unwrap(); assert_eq!(stale["error"]["code"], "stale_release_notes"); let dismissed = app.handle_api_request(crate::api::schema::Request { id: "dismiss".into(), method: crate::api::schema::Method::ReleaseNotesDismiss( crate::api::schema::ReleaseNotesDismissParams { version: "99.99.99".into(), }, ), }); let dismissed: serde_json::Value = serde_json::from_str(&dismissed).unwrap(); assert_eq!(dismissed["result"]["type"], "ok"); assert!(app.state.latest_release_notes.is_some()); } #[tokio::test] async fn server_reload_agent_manifests_resets_detection_runtimes() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), ); app.state.workspaces = vec![crate::workspace::Workspace::test_new("manifest-reload")]; app.state.ensure_test_terminals(); let pane_id = app.state.workspaces[0].tabs[0].root_pane; let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] .attached_terminal_id .clone(); let (runtime, _rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); let reset_notify = runtime.agent_detection_reset_notify_for_test(); app.terminal_runtimes.insert(terminal_id, runtime); let response = app.handle_api_request(crate::api::schema::Request { id: "reload_manifests".into(), method: crate::api::schema::Method::ServerReloadAgentManifests( crate::api::schema::EmptyParams::default(), ), }); let response: serde_json::Value = serde_json::from_str(&response).unwrap(); assert_eq!(response["result"]["type"], "agent_manifest_reload"); assert!(!response["result"]["manifests"] .as_array() .unwrap() .is_empty()); tokio::time::timeout( std::time::Duration::from_millis(50), reset_notify.notified(), ) .await .expect("manual manifest reload should reset detection runtimes"); } #[tokio::test] async fn server_agent_manifests_reports_status_without_resetting_runtimes() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), ); app.state.workspaces = vec![crate::workspace::Workspace::test_new("manifest-status")]; app.state.ensure_test_terminals(); let pane_id = app.state.workspaces[0].tabs[0].root_pane; let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] .attached_terminal_id .clone(); let (runtime, _rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); let reset_notify = runtime.agent_detection_reset_notify_for_test(); app.terminal_runtimes.insert(terminal_id, runtime); let response = app.handle_api_request(crate::api::schema::Request { id: "manifest_status".into(), method: crate::api::schema::Method::ServerAgentManifests( crate::api::schema::EmptyParams::default(), ), }); let response: serde_json::Value = serde_json::from_str(&response).unwrap(); assert_eq!(response["result"]["type"], "agent_manifest_status"); assert!(!response["result"]["manifests"] .as_array() .unwrap() .is_empty()); assert!( tokio::time::timeout( std::time::Duration::from_millis(10), reset_notify.notified(), ) .await .is_err(), "status request should not reset detection runtimes" ); } #[tokio::test] async fn agent_explain_evaluates_with_server_manifest_cache() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), ); app.state.workspaces = vec![crate::workspace::Workspace::test_new("agent-explain")]; app.state.ensure_test_terminals(); let pane_id = app.state.workspaces[0].tabs[0].root_pane; let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] .attached_terminal_id .clone(); app.state .terminals .get_mut(&terminal_id) .unwrap() .detected_agent = Some(Agent::Codex); let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes( 80, 24, b"press enter to confirm or esc to cancel", ); app.terminal_runtimes.insert(terminal_id, runtime); let target = app.public_pane_id(0, pane_id).unwrap(); let response = app.handle_api_request(crate::api::schema::Request { id: "agent_explain".into(), method: crate::api::schema::Method::AgentExplain(crate::api::schema::AgentTarget { target, }), }); let response: serde_json::Value = serde_json::from_str(&response).unwrap(); assert_eq!(response["result"]["type"], "agent_explain"); assert_eq!(response["result"]["explain"]["state"], "blocked"); assert_eq!( response["result"]["explain"]["matched_rule"]["id"], "live_strong_blocker" ); } #[tokio::test] async fn agent_explain_rejects_hook_only_full_lifecycle_authority() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), ); app.state.workspaces = vec![crate::workspace::Workspace::test_new("agent-explain-omp")]; app.state.ensure_test_terminals(); let pane_id = app.state.workspaces[0].tabs[0].root_pane; let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] .attached_terminal_id .clone(); app.state .terminals .get_mut(&terminal_id) .unwrap() .set_hook_authority( "herdr:omp".to_string(), "omp".to_string(), AgentState::Working, None, Some(1), ); let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""); app.terminal_runtimes.insert(terminal_id, runtime); let target = app.public_pane_id(0, pane_id).unwrap(); let response = app.handle_api_request(crate::api::schema::Request { id: "agent_explain_omp".into(), method: crate::api::schema::Method::AgentExplain(crate::api::schema::AgentTarget { target, }), }); let response: serde_json::Value = serde_json::from_str(&response).unwrap(); assert_eq!(response["error"]["code"], "agent_not_found"); } #[tokio::test] async fn pane_process_info_returns_response_for_existing_pane() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), ); app.state.workspaces = vec![crate::workspace::Workspace::test_new("process-info")]; app.state.ensure_test_terminals(); let pane_id = app.state.workspaces[0].tabs[0].root_pane; let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] .attached_terminal_id .clone(); let (runtime, _rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); app.terminal_runtimes.insert(terminal_id, runtime); let target = app.public_pane_id(0, pane_id).unwrap(); let response = app.handle_api_request(crate::api::schema::Request { id: "process_info".into(), method: crate::api::schema::Method::PaneProcessInfo( crate::api::schema::PaneProcessInfoParams { pane_id: Some(target.clone()), }, ), }); let response: serde_json::Value = serde_json::from_str(&response).unwrap(); assert_eq!(response["result"]["type"], "pane_process_info"); assert_eq!(response["result"]["process_info"]["pane_id"], target); } #[test] fn client_window_title_api_reports_no_foreground_client_in_app_mode() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), ); let set = app.handle_api_request(crate::api::schema::Request { id: "title_set".into(), method: crate::api::schema::Method::ClientWindowTitleSet( crate::api::schema::ClientWindowTitleSetParams { title: "plugin review".into(), }, ), }); let set: serde_json::Value = serde_json::from_str(&set).unwrap(); assert_eq!(set["result"]["type"], "client_window_title"); assert_eq!(set["result"]["changed"], false); assert_eq!(set["result"]["reason"], "no_foreground_client"); let clear = app.handle_api_request(crate::api::schema::Request { id: "title_clear".into(), method: crate::api::schema::Method::ClientWindowTitleClear( crate::api::schema::EmptyParams::default(), ), }); let clear: serde_json::Value = serde_json::from_str(&clear).unwrap(); assert_eq!(clear["result"]["type"], "client_window_title"); assert_eq!(clear["result"]["reason"], "no_foreground_client"); } #[cfg(unix)] #[tokio::test] async fn herdr_toast_context_uses_live_root_runtime_cwd_label() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), ); let mut workspace = crate::workspace::Workspace::test_new("stale"); workspace.custom_name = None; let root = workspace.tabs[0].root_pane; let terminal_id = workspace.terminal_id(root).cloned().unwrap(); let temp_root = std::env::temp_dir().join(format!( "herdr-toast-context-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); let stale_cwd = temp_root.join("__herdr_original__"); let live_cwd = temp_root.join("__herdr_projects__"); std::fs::create_dir_all(&stale_cwd).unwrap(); std::fs::create_dir_all(&live_cwd).unwrap(); init_repo(&stale_cwd); init_repo(&live_cwd); workspace.identity_cwd = stale_cwd.clone(); app.state.workspaces = vec![workspace]; app.state.ensure_test_terminals(); app.state.terminals.get_mut(&terminal_id).unwrap().cwd = stale_cwd; app.state.active = None; app.state.selected = 0; app.state.mode = Mode::Terminal; app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; app.state.toast_config.delay_seconds = 0; let (events, _) = tokio::sync::mpsc::channel(4); let runtime = crate::terminal::TerminalRuntime::spawn( root, 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; } app.terminal_runtimes.insert(terminal_id, runtime); app.handle_internal_event(AppEvent::StateChanged { pane_id: root, agent: Some(Agent::Codex), state: AgentState::Working, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); app.handle_internal_event(AppEvent::StateChanged { pane_id: root, agent: Some(Agent::Codex), state: AgentState::Idle, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); assert_eq!( app.state.toast.as_ref().map(|toast| toast.context.as_str()), Some("__herdr_projects__ · 1") ); for (_, runtime) in app.terminal_runtimes.drain() { runtime.shutdown(); } let _ = std::fs::remove_dir_all(temp_root); } #[cfg(unix)] #[tokio::test] async fn delayed_herdr_toast_context_uses_live_root_runtime_cwd_label() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), ); let mut workspace = crate::workspace::Workspace::test_new("stale"); workspace.custom_name = None; let root = workspace.tabs[0].root_pane; let terminal_id = workspace.terminal_id(root).cloned().unwrap(); let temp_root = std::env::temp_dir().join(format!( "herdr-delayed-toast-context-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); let stale_cwd = temp_root.join("__herdr_original__"); let live_cwd = temp_root.join("__herdr_projects__"); std::fs::create_dir_all(&stale_cwd).unwrap(); std::fs::create_dir_all(&live_cwd).unwrap(); init_repo(&stale_cwd); init_repo(&live_cwd); workspace.identity_cwd = stale_cwd.clone(); app.state.workspaces = vec![workspace]; app.state.ensure_test_terminals(); app.state.terminals.get_mut(&terminal_id).unwrap().cwd = stale_cwd; app.state.active = None; app.state.selected = 0; app.state.mode = Mode::Terminal; app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; app.state.toast_config.delay_seconds = 1; let (events, _) = tokio::sync::mpsc::channel(4); let runtime = crate::terminal::TerminalRuntime::spawn( root, 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; } app.terminal_runtimes.insert(terminal_id, runtime); app.handle_internal_event(AppEvent::StateChanged { pane_id: root, agent: Some(Agent::Codex), state: AgentState::Working, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); app.handle_internal_event(AppEvent::StateChanged { pane_id: root, agent: Some(Agent::Codex), state: AgentState::Idle, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); let notification_deadline = app .state .next_pending_agent_notification_deadline() .expect("pending notification deadline"); let mut deliveries = app .state .drain_due_agent_notifications(notification_deadline); app.refresh_agent_notification_delivery_contexts(&mut deliveries); assert_eq!( deliveries .first() .and_then(|delivery| delivery.toast.as_ref()) .map(|toast| toast.context.as_str()), Some("__herdr_projects__ · 1") ); for (_, runtime) in app.terminal_runtimes.drain() { runtime.shutdown(); } let _ = std::fs::remove_dir_all(temp_root); } #[test] fn overlay_exit_preserves_focus_changed_before_exit() { let mut workspace = crate::workspace::Workspace::test_new("overlay"); let previous_focus = workspace.tabs[0].root_pane; let overlay_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); workspace.tabs[0].zoomed = true; let new_tab = workspace.test_add_tab(Some("new")); workspace.switch_tab(new_tab); let mut app = app_with_overlay(workspace, overlay_pane, previous_focus, true); app.handle_internal_event(AppEvent::PaneDied { pane_id: overlay_pane, exit_reason: crate::platform::ChildExitReason::Exited, }); let overlay_tab = &app.state.workspaces[0].tabs[0]; assert_eq!(app.state.workspaces[0].active_tab, new_tab); assert_eq!(overlay_tab.layout.focused(), previous_focus); assert!(overlay_tab.zoomed); assert!(app.overlay_panes.is_empty()); } #[test] fn pane_exit_emits_layout_updated_when_tab_survives() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), ); let mut workspace = crate::workspace::Workspace::test_new("pane-exit-layout"); let dead_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces = vec![workspace]; app.state.ensure_test_terminals(); let tab_id = app.public_tab_id(0, 0).unwrap(); app.handle_internal_event(AppEvent::PaneDied { pane_id: dead_pane, exit_reason: crate::platform::ChildExitReason::Exited, }); let events = event_hub.events_after(0); let pane_exited = events .iter() .position(|(_, event)| event.event == crate::api::schema::EventKind::PaneExited) .expect("pane.exited should be emitted"); let layout_updated = events .iter() .position(|(_, event)| event.event == crate::api::schema::EventKind::LayoutUpdated) .expect("layout.updated should be emitted"); assert!(pane_exited < layout_updated); assert!(matches!( &events[layout_updated].1.data, crate::api::schema::EventData::LayoutUpdated { layout } if layout.tab_id == tab_id && layout.panes.len() == 1 )); } #[test] fn idle_agent_exit_emits_release_event_without_a_state_change() { for agent_name in [None, Some("reviewer")] { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), ); let workspace = crate::workspace::Workspace::test_new("idle-agent-exit"); let pane_id = workspace.tabs[0].root_pane; let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); app.state.workspaces = vec![workspace]; app.state.ensure_test_terminals(); let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); if let Some(agent_name) = agent_name { terminal.set_agent_name(agent_name.into()); } app.handle_internal_event(AppEvent::StateChanged { pane_id, agent: Some(Agent::Pi), state: AgentState::Idle, visible_blocker: false, visible_working: false, process_exited: true, observed_at: std::time::Instant::now(), }); // The release event is this test's subject; the name outliving the // observation is pinned by // `a_process_exit_observation_alone_does_not_free_the_name`. assert_eq!( app.state.terminals[&terminal_id].agent_name.as_deref(), agent_name ); assert!(event_hub.events_after(0).iter().any(|(_, event)| matches!( &event.data, crate::api::schema::EventData::PaneAgentDetected { released: true, final_status: Some(crate::api::schema::AgentStatus::Idle), .. } ))); } } #[test] fn process_exit_releases_a_newer_hook_owned_agent() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), ); let workspace = crate::workspace::Workspace::test_new("stale-agent-exit"); let pane_id = workspace.tabs[0].root_pane; let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); app.state.workspaces = vec![workspace]; app.state.ensure_test_terminals(); let observed_at = std::time::Instant::now(); let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); terminal.set_detected_state(Some(Agent::Codex), AgentState::Working); terminal .set_hook_authority_at( "herdr:codex".into(), "codex".into(), AgentState::Working, None, None, Some(1), observed_at + std::time::Duration::from_secs(1), ) .unwrap(); terminal.set_agent_name("reviewer".into()); app.handle_internal_event(AppEvent::StateChanged { pane_id, agent: Some(Agent::Codex), state: AgentState::Idle, visible_blocker: false, visible_working: false, process_exited: true, observed_at, }); let terminal = &app.state.terminals[&terminal_id]; assert_eq!(terminal.state, AgentState::Idle); // Releasing the registration does not free the name yet; a wrong // observation must not cost a live agent the handle its owner gave it. assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); assert!(event_hub.events_after(0).iter().any(|(_, event)| matches!( event.data, crate::api::schema::EventData::PaneAgentDetected { released: true, .. } ))); } #[test] fn overlay_exit_layout_updated_uses_restored_zoom_state() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), ); let mut workspace = crate::workspace::Workspace::test_new("overlay-layout"); let previous_focus = workspace.tabs[0].root_pane; let overlay_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); workspace.tabs[0].layout.focus_pane(previous_focus); workspace.tabs[0].zoomed = true; app.state.workspaces = vec![workspace]; app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.mode = Mode::Terminal; let tab_id = app.public_tab_id(0, 0).unwrap(); app.overlay_panes.insert( overlay_pane, OverlayPaneState { ws_idx: 0, tab_idx: 0, previous_focus, previous_zoomed: false, temp_files: Vec::new(), }, ); app.handle_internal_event(AppEvent::PaneDied { pane_id: overlay_pane, exit_reason: crate::platform::ChildExitReason::Exited, }); let events = event_hub.events_after(0); let layout_updated = events .iter() .rposition(|(_, event)| event.event == crate::api::schema::EventKind::LayoutUpdated) .expect("layout.updated should be emitted"); assert!(matches!( &events[layout_updated].1.data, crate::api::schema::EventData::LayoutUpdated { layout } if layout.tab_id == tab_id && layout.zoomed )); } #[test] fn overlay_exit_preserves_same_tab_focus_changed_before_exit() { let mut workspace = crate::workspace::Workspace::test_new("overlay"); let previous_focus = workspace.tabs[0].root_pane; let overlay_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); workspace.tabs[0].layout.focus_pane(previous_focus); workspace.tabs[0].zoomed = true; let mut app = app_with_overlay(workspace, overlay_pane, previous_focus, false); app.handle_internal_event(AppEvent::PaneDied { pane_id: overlay_pane, exit_reason: crate::platform::ChildExitReason::Exited, }); let tab = &app.state.workspaces[0].tabs[0]; assert_eq!(app.state.workspaces[0].active_tab, 0); assert_eq!(tab.layout.focused(), previous_focus); assert!(tab.zoomed); assert!(app.overlay_panes.is_empty()); } #[test] fn overlay_exit_restores_previous_focus_when_overlay_still_focused() { let mut workspace = crate::workspace::Workspace::test_new("overlay"); let previous_focus = workspace.tabs[0].root_pane; let overlay_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); workspace.tabs[0].zoomed = true; let mut app = app_with_overlay(workspace, overlay_pane, previous_focus, false); app.handle_internal_event(AppEvent::PaneDied { pane_id: overlay_pane, exit_reason: crate::platform::ChildExitReason::Exited, }); let tab = &app.state.workspaces[0].tabs[0]; assert_eq!(app.state.workspaces[0].active_tab, 0); assert_eq!(tab.layout.focused(), previous_focus); assert!(!tab.zoomed); assert!(app.overlay_panes.is_empty()); } #[tokio::test] async fn pane_died_respawns_shell_and_clears_restored_agent_session() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), ); let workspace = crate::workspace::Workspace::test_new("restored"); app.state.default_shell = test_support::exiting_test_command().into(); let pane_id = workspace.tabs[0].root_pane; let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); app.state.workspaces = vec![workspace]; app.state.ensure_test_terminals(); let terminal = app .state .terminals .get_mut(&terminal_id) .expect("test terminal should exist"); terminal.respawn_shell_on_exit = true; terminal.set_agent_name("codex".into()); terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { source: "herdr:codex".into(), agent: "codex".into(), session_ref: crate::agent_resume::AgentSessionRef::id("codex-session") .expect("test session id should be valid"), }); app.handle_internal_event(AppEvent::PaneDied { pane_id, exit_reason: crate::platform::ChildExitReason::Exited, }); assert!( app.find_pane(pane_id).is_some(), "respawnable agent pane should stay attached after the agent process exits" ); let terminal = app .state .terminals .get(&terminal_id) .expect("terminal should survive respawn"); assert!(!terminal.respawn_shell_on_exit); assert!(terminal.persisted_agent_session.is_none()); assert!(terminal.agent_name.is_none()); for (_, runtime) in app.terminal_runtimes.drain() { runtime.shutdown(); } } #[cfg(windows)] #[test] fn windows_powershell_exit_after_agent_process_exit_respawns_shell() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), ); let workspace = crate::workspace::Workspace::test_new("powershell"); let pane_id = workspace.tabs[0].root_pane; app.state.workspaces = vec![workspace]; app.state.ensure_test_terminals(); app.state.default_shell = "powershell.exe".into(); app.state.shell_mode = crate::config::ShellModeConfig::NonLogin; app.handle_internal_event(AppEvent::StateChanged { pane_id, agent: Some(crate::detect::Agent::OpenCode), state: AgentState::Idle, visible_blocker: false, visible_working: false, process_exited: true, observed_at: std::time::Instant::now(), }); assert_eq!( app.runtime_exit_action(pane_id), RuntimeExitAction::RespawnShell ); } #[cfg(windows)] #[test] fn windows_powershell_exit_without_recent_agent_process_exit_closes_pane() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), ); let workspace = crate::workspace::Workspace::test_new("powershell"); let pane_id = workspace.tabs[0].root_pane; app.state.workspaces = vec![workspace]; app.state.ensure_test_terminals(); app.state.default_shell = "powershell.exe".into(); app.state.shell_mode = crate::config::ShellModeConfig::NonLogin; assert_eq!( app.runtime_exit_action(pane_id), RuntimeExitAction::ClosePane ); } #[test] fn stalled_worktree_runtime_exit_closes_pane() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), ); let workspace = crate::workspace::Workspace::test_new("worktree"); let pane_id = workspace.tabs[0].root_pane; let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); app.state.workspaces = vec![workspace]; app.state.ensure_test_terminals(); #[cfg(windows)] { app.state.default_shell = "powershell.exe".into(); app.state.shell_mode = crate::config::ShellModeConfig::NonLogin; let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); terminal.set_detected_state(Some(Agent::Codex), AgentState::Working); terminal.set_detected_state_with_screen_signals_at( Some(Agent::Codex), AgentState::Idle, false, false, false, true, std::time::Instant::now(), ); assert_eq!( app.runtime_exit_action(pane_id), RuntimeExitAction::RespawnShell ); } app.pending_worktree_remove_runtime_exits.insert(pane_id, 1); app.pending_worktree_remove_runtime_restores .insert(pane_id, 8); app.handle_internal_event(AppEvent::WorktreeRuntimeRestoreFailed { pane_id, operation_id: 7, }); assert_eq!( app.pending_worktree_remove_runtime_restores.get(&pane_id), Some(&8) ); assert!(app.event_rx.try_recv().is_err()); app.handle_internal_event(AppEvent::WorktreeRuntimeRestoreFailed { pane_id, operation_id: 8, }); assert!(app.find_pane(pane_id).is_none()); assert!(app.terminal_runtimes.get(&terminal_id).is_none()); assert!(app.pending_worktree_remove_runtime_exits.is_empty()); assert!(app.pending_worktree_remove_runtime_restores.is_empty()); } #[test] fn terminal_delivery_does_not_refresh_existing_targeted_toast() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), ); let mut workspace = crate::workspace::Workspace::test_new("stale"); workspace.custom_name = None; workspace.identity_cwd = "/__herdr_original__".into(); let root = workspace.tabs[0].root_pane; let terminal_id = workspace.terminal_id(root).cloned().unwrap(); let workspace_id = workspace.id.clone(); app.state.workspaces = vec![workspace]; app.state.ensure_test_terminals(); app.state.terminals.get_mut(&terminal_id).unwrap().cwd = "/__herdr_projects__".into(); app.state.active = None; app.state.selected = 0; app.state.mode = Mode::Terminal; app.state.toast_config.delivery = crate::config::ToastDelivery::Terminal; app.handle_internal_event(AppEvent::StateChanged { pane_id: root, agent: Some(Agent::Codex), state: AgentState::Working, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); app.state.toast = Some(crate::app::state::ToastNotification { kind: ToastKind::Finished, title: "codex finished".into(), context: "__herdr_original__ · 1".into(), position: None, target: Some(crate::app::state::ToastTarget { workspace_id, pane_id: root, }), }); app.handle_internal_event(AppEvent::StateChanged { pane_id: root, agent: Some(Agent::Codex), state: AgentState::Idle, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); assert_eq!( app.state.toast.as_ref().map(|toast| toast.context.as_str()), Some("__herdr_original__ · 1") ); } }