use std::sync::atomic::Ordering; use std::time::{Duration, Instant}; mod agents; mod integrations; mod panes; mod responses; mod tabs; mod workspaces; mod worktrees; use super::{api_helpers::pane_agent_status, App, Mode, OverlayPaneState, ToastKind}; use crate::events::AppEvent; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RuntimeExitAction { RespawnShell, ClosePane, } impl App { pub(crate) fn handle_internal_event(&mut self, ev: AppEvent) { if let AppEvent::ClipboardWrite { content } = ev { #[cfg(not(test))] crate::selection::write_osc52_bytes(&content); #[cfg(test)] let _ = content; self.show_clipboard_feedback(); return; } if let AppEvent::GitStatusRefreshed { results, cache_updates, } = ev { 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(); } if self .state .apply_workspace_git_statuses(&self.terminal_runtimes, results) { self.render_dirty.store(true, Ordering::Release); self.render_notify.notify_one(); } return; } if let AppEvent::WorktreeAddFinished(result) = ev { self.handle_worktree_add_finished(result); return; } if let AppEvent::WorktreeRemoveFinished(result) = ev { self.handle_worktree_remove_finished(result); return; } if let AppEvent::PaneDied { pane_id } = &ev { if self.runtime_exit_action(*pane_id) == RuntimeExitAction::RespawnShell && self.respawn_shell_for_launch_pane(*pane_id) { self.overlay_panes.remove(pane_id); self.render_dirty.store(true, Ordering::Release); self.render_notify.notify_one(); return; } } let overlay_state = if let AppEvent::PaneDied { pane_id } = &ev { self.overlay_panes.remove(pane_id) } 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 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 previous_toast = self.state.toast.clone(); let pane_updates = self.state.handle_app_event(ev); 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((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); } } } } if let Some(overlay) = overlay_state { self.restore_overlay_after_exit(overlay); } if self.local_terminal_notifications && matches!( self.state.toast_config.delivery, crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System ) { let notify = match self.state.toast_config.delivery { crate::config::ToastDelivery::Terminal => crate::terminal_notify::show_notification, crate::config::ToastDelivery::System => crate::platform::show_desktop_notification, _ => unreachable!("toast delivery was checked above"), }; if let Some((version, install_command)) = update_ready { let instruction = crate::update::update_install_instruction(&install_command); let _ = notify(&format!("v{version} available"), Some(&instruction)); } else { for update in &pane_updates { let is_active_tab = self .state .pane_is_in_active_tab(update.ws_idx, update.pane_id); let suppress_active_tab_notifications = crate::app::actions::active_tab_suppresses_notifications( is_active_tab, self.state.outer_terminal_focus, ); let Some(kind) = crate::app::actions::notification_toast_for_state_change( suppress_active_tab_notifications, update.previous_state, update.state, ) else { continue; }; let Some(ws) = self.state.workspaces.get(update.ws_idx) else { continue; }; let Some(pane) = ws .tabs .iter() .find_map(|tab| tab.panes.get(&update.pane_id)) else { continue; }; let Some(agent_label) = self .state .terminals .get(&pane.attached_terminal_id) .and_then(|terminal| terminal.effective_agent_label()) else { continue; }; let event_text = match kind { ToastKind::NeedsAttention => "needs attention", ToastKind::Finished => "finished", ToastKind::UpdateInstalled => "updated", }; let workspace_label = ws.display_name_from(&self.state.terminals, &self.terminal_runtimes); let _ = notify( &format!("{} {}", agent_label, event_text), Some(&crate::app::actions::notification_context( ws, &workspace_label, update.ws_idx, update.pane_id, )), ); } } } self.sync_toast_deadline(previous_toast); self.shutdown_detached_terminal_runtimes(); } 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; } } pub(crate) fn show_clipboard_feedback(&mut self) { self.state.copy_feedback = Some(crate::app::state::CopyFeedback { message: "copied to clipboard".to_string(), }); self.copy_feedback_deadline = Some(Instant::now() + super::COPY_FEEDBACK_DURATION); } fn restore_overlay_after_exit(&mut self, overlay: OverlayPaneState) { 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; } 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 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 { RuntimeExitAction::RespawnShell } else { RuntimeExitAction::ClosePane } } fn respawn_shell_for_launch_pane(&mut self, pane_id: crate::layout::PaneId) -> 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 runtime = match crate::terminal::TerminalRuntime::spawn( pane_id, rows, cols, cwd, self.state.pane_scrollback_limit_bytes, self.state.host_terminal_theme, crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode), 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(); } self.state.focus_pane_in_workspace(ws_idx, pane_id); self.schedule_session_save(); true } pub(crate) fn emit_pane_state_update(&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.previous_agent_label != update.agent_label { 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(), }, }); } 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, custom_status: presentation.custom_status, state_labels: presentation.state_labels, }, }); } } pub(super) 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(super) fn emit_event(&self, event: crate::api::schema::EventEnvelope) { self.event_hub.push(event); } pub(crate) fn sync_focus_events(&mut self) { 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 { 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 { self.send_pane_focus_event(ws_idx, pane_id, crate::ghostty::FocusEvent::Gained); 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), }, }); } } self.last_focus = current_focus; } 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); } 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 { 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::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::WorkspaceClose(target) => { return self.handle_workspace_close(request.id, target) } Method::WorktreeList(params) => return self.handle_worktree_list(request.id, params), Method::WorktreeCreate(params) => { return self.handle_worktree_create(request.id, params); } Method::WorktreeOpen(params) => return self.handle_worktree_open(request.id, params), Method::WorktreeRemove(params) => { return self.handle_worktree_remove(request.id, params); } 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::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::AgentStart(params) => return self.handle_agent_start(request.id, params), Method::AgentRead(params) => return self.handle_agent_read(request.id, params), Method::AgentSend(params) => return self.handle_agent_send(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::PaneZoom(params) => return self.handle_pane_zoom(request.id, params), Method::PaneLayout(params) => return self.handle_pane_layout(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::PaneList(params) => return self.handle_pane_list(request.id, params), Method::PaneGet(target) => return self.handle_pane_get(request.id, target), Method::PaneRename(params) => return self.handle_pane_rename(request.id, params), Method::PaneRead(params) => return self.handle_pane_read(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::PaneSendKeys(params) => return self.handle_pane_send_keys(request.id, params), Method::IntegrationInstall(params) => { return self.handle_integration_install(request.id, params); } Method::IntegrationUninstall(params) => { return self.handle_integration_uninstall(request.id, params); } _ => { return responses::encode_error( request.id, "not_implemented", "method not implemented yet", ); } }; serde_json::to_string(&response).unwrap() } } #[cfg(test)] mod tests { use super::*; use crate::detect::{Agent, AgentState}; 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()); } #[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(), true, 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; 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(), crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), events, std::sync::Arc::new(tokio::sync::Notify::new()), std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), ) .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_idle: 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_idle: 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); } #[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(), true, None, api_rx, crate::api::EventHub::default(), ); let workspace = crate::workspace::Workspace::test_new("restored"); 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 }); 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(); } } #[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(), true, None, api_rx, crate::api::EventHub::default(), ); app.local_terminal_notifications = false; 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_idle: 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(), 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_idle: 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") ); } }