use std::cell::Cell; use std::io; use std::path::Path; use std::sync::{ atomic::{AtomicBool, AtomicU16, AtomicU32, AtomicU64, Ordering}, Arc, Mutex, }; use bytes::Bytes; use portable_pty::CommandBuilder; #[cfg(all(test, unix))] use portable_pty::{native_pty_system, PtySize}; use ratatui::{layout::Rect, Frame}; #[cfg(test)] use tokio::sync::watch; use tokio::sync::{mpsc, Notify}; #[cfg(not(windows))] use tracing::debug; use tracing::{error, info, warn}; use crate::detect::{Agent, AgentState}; use crate::events::AppEvent; use crate::layout::PaneId; use crate::pty::actor::{PtyIoActor, PtyIoActorConfig, PtyIoActorHandle, PtyReadResult}; use crate::render_signal::RenderSignal; mod agent_detection; mod cursor; mod input; mod kitty_keyboard; mod osc; mod state; mod terminal; mod xtgettcap; use self::agent_detection::{ decide_detection_screen_read, decide_screen_detection_publish, detection_update_for_publish_with_osc, mark_detection_content_changed, observe_detection_content_change, DetectionPublishDecision, DetectionScreenReadDecision, DetectionScreenReadInput, PendingIdleConfirmation, ScreenDetectionPublishInput, AGENT_PENDING_IDLE_RECHECK, AGENT_STARTUP_GRACE_WINDOW, }; #[cfg(any(unix, test))] pub use self::terminal::InputState; use self::terminal::{GhosttyPaneTerminal, PaneTerminal}; pub(crate) use self::terminal::{ TerminalDirtyPatch, TerminalDirtyPatchOutcome, TerminalReadSnapshot, TerminalSearchDirection, TerminalSearchWindow, TerminalTextPoint, TerminalWordMotion, }; pub use self::{ state::PaneState, terminal::{ScrollMetrics, TerminalCursorState}, }; const RELEASE_REACQUIRE_SUPPRESSION: std::time::Duration = std::time::Duration::from_secs(1); const PANE_TERM: &str = "xterm-256color"; const PANE_COLORTERM: &str = "truecolor"; fn apply_pane_terminal_env(cmd: &mut CommandBuilder) { // Each pane is rendered by herdr's own terminal layer, not the outer terminal // that launched the app. Advertising the inherited TERM leaks the host terminal // identity into shells and across SSH, which breaks redraw and cursor movement // when the remote side lacks matching terminfo entries. cmd.env("TERM", PANE_TERM); cmd.env("COLORTERM", PANE_COLORTERM); cmd.env_remove("WT_SESSION"); } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct PaneLaunchEnv { extra: Vec<(String, String)>, identity: PaneLaunchIdentity, } #[derive(Debug, Clone, Default, PartialEq, Eq)] enum PaneLaunchIdentity { #[default] Inherit, Managed { workspace_id: String, tab_id: String, pane_id: String, }, OmitPane, } impl PaneLaunchEnv { pub(crate) fn from_extra(extra: Vec<(String, String)>) -> Self { Self { extra, identity: PaneLaunchIdentity::Inherit, } } pub(crate) fn with_identity( mut self, workspace_id: String, tab_id: String, pane_id: String, ) -> Self { self.identity = PaneLaunchIdentity::Managed { workspace_id, tab_id, pane_id, }; self } pub(crate) fn without_pane_identity(mut self) -> Self { self.identity = PaneLaunchIdentity::OmitPane; self } } fn apply_pane_launch_env(cmd: &mut CommandBuilder, launch_env: &PaneLaunchEnv) { cmd.env_remove("CODEX_THREAD_ID"); for (key, value) in &launch_env.extra { cmd.env(key, value); } cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE); crate::integration::apply_pane_base_env(cmd); crate::platform::apply_pane_runtime_marker(cmd); match &launch_env.identity { PaneLaunchIdentity::Inherit => {} PaneLaunchIdentity::Managed { workspace_id, tab_id, pane_id, } => { cmd.env(crate::integration::HERDR_WORKSPACE_ID_ENV_VAR, workspace_id); cmd.env(crate::integration::HERDR_TAB_ID_ENV_VAR, tab_id); cmd.env(crate::integration::HERDR_PANE_ID_ENV_VAR, pane_id); } PaneLaunchIdentity::OmitPane => { cmd.env_remove(crate::integration::HERDR_PANE_ID_ENV_VAR); } } } #[derive(Debug, Clone, Copy)] struct PendingAgentRelease { agent: Agent, until: std::time::Instant, } #[derive(Clone, Copy, Default)] struct SpawnInitialState<'a> { detected_agent: Option, history_ansi: Option<&'a str>, windows_powershell_prompt_cwd_reporting: bool, } #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum AgentDetection { Enabled, Disabled, } fn active_pending_release( pending_release: &Mutex>, now: std::time::Instant, ) -> Option { let mut pending_release = pending_release.lock().ok()?; match *pending_release { Some(pending) if now < pending.until => Some(pending.agent), Some(_) => { *pending_release = None; None } None => None, } } async fn publish_state_changed_event( state_events: mpsc::Sender, pane_id: PaneId, agent: Option, state: AgentState, visible_blocker: bool, visible_working: bool, process_exited: bool, observed_at: std::time::Instant, ) { // This runs on the async detector task, not the PTY reader thread. // Waiting for queue space here preserves correctness-critical state transitions // without blocking pane I/O. if let Err(e) = state_events .send(AppEvent::StateChanged { pane_id, agent, state, visible_blocker, visible_working, process_exited, observed_at, }) .await { warn!( pane = pane_id.raw(), err = %e, "failed to deliver StateChanged event" ); } } async fn publish_agent_process_detected_event( state_events: mpsc::Sender, pane_id: PaneId, agent: Agent, observed_at: std::time::Instant, ) { if let Err(e) = state_events .send(AppEvent::AgentProcessDetected { pane_id, agent, observed_at, }) .await { warn!( pane = pane_id.raw(), err = %e, "failed to deliver AgentProcessDetected event" ); } } #[derive(Debug, Clone, Copy)] struct AgentDetectionPublishUpdate { state: AgentState, visible_idle: bool, visible_blocker: bool, visible_working: bool, process_exited: bool, } async fn apply_agent_detection_publish_update( state_events: mpsc::Sender, pane_id: PaneId, agent: Option, update: AgentDetectionPublishUpdate, observed_at: std::time::Instant, state: &mut AgentState, last_visible_idle: &mut bool, last_visible_blocker: &mut bool, last_visible_working: &mut bool, last_visible_signal_refresh: &mut Option, foreground_shell_exit_reported: &mut bool, ) { *state = update.state; *last_visible_idle = update.visible_idle; *last_visible_blocker = update.visible_blocker; *last_visible_working = update.visible_working; *last_visible_signal_refresh = if update.visible_blocker || update.visible_working { Some(observed_at) } else { None }; if update.process_exited { *foreground_shell_exit_reported = true; } publish_state_changed_event( state_events, pane_id, agent, update.state, update.visible_blocker, update.visible_working, update.process_exited, observed_at, ) .await; } const AGENT_MISS_CONFIRMATION_ATTEMPTS: u8 = 6; const PROCESS_RECHECK_IDENTIFIED: std::time::Duration = std::time::Duration::from_secs(5); const PROCESS_RECHECK_MISSING_FOREGROUND_GROUP: std::time::Duration = std::time::Duration::from_secs(30); const PROCESS_ACQUISITION_WINDOW: std::time::Duration = std::time::Duration::from_secs(8); const PROCESS_ACQUISITION_FAST_WINDOW: std::time::Duration = std::time::Duration::from_millis(1500); const PROCESS_ACQUISITION_FAST_RECHECK: std::time::Duration = std::time::Duration::from_millis(500); const PROCESS_ACQUISITION_SLOW_RECHECK: std::time::Duration = std::time::Duration::from_secs(2); const PROCESS_ACQUISITION_IDLE_RESET: std::time::Duration = std::time::Duration::from_secs(2); #[derive(Debug, Clone, Copy)] struct AgentDetectionPresence { current_agent: Option, consecutive_misses: u8, } #[cfg(unix)] fn absolute_process_cwd(pid: u32) -> Option { crate::platform::process_cwd(pid).filter(|cwd| cwd.is_absolute()) } #[cfg(unix)] fn usable_process_cwd(pid: u32) -> Option { absolute_process_cwd(pid).filter(|cwd| cwd.is_dir()) } #[cfg(unix)] fn foreground_member_cwd_different_from_shell( shell_pid: u32, shell_cwd: Option<&std::path::PathBuf>, ) -> Option { let job = crate::detect::foreground_job(shell_pid)?; for process in job.processes { if process.pid == shell_pid { continue; } let Some(cwd) = absolute_process_cwd(process.pid) else { continue; }; if shell_cwd != Some(&cwd) { return Some(cwd); } } None } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ForegroundShellAgentAction { ObserveProbe, ReportProcessExit, ReportReplacementProcess, ClearAgent, } fn foreground_shell_agent_action( previous_agent: Option, new_agent: Option, foreground_is_pane_shell: bool, process_exit_reported: bool, ) -> ForegroundShellAgentAction { let Some(previous_agent) = previous_agent else { return ForegroundShellAgentAction::ObserveProbe; }; if process_exit_reported { return if new_agent == Some(previous_agent) { ForegroundShellAgentAction::ReportReplacementProcess } else if new_agent.is_none() { ForegroundShellAgentAction::ClearAgent } else { ForegroundShellAgentAction::ObserveProbe }; } if new_agent.is_some() { return ForegroundShellAgentAction::ObserveProbe; } if foreground_is_pane_shell { // Do not clear identity immediately. First publish an idle process-exit // transition for the previous agent so notifications and wait-agent callers // observe completion before the pane becomes unknown. return ForegroundShellAgentAction::ReportProcessExit; } ForegroundShellAgentAction::ObserveProbe } /// Drops retained OSC evidence when changing away from an identified agent. /// First acquisition keeps bytes that the newly identified process may have /// emitted before the process probe recognized it. fn clear_osc_evidence_for_agent_transition(terminal: &PaneTerminal, previous_agent: Option) { if previous_agent.is_some() { terminal.clear_agent_osc_state(); } } fn apply_foreground_shell_agent_action( agent_presence: &mut AgentDetectionPresence, action: ForegroundShellAgentAction, previous_agent: Option, new_agent: Option, pending_foreground_shell_clear: &mut bool, foreground_shell_exit_reported: &mut bool, ) -> bool { match action { ForegroundShellAgentAction::ReportReplacementProcess => { *pending_foreground_shell_clear = false; *foreground_shell_exit_reported = false; agent_presence.observe_process_probe(previous_agent); true } ForegroundShellAgentAction::ReportProcessExit => { *pending_foreground_shell_clear = true; false } ForegroundShellAgentAction::ClearAgent => { *pending_foreground_shell_clear = false; *foreground_shell_exit_reported = false; agent_presence.clear_current_agent() } ForegroundShellAgentAction::ObserveProbe => { *pending_foreground_shell_clear = false; *foreground_shell_exit_reported = false; agent_presence.observe_process_probe(new_agent) } } } #[derive(Debug, Clone, Copy)] struct ProcessProbeInput { current_agent: Option, suppressed_agent: Option, foreground_pgid: Option, last_foreground_pgid: Option, has_process_probe: bool, acquisition_age: Option, pending_foreground_shell_clear: bool, pending_restore_probe: bool, elapsed_since_process_check: std::time::Duration, } fn foreground_group_changed( foreground_pgid: Option, last_foreground_pgid: Option, ) -> bool { foreground_pgid != last_foreground_pgid && (foreground_pgid.is_some() || last_foreground_pgid.is_some()) } // Only kernel-observed foreground groups drive change detection. Remembering an // inferred group would look like a change on every tick while the kernel stays silent. fn process_group_for_change_tracking( observed_foreground_pgid: Option, probed_process_group_id: Option, ) -> Option { observed_foreground_pgid?; probed_process_group_id.or(observed_foreground_pgid) } fn should_skip_process_probe_for_lifecycle_authority( full_lifecycle_authority_active: bool, input: ProcessProbeInput, ) -> bool { full_lifecycle_authority_active && input.foreground_pgid.is_some() && !input.pending_foreground_shell_clear && input.suppressed_agent.is_none() && input.has_process_probe && !foreground_group_changed(input.foreground_pgid, input.last_foreground_pgid) } #[cfg(any(windows, test))] fn should_observe_foreground_process_group( lifecycle_authority: bool, content_changed: bool, elapsed: std::time::Duration, input: ProcessProbeInput, ) -> bool { !input.has_process_probe || input.current_agent.is_none() || input.suppressed_agent.is_some() || input.pending_foreground_shell_clear || input.pending_restore_probe || content_changed || (lifecycle_authority && elapsed >= PROCESS_RECHECK_IDENTIFIED) || (!lifecycle_authority && input.elapsed_since_process_check >= PROCESS_RECHECK_IDENTIFIED) } fn should_probe_foreground_job(input: ProcessProbeInput) -> bool { if input.pending_foreground_shell_clear || input.pending_restore_probe { return true; } let foreground_group_changed = foreground_group_changed(input.foreground_pgid, input.last_foreground_pgid); if input.suppressed_agent.is_some() { return !input.has_process_probe || foreground_group_changed; } if let Some(acquisition_age) = input.acquisition_age { let acquisition_interval = if acquisition_age <= PROCESS_ACQUISITION_FAST_WINDOW { PROCESS_ACQUISITION_FAST_RECHECK } else { PROCESS_ACQUISITION_SLOW_RECHECK }; if acquisition_age <= PROCESS_ACQUISITION_WINDOW && input.elapsed_since_process_check >= acquisition_interval { return true; } } if input.current_agent.is_none() { return !input.has_process_probe || foreground_group_changed || (input.foreground_pgid.is_none() && input.elapsed_since_process_check >= PROCESS_RECHECK_MISSING_FOREGROUND_GROUP); } foreground_group_changed || input.elapsed_since_process_check >= PROCESS_RECHECK_IDENTIFIED } fn sync_content_change_acquisition( current_agent: Option, suppressed_agent: Option, process_group_changed: bool, content_changed: bool, now: std::time::Instant, acquisition_started_at: &mut Option, last_content_change_at: &mut Option, ) { if current_agent.is_some() || suppressed_agent.is_some() || process_group_changed { return; } if content_changed { let should_start = acquisition_started_at.is_none_or(|started| { now.duration_since(started) > PROCESS_ACQUISITION_WINDOW && last_content_change_at.is_none_or(|last_change| { now.duration_since(last_change) >= PROCESS_ACQUISITION_IDLE_RESET }) }); if should_start { *acquisition_started_at = Some(now); } *last_content_change_at = Some(now); return; } let Some(acquisition_started) = *acquisition_started_at else { return; }; let Some(last_content_change) = *last_content_change_at else { return; }; if now.duration_since(acquisition_started) > PROCESS_ACQUISITION_WINDOW && now.duration_since(last_content_change) >= PROCESS_ACQUISITION_IDLE_RESET { *acquisition_started_at = None; *last_content_change_at = None; } } #[derive(Debug, Clone)] struct ProcessProbeResult { process_group_id: Option, foreground_is_pane_shell: bool, agent: Option, process_name: Option, } fn agent_hint_for_foreground_job_members( job: &crate::platform::ForegroundJob, read_hint: impl Fn(u32) -> Option, ) -> Option { read_hint(job.process_group_id) .or_else(|| agent_hint_for_non_leader_foreground_job_members(job, read_hint)) } fn agent_hint_for_non_leader_foreground_job_members( job: &crate::platform::ForegroundJob, read_hint: impl Fn(u32) -> Option, ) -> Option { job.processes .iter() .filter(|process| process.pid != job.process_group_id) .find_map(|process| read_hint(process.pid)) } fn identify_process_group_leader_in_job( job: &crate::platform::ForegroundJob, ) -> Option<(Agent, String)> { let leader = job .processes .iter() .find(|process| process.pid == job.process_group_id)?; let leader_job = crate::platform::ForegroundJob { process_group_id: job.process_group_id, processes: vec![leader.clone()], }; crate::detect::identify_agent_in_job(&leader_job) } fn process_probe_result( job: &crate::platform::ForegroundJob, pid: u32, agent: Agent, process_name: String, ) -> ProcessProbeResult { ProcessProbeResult { process_group_id: Some(job.process_group_id), foreground_is_pane_shell: job.processes.iter().any(|process| process.pid == pid), agent: Some(agent), process_name: Some(process_name), } } fn hinted_process_probe_result( job: &crate::platform::ForegroundJob, pid: u32, read_hint: impl Fn(u32) -> Option, ) -> Option { let agent = agent_hint_for_foreground_job_members(job, read_hint)?; Some(process_probe_result( job, pid, agent, crate::detect::agent_label(agent).to_string(), )) } fn probe_foreground_process_from_jobs( pid: u32, foreground_pgid: Option, leader_job: Option, foreground_job: impl FnOnce() -> Option, read_hint: impl Fn(u32) -> Option + Copy, ) -> ProcessProbeResult { if let Some(job) = leader_job.as_ref() { if let Some(hinted) = hinted_process_probe_result(job, pid, read_hint) { return hinted; } if let Some((agent, process_name)) = crate::detect::identify_agent_in_job(job) { return process_probe_result(job, pid, agent, process_name); } } let foreground_job = foreground_job(); if let Some(job) = foreground_job.as_ref() { if let Some(agent) = read_hint(job.process_group_id) { return process_probe_result( job, pid, agent, crate::detect::agent_label(agent).to_string(), ); } if let Some((agent, process_name)) = identify_process_group_leader_in_job(job) { return process_probe_result(job, pid, agent, process_name); } if let Some(agent) = agent_hint_for_non_leader_foreground_job_members(job, read_hint) { return process_probe_result( job, pid, agent, crate::detect::agent_label(agent).to_string(), ); } let identified = crate::detect::identify_agent_in_job(job); return ProcessProbeResult { process_group_id: Some(job.process_group_id), foreground_is_pane_shell: job.processes.iter().any(|process| process.pid == pid), agent: identified.as_ref().map(|(agent, _)| *agent), process_name: identified.map(|(_, process_name)| process_name), }; } ProcessProbeResult { process_group_id: foreground_pgid, foreground_is_pane_shell: false, agent: None, process_name: None, } } fn probe_foreground_process(pid: u32, foreground_pgid: Option) -> ProcessProbeResult { probe_foreground_process_from_jobs( pid, foreground_pgid, foreground_pgid.and_then(crate::detect::foreground_group_leader_job), || crate::detect::foreground_job(pid), crate::platform::process_agent_hint, ) } #[cfg(unix)] fn spawn_basic_detection_task( pane_id: PaneId, child_pid: Arc, terminal: Arc, detection_content_seq: Arc, full_lifecycle_authority_active: Arc, state_events: mpsc::Sender, ) -> ( tokio::task::AbortHandle, Arc, Arc>>, ) { let detect_reset_notify = Arc::new(Notify::new()); let detect_reset = detect_reset_notify.clone(); let pending_release = Arc::new(Mutex::new(None)); let pending_release_for_task = pending_release.clone(); let handle = tokio::spawn(async move { let mut agent_presence = AgentDetectionPresence::from_agent(None); let mut state = AgentState::Unknown; let mut last_visible_idle = false; let mut last_visible_blocker = false; let mut last_visible_working = false; let mut last_visible_signal_refresh = None; let mut last_process_check = std::time::Instant::now(); let mut last_foreground_pgid = None; let mut has_process_probe = false; let mut acquisition_started_at = None; let mut last_content_change_at = None; let mut pending_foreground_shell_clear = false; let mut foreground_shell_exit_reported = false; let mut release_was_active = false; let mut last_detection_text = String::new(); let mut last_screen_scan_detection_content_seq = None; let mut agent_startup_grace_until = None; let mut pending_idle = PendingIdleConfirmation::default(); loop { let sleep_duration = if pending_idle.active() { AGENT_PENDING_IDLE_RECHECK } else { std::time::Duration::from_millis(300) }; tokio::select! { _ = tokio::time::sleep(sleep_duration) => {} _ = detect_reset.notified() => { agent_presence = AgentDetectionPresence::from_agent(None); state = AgentState::Unknown; last_visible_idle = false; last_visible_blocker = false; last_visible_working = false; last_visible_signal_refresh = None; last_process_check = std::time::Instant::now(); last_foreground_pgid = None; has_process_probe = false; acquisition_started_at = None; last_content_change_at = None; pending_foreground_shell_clear = false; foreground_shell_exit_reported = false; release_was_active = false; last_detection_text.clear(); last_screen_scan_detection_content_seq = None; agent_startup_grace_until = None; pending_idle.clear(); } } let now = std::time::Instant::now(); let suppressed_agent = active_pending_release(&pending_release_for_task, now); if suppressed_agent.is_none() && release_was_active { has_process_probe = false; acquisition_started_at = None; last_content_change_at = None; } release_was_active = suppressed_agent.is_some(); let pid = child_pid.load(Ordering::Acquire); let mut agent_changed = false; let mut agent = agent_presence.current_agent(); let lifecycle_authority_active = full_lifecycle_authority_active.load(Ordering::Acquire); let foreground_pgid = (pid > 0) .then(|| crate::detect::foreground_process_group_id(pid)) .flatten(); let process_group_changed = foreground_group_changed(foreground_pgid, last_foreground_pgid); let should_check_process = pid > 0 && { let process_probe_input = ProcessProbeInput { current_agent: agent, suppressed_agent, foreground_pgid, last_foreground_pgid, has_process_probe, acquisition_age: acquisition_started_at .map(|started| now.duration_since(started)), pending_foreground_shell_clear, pending_restore_probe: false, elapsed_since_process_check: now.duration_since(last_process_check), }; !should_skip_process_probe_for_lifecycle_authority( lifecycle_authority_active, process_probe_input, ) && should_probe_foreground_job(process_probe_input) }; if should_check_process { last_process_check = now; let had_process_probe = has_process_probe; has_process_probe = true; let probe = probe_foreground_process(pid, foreground_pgid); let process_group_id = probe.process_group_id; let tracked_process_group_id = process_group_for_change_tracking(foreground_pgid, process_group_id); let foreground_is_pane_shell = probe.foreground_is_pane_shell; let mut new_agent = probe.agent; if let Some(suppressed_agent) = suppressed_agent { if new_agent == Some(suppressed_agent) { new_agent = None; } else if let Ok(mut pending_release) = pending_release_for_task.lock() { *pending_release = None; } } let previous_agent = agent_presence.current_agent(); let foreground_action = foreground_shell_agent_action( previous_agent, new_agent, foreground_is_pane_shell, foreground_shell_exit_reported, ); let changed = apply_foreground_shell_agent_action( &mut agent_presence, foreground_action, previous_agent, new_agent, &mut pending_foreground_shell_clear, &mut foreground_shell_exit_reported, ); last_foreground_pgid = tracked_process_group_id; if new_agent.is_some() { acquisition_started_at = None; last_content_change_at = None; } else if agent_presence.current_agent().is_none() && had_process_probe && process_group_changed { acquisition_started_at = Some(now); } if changed { agent = agent_presence.current_agent(); agent_changed = previous_agent != agent || foreground_action == ForegroundShellAgentAction::ReportReplacementProcess; if agent_changed { pending_idle.clear(); last_screen_scan_detection_content_seq = None; // A replacement agent must not inherit OSC evidence // from the previous process; a first acquisition keeps // the evidence its own process already emitted. clear_osc_evidence_for_agent_transition(&terminal, previous_agent); if let Some(agent) = agent { agent_startup_grace_until = Some(now + AGENT_STARTUP_GRACE_WINDOW); state = AgentState::Unknown; last_visible_idle = false; last_visible_blocker = false; last_visible_working = false; last_visible_signal_refresh = None; publish_agent_process_detected_event( state_events.clone(), pane_id, agent, now, ) .await; } else { agent_startup_grace_until = None; } } } } let process_exited = pending_foreground_shell_clear && agent.is_some() && !foreground_shell_exit_reported; if lifecycle_authority_active && !process_exited { pending_idle.clear(); continue; } if let Some(until) = agent_startup_grace_until { if process_exited { agent_startup_grace_until = None; pending_idle.clear(); } else { if now < until { pending_idle.clear(); continue; } agent_startup_grace_until = None; last_screen_scan_detection_content_seq = None; pending_idle.clear(); continue; } } let current_detection_content_seq = if agent.is_some() { Some(detection_content_seq.load(Ordering::Relaxed)) } else { None }; match decide_detection_screen_read(DetectionScreenReadInput { state, agent, pending_idle_active: pending_idle.active(), agent_changed, process_exited, current_detection_content_seq, last_screen_scan_detection_content_seq, }) { DetectionScreenReadDecision::Read => {} DetectionScreenReadDecision::Skip => continue, } let content = terminal.detection_text(); last_screen_scan_detection_content_seq = current_detection_content_seq; let content_changed = content != last_detection_text; last_detection_text.clone_from(&content); if !process_exited && crate::detect::should_skip_state_update(agent, &content) { pending_idle.clear(); continue; } sync_content_change_acquisition( agent_presence.current_agent(), suppressed_agent, process_group_changed, content_changed, now, &mut acquisition_started_at, &mut last_content_change_at, ); let osc_title = terminal.agent_osc_title(); let osc_progress = terminal.agent_osc_progress(); let Some(screen_detection) = detection_update_for_publish_with_osc( agent, &content, &osc_title, &osc_progress, process_exited, ) else { pending_idle.clear(); continue; }; match decide_screen_detection_publish( ScreenDetectionPublishInput { screen_detection, current_state: state, last_visible_idle, last_visible_blocker, last_visible_working, last_visible_signal_refresh, process_exited, agent_changed, now, }, &mut pending_idle, ) { DetectionPublishDecision::NoPublish => {} DetectionPublishDecision::Publish { state: new_state, visible_idle, visible_blocker, visible_working, process_exited: publish_process_exited, } => { apply_agent_detection_publish_update( state_events.clone(), pane_id, agent, AgentDetectionPublishUpdate { state: new_state, visible_idle, visible_blocker, visible_working, process_exited: publish_process_exited, }, now, &mut state, &mut last_visible_idle, &mut last_visible_blocker, &mut last_visible_working, &mut last_visible_signal_refresh, &mut foreground_shell_exit_reported, ) .await; } } } }); (handle.abort_handle(), detect_reset_notify, pending_release) } impl AgentDetectionPresence { fn from_agent(current_agent: Option) -> Self { Self { current_agent, consecutive_misses: 0, } } fn current_agent(&self) -> Option { self.current_agent } fn clear_current_agent(&mut self) -> bool { if self.current_agent.is_none() { self.consecutive_misses = 0; return false; } self.current_agent = None; self.consecutive_misses = 0; true } fn observe_process_probe(&mut self, identified_agent: Option) -> bool { match identified_agent { Some(agent) => { self.consecutive_misses = 0; if Some(agent) == self.current_agent { return false; } self.current_agent = Some(agent); true } None => { if self.current_agent.is_none() { self.consecutive_misses = 0; return false; } self.consecutive_misses = self.consecutive_misses.saturating_add(1); if self.consecutive_misses < AGENT_MISS_CONFIRMATION_ATTEMPTS { return false; } self.current_agent = None; self.consecutive_misses = 0; true } } } } // --------------------------------------------------------------------------- // PaneRuntime — PTY, parser, channels, background tasks // --------------------------------------------------------------------------- /// PTY runtime for a pane. Owns the terminal, I/O channels, and background tasks. /// Dropping this shuts down all background tasks and closes the PTY. pub struct PaneRuntime { pane_id: PaneId, terminal: Arc, io: PaneRuntimeIo, current_size: Cell<(u16, u16, u32, u32)>, child_pid: Arc, reported_cwd: Arc>>, child_wait_completed: Option>, kitty_keyboard_flags: Arc, content_seq: Arc, content_write_lock: Arc>, detection_content_seq: Arc, full_lifecycle_authority_active: Arc, detect_reset_notify: Arc, pending_release: Arc>>, preserve_processes_on_drop: bool, // Task handles for deterministic shutdown detect_handle: Option, } enum PaneRuntimeIo { Actor(PtyIoActorHandle), #[cfg(test)] TestChannel { sender: mpsc::Sender, resize_tx: watch::Sender<(u16, u16, u32, u32)>, }, } impl PaneRuntimeIo { fn shutdown(&self) { match self { PaneRuntimeIo::Actor(actor) => actor.shutdown(), #[cfg(test)] PaneRuntimeIo::TestChannel { .. } => {} } } #[cfg(unix)] fn duplicate_handoff_fd(&self) -> std::io::Result { match self { PaneRuntimeIo::Actor(actor) => actor.duplicate_for_handoff(), #[cfg(test)] PaneRuntimeIo::TestChannel { .. } => { Err(std::io::Error::other("test runtime has no PTY master fd")) } } } #[cfg(unix)] fn foreground_process_group_id(&self) -> Option { match self { PaneRuntimeIo::Actor(actor) => actor.foreground_process_group_id(), #[cfg(test)] PaneRuntimeIo::TestChannel { .. } => None, } } #[cfg(unix)] fn begin_handoff(&self, timeout: std::time::Duration) -> std::io::Result<()> { match self { PaneRuntimeIo::Actor(actor) => actor.begin_handoff(timeout), #[cfg(test)] PaneRuntimeIo::TestChannel { .. } => Ok(()), } } #[cfg(unix)] fn set_handoff_paused(&self, paused: bool) -> std::io::Result<()> { match self { PaneRuntimeIo::Actor(actor) => { if paused { actor.begin_handoff(std::time::Duration::from_secs(1)) } else { actor.rollback_handoff() } } #[cfg(test)] PaneRuntimeIo::TestChannel { .. } => Ok(()), } } #[cfg(unix)] fn release_after_commit(&self) -> std::io::Result<()> { match self { PaneRuntimeIo::Actor(actor) => actor.release_after_commit(), #[cfg(test)] PaneRuntimeIo::TestChannel { .. } => Ok(()), } } fn resize( &self, rows: u16, cols: u16, cell_width_px: u32, cell_height_px: u32, terminal_responses: Vec, ) { match self { PaneRuntimeIo::Actor(actor) => { actor.resize( rows, cols, cell_width_px, cell_height_px, terminal_responses, ); } #[cfg(test)] PaneRuntimeIo::TestChannel { resize_tx, .. } => { let _ = resize_tx.send((rows, cols, cell_width_px, cell_height_px)); } } } #[cfg(unix)] fn nudge_child_redraw_after_handoff( &self, rows: u16, cols: u16, cell_width_px: u32, cell_height_px: u32, ) { match self { PaneRuntimeIo::Actor(actor) => { actor.nudge_child_redraw_after_handoff(rows, cols, cell_width_px, cell_height_px); } #[cfg(test)] PaneRuntimeIo::TestChannel { .. } => {} } } fn try_send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::TrySendError> { match self { PaneRuntimeIo::Actor(actor) => actor.try_write_user_input(bytes), #[cfg(test)] PaneRuntimeIo::TestChannel { sender, .. } => sender.try_send(bytes), } } fn write_terminal_response(&self, response: impl FnOnce() -> Option) { match self { PaneRuntimeIo::Actor(actor) => actor.write_terminal_response(response), #[cfg(test)] PaneRuntimeIo::TestChannel { sender, .. } => { if let Some(bytes) = response() { let _ = sender.try_send(bytes); } } } } fn queue_user_input_submission( &self, text: Bytes, enter: Bytes, delay: std::time::Duration, ) -> std::io::Result>> { match self { PaneRuntimeIo::Actor(actor) => actor.queue_user_input_submission(text, enter, delay), #[cfg(test)] PaneRuntimeIo::TestChannel { sender, .. } => { let sender = sender.clone(); let (reply_tx, reply_rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let result = sender .try_send(text) .map_err(std::io::Error::other) .and_then(|()| { std::thread::sleep(delay); sender.try_send(enter).map_err(std::io::Error::other) }); let _ = reply_tx.send(result); }); Ok(reply_rx) } } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WheelRouting { HostScroll, MouseReport, AlternateScroll, } impl Drop for PaneRuntime { fn drop(&mut self) { // Abort detection task immediately and terminate the owned session. // The PTY actor shuts down before the process/session policy runs. if let Some(handle) = &self.detect_handle { handle.abort(); } self.io.shutdown(); if !self.preserve_processes_on_drop { shutdown_pane_processes( self.pane_id, self.child_pid.load(Ordering::Acquire), self.child_wait_completed.as_deref(), ); } } } fn process_alive_for_shutdown( pid: u32, child_pid: u32, child_wait_completed: bool, process_exists: impl FnOnce(u32) -> bool, ) -> bool { if pid == child_pid && child_wait_completed { return false; } process_exists(pid) } fn wait_for_processes_to_exit( pids: &[u32], child_pid: u32, child_wait_completed: Option<&AtomicBool>, timeout: std::time::Duration, ) -> bool { let deadline = std::time::Instant::now() + timeout; loop { let child_wait_completed = child_wait_completed.is_some_and(|flag| flag.load(Ordering::Acquire)); if pids.iter().all(|pid| { !process_alive_for_shutdown( *pid, child_pid, child_wait_completed, crate::platform::process_exists, ) }) { return true; } if std::time::Instant::now() >= deadline { return false; } std::thread::sleep(std::time::Duration::from_millis(20)); } } fn shutdown_pane_processes( pane_id: PaneId, child_pid: u32, child_wait_completed: Option<&AtomicBool>, ) { if child_pid == 0 { return; } let mut pids = crate::platform::session_processes(child_pid); if pids.is_empty() { pids.push(child_pid); } pids.sort_unstable(); pids.dedup(); for (signal, grace) in [ ( crate::platform::Signal::Hangup, std::time::Duration::from_millis(250), ), ( crate::platform::Signal::Terminate, std::time::Duration::from_millis(250), ), ( crate::platform::Signal::Kill, std::time::Duration::from_millis(250), ), ] { crate::platform::signal_processes(&pids, signal); if wait_for_processes_to_exit(&pids, child_pid, child_wait_completed, grace) { info!( pane = pane_id.raw(), pid = child_pid, ?signal, "pane session terminated" ); return; } } warn!( pane = pane_id.raw(), pid = child_pid, pids = ?pids, "pane session still alive after forced shutdown" ); } #[cfg(unix)] fn truncate_handoff_history(history: String, max_bytes: usize) -> String { if history.len() <= max_bytes { return history; } let mut start = history.len().saturating_sub(max_bytes); while !history.is_char_boundary(start) { start += 1; } let Some(newline_offset) = history[start..].find('\n') else { return String::new(); }; start += newline_offset + 1; history[start..].to_owned() } fn pane_shell(configured_shell: &str) -> String { pane_shell_from(configured_shell, std::env::var("SHELL").ok()) } fn pane_shell_from(configured_shell: &str, env_shell: Option) -> String { let configured_shell = configured_shell.trim(); if !configured_shell.is_empty() { return configured_shell.to_string(); } #[cfg(windows)] { let _ = env_shell; default_pane_shell() } #[cfg(not(windows))] env_shell .map(|shell| shell.trim().to_string()) .filter(|shell| !shell.is_empty()) .unwrap_or_else(default_pane_shell) } #[cfg(windows)] fn default_pane_shell() -> String { "powershell.exe".into() } #[cfg(not(windows))] fn default_pane_shell() -> String { "/bin/sh".into() } #[derive(Clone, Copy)] pub(crate) struct PaneShellConfig<'a> { pub(crate) default_shell: &'a str, pub(crate) mode: crate::config::ShellModeConfig, } impl<'a> PaneShellConfig<'a> { pub(crate) fn new(default_shell: &'a str, mode: crate::config::ShellModeConfig) -> Self { Self { default_shell, mode, } } } /// Target platform for shell launch policy. Parameterized (instead of raw /// `cfg!` checks at each decision point) so every branch stays testable on /// every host platform. #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum ShellLaunchTarget { Windows, Macos, OtherUnix, } impl ShellLaunchTarget { fn current() -> Self { if cfg!(windows) { Self::Windows } else if cfg!(target_os = "macos") { Self::Macos } else { Self::OtherUnix } } } fn shell_mode_uses_login_shell( mode: crate::config::ShellModeConfig, target: ShellLaunchTarget, ) -> bool { match mode { crate::config::ShellModeConfig::Auto => target == ShellLaunchTarget::Macos, crate::config::ShellModeConfig::Login => true, crate::config::ShellModeConfig::NonLogin => false, } } fn is_executable_file(path: &Path) -> bool { let Ok(metadata) = path.metadata() else { return false; }; if !metadata.is_file() { return false; } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; metadata.permissions().mode() & 0o111 != 0 } #[cfg(not(unix))] { true } } fn resolve_shell_for_login_mode(shell: &str) -> io::Result { if shell.contains(std::path::MAIN_SEPARATOR) { let path = Path::new(shell); return is_executable_file(path) .then(|| shell.to_string()) .ok_or_else(|| { io::Error::new( io::ErrorKind::NotFound, format!("login shell {shell:?} is not executable"), ) }); } std::env::var_os("PATH") .and_then(|path| { std::env::split_paths(&path) .map(|dir| dir.join(shell)) .find(|candidate| is_executable_file(candidate)) }) .and_then(|path| path.into_os_string().into_string().ok()) .ok_or_else(|| { io::Error::new( io::ErrorKind::NotFound, format!("login shell {shell:?} was not found on PATH"), ) }) } /// Sourced via `-NoExit -Command` when launching PowerShell on Windows. It /// wraps whatever `prompt` function the user's profile left behind so each /// prompt render appends the cwd as OSC 9;9 — the sequence Windows Terminal /// and ConEmu standardized for shell integration. PowerShell never updates /// its Win32 process cwd on `Set-Location`, so the prompt hook updates it when /// possible before reporting the cwd. /// /// The snippet must not contain double quotes: powershell.exe parses its /// command line with its own rules that disagree with the ArgvQuote escaping /// portable-pty applies, and embedded `\"` sequences get corrupted in /// transit. Single-quoted strings and `[char]` codes keep the round-trip /// byte-exact, and the OSC 9;9 payload is emitted unquoted (the original /// ConEmu form, which the cwd tracker accepts). /// /// The original prompt must be invoked before any other statement in the /// wrapper: anything that runs first resets `$?`, so a status-aware user /// prompt would show success after a failed command (verified on 5.1). pub(crate) const WINDOWS_POWERSHELL_SHELL_INTEGRATION_COMMAND: &str = r"if ($null -eq $global:__HerdrOriginalPrompt) { $global:__HerdrOriginalPrompt = $function:prompt; function global:prompt { $out = @(& $global:__HerdrOriginalPrompt) -join ' '; $loc = $ExecutionContext.SessionState.Path.CurrentLocation; if ($loc.Provider.Name -eq 'FileSystem') { try { [Environment]::CurrentDirectory = $loc.ProviderPath } catch {}; $esc = [string][char]27; $out += $esc + ']9;9;' + $loc.ProviderPath + $esc + '\' }; $out } }"; fn pane_shell_command_builder_for_target( shell_config: PaneShellConfig<'_>, target: ShellLaunchTarget, ) -> io::Result { let shell = pane_shell(shell_config.default_shell); if shell_mode_uses_login_shell(shell_config.mode, target) { let mut cmd = CommandBuilder::new_default_prog(); cmd.env("SHELL", resolve_shell_for_login_mode(&shell)?); Ok(cmd) } else { let mut cmd = CommandBuilder::new(&shell); if uses_windows_powershell_pane_shell_for_target(shell_config, target) { cmd.args([ "-NoExit", "-Command", WINDOWS_POWERSHELL_SHELL_INTEGRATION_COMMAND, ]); } Ok(cmd) } } fn pane_shell_command_builder(shell_config: PaneShellConfig<'_>) -> io::Result { pane_shell_command_builder_for_target(shell_config, ShellLaunchTarget::current()) } /// True when panes launch an interactive PowerShell directly on Windows. /// Gates the prompt-based cwd reporting pipeline and the agent-exit shell /// respawn recovery. pub(crate) fn uses_windows_powershell_pane_shell(shell_config: PaneShellConfig<'_>) -> bool { uses_windows_powershell_pane_shell_for_target(shell_config, ShellLaunchTarget::current()) } fn uses_windows_powershell_pane_shell_for_target( shell_config: PaneShellConfig<'_>, target: ShellLaunchTarget, ) -> bool { target == ShellLaunchTarget::Windows && !shell_mode_uses_login_shell(shell_config.mode, target) && is_powershell_shell(&pane_shell(shell_config.default_shell)) } fn is_powershell_shell(shell: &str) -> bool { // Split on both separators by hand: `Path::file_name` only treats `\` as // a separator on Windows hosts, and this predicate must evaluate Windows // shell paths correctly from tests on any host. let name = shell .rsplit(['/', '\\']) .next() .unwrap_or(shell) .to_ascii_lowercase(); matches!( name.as_str(), "powershell" | "powershell.exe" | "pwsh" | "pwsh.exe" ) } fn usable_reported_cwd(cwd: std::path::PathBuf) -> Option { (cwd.is_absolute() && cwd.is_dir()).then_some(cwd) } fn publish_terminal_bells(pane_id: PaneId, count: u16, events: &mpsc::Sender) { if count == 0 { return; } if let Err(err) = events.try_send(AppEvent::TerminalBell { pane_id, count }) { warn!( pane = pane_id.raw(), count, err = %err, "failed to queue terminal bell" ); } } fn publish_reported_cwd( pane_id: PaneId, cwd: std::path::PathBuf, reported_cwd: &Arc>>, events: &mpsc::Sender, ) { let Some(cwd) = usable_reported_cwd(cwd) else { return; }; if let Ok(mut current) = reported_cwd.lock() { if current.as_ref() == Some(&cwd) { return; } *current = Some(cwd.clone()); } if let Err(err) = events.try_send(AppEvent::TerminalCwdReported { pane_id, cwd }) { warn!( pane = pane_id.raw(), err = %err, "failed to send terminal cwd report" ); } } impl PaneRuntime { pub fn shutdown(mut self) { if let Some(handle) = self.detect_handle.take() { handle.abort(); } self.io.shutdown(); shutdown_pane_processes( self.pane_id, self.child_pid.load(Ordering::Acquire), self.child_wait_completed.as_deref(), ); self.preserve_processes_on_drop = true; } #[cfg(unix)] pub fn duplicate_handoff_fd(&self) -> std::io::Result { self.io.duplicate_handoff_fd() } #[cfg(unix)] pub fn preserve_for_handoff(mut self) { if let Err(err) = self.io.release_after_commit() { warn!( pane = self.pane_id.raw(), err = %err, "failed to release PTY actor after handoff commit; dropping runtime will still close the actor handle" ); } if let Some(handle) = self.detect_handle.take() { handle.abort(); } self.preserve_processes_on_drop = true; } #[cfg(unix)] pub fn assume_handoff_ownership(&mut self) { self.preserve_processes_on_drop = false; } #[cfg(unix)] pub fn set_handoff_reader_paused(&self, paused: bool) { if let Err(err) = self.io.set_handoff_paused(paused) { warn!( pane = self.pane_id.raw(), err = %err, paused, "failed to update PTY actor handoff pause state" ); } } #[cfg(unix)] pub fn pause_handoff_reader(&self, timeout: std::time::Duration) -> std::io::Result<()> { self.io.begin_handoff(timeout) } #[cfg(unix)] pub fn handoff_runtime_state( &self, pane_id: u32, ) -> crate::handoff_runtime::HandoffRuntimeState { let child_pid = self.child_pid.load(Ordering::Acquire); let (rows, cols, cell_width_px, cell_height_px) = self.current_size.get(); crate::handoff_runtime::HandoffRuntimeState { pane_id, child_pid, rows, cols, cell_width_px, cell_height_px, keyboard_protocol_flags: match self.keyboard_protocol() { crate::input::KeyboardProtocol::Legacy => 0, crate::input::KeyboardProtocol::Kitty { flags } => flags, }, keyboard_protocol_ansi: self.terminal.kitty_keyboard_state_ansi(), input_state: self.input_state(), terminal_title: self.terminal_title(), initial_history_ansi: None, } } #[cfg(unix)] pub fn handoff_history_ansi(&self) -> Option { if self.terminal.alternate_screen_active() { return None; } self.snapshot_history().map(|history| { truncate_handoff_history(history, crate::server::handoff::MAX_REPLAY_BYTES_PER_PANE) }) } pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) { self.terminal.apply_host_terminal_theme(theme); } pub fn apply_host_terminal_appearance( &self, appearance: Option, ) { self.io .write_terminal_response(|| self.terminal.apply_host_terminal_appearance(appearance)); } // Runtime construction threads PTY geometry, host context, launch policy, and render hooks. #[allow(clippy::too_many_arguments)] pub fn spawn( pane_id: PaneId, rows: u16, cols: u16, cwd: std::path::PathBuf, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, host_terminal_appearance: Option, shell_config: PaneShellConfig<'_>, launch_env: &PaneLaunchEnv, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, ) -> std::io::Result { Self::spawn_with_initial_history( pane_id, rows, cols, cwd, scrollback_limit_bytes, host_terminal_theme, host_terminal_appearance, shell_config, launch_env, None, events, render_notify, render_dirty, ) } // Runtime construction needs to thread PTY size, environment, theme, and render hooks together. #[allow(clippy::too_many_arguments)] pub(crate) fn spawn_with_initial_history( pane_id: PaneId, rows: u16, cols: u16, cwd: std::path::PathBuf, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, host_terminal_appearance: Option, shell_config: PaneShellConfig<'_>, launch_env: &PaneLaunchEnv, initial_history_ansi: Option<&str>, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, ) -> std::io::Result { let windows_powershell_prompt_cwd_reporting = uses_windows_powershell_pane_shell(shell_config); let mut cmd = pane_shell_command_builder(shell_config)?; cmd.cwd(cwd); apply_pane_terminal_env(&mut cmd); apply_pane_launch_env(&mut cmd, launch_env); Self::spawn_command_builder( pane_id, rows, cols, scrollback_limit_bytes, host_terminal_theme, host_terminal_appearance, events, render_notify, render_dirty, cmd, "failed to spawn shell", SpawnInitialState { detected_agent: None, history_ansi: initial_history_ansi, windows_powershell_prompt_cwd_reporting, }, AgentDetection::Enabled, ) } // Runtime construction needs to thread PTY size, environment, theme, and render hooks together. #[allow(clippy::too_many_arguments)] pub fn spawn_shell_command( pane_id: PaneId, rows: u16, cols: u16, cwd: std::path::PathBuf, command: &str, launch_env: &PaneLaunchEnv, agent_detection: AgentDetection, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, host_terminal_appearance: Option, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, ) -> std::io::Result { let mut cmd = crate::platform::pane_custom_command_pty_builder(command); cmd.cwd(cwd); apply_pane_terminal_env(&mut cmd); apply_pane_launch_env(&mut cmd, launch_env); Self::spawn_command_builder( pane_id, rows, cols, scrollback_limit_bytes, host_terminal_theme, host_terminal_appearance, events, render_notify, render_dirty, cmd, "failed to spawn command pane", SpawnInitialState::default(), agent_detection, ) } // Runtime construction needs to thread PTY size, environment, theme, render hooks, and detection policy together. #[allow(clippy::too_many_arguments)] pub fn spawn_argv_command( pane_id: PaneId, rows: u16, cols: u16, cwd: std::path::PathBuf, argv: &[String], launch_env: &PaneLaunchEnv, agent_detection: AgentDetection, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, host_terminal_appearance: Option, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, ) -> std::io::Result { let Some((program, args)) = argv.split_first() else { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "argv must not be empty", )); }; let mut cmd = CommandBuilder::new(program); for arg in args { cmd.arg(arg); } cmd.cwd(cwd); apply_pane_terminal_env(&mut cmd); apply_pane_launch_env(&mut cmd, launch_env); Self::spawn_command_builder( pane_id, rows, cols, scrollback_limit_bytes, host_terminal_theme, host_terminal_appearance, events, render_notify, render_dirty, cmd, "failed to spawn argv command pane", SpawnInitialState::default(), agent_detection, ) } #[cfg(unix)] pub fn from_handoff_fd( import: crate::handoff_runtime::ImportedHandoffRuntime, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, host_terminal_appearance: Option, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, ) -> std::io::Result { let crate::handoff_runtime::ImportedHandoffRuntime { master_fd, state } = import; let crate::handoff_runtime::HandoffRuntimeState { pane_id, child_pid, rows, cols, cell_width_px, cell_height_px, keyboard_protocol_flags, keyboard_protocol_ansi, input_state, terminal_title, initial_history_ansi, } = state; let pane_id = PaneId::from_raw(pane_id); use std::os::fd::FromRawFd; let master_fd = unsafe { std::os::fd::OwnedFd::from_raw_fd(master_fd) }; let (response_tx, _response_rx) = mpsc::channel::(1); let mut terminal = crate::ghostty::Terminal::new(cols, rows, scrollback_limit_bytes) .map_err(|e| std::io::Error::other(e.to_string()))?; terminal .resize(cols, rows, cell_width_px, cell_height_px) .map_err(|e| std::io::Error::other(e.to_string()))?; if crate::kitty_graphics::is_enabled() { terminal .enable_kitty_graphics() .map_err(|e| std::io::Error::other(e.to_string()))?; } let pane_terminal = GhosttyPaneTerminal::new(terminal, response_tx.clone())?; pane_terminal.apply_host_terminal_theme(host_terminal_theme); let _ = pane_terminal.apply_host_terminal_appearance(host_terminal_appearance); pane_terminal.seed_terminal_title(terminal_title); if let Some(input_state) = input_state { pane_terminal.seed_handoff_input_state(input_state); } if let Some(ansi) = keyboard_protocol_ansi.as_deref() { pane_terminal.seed_keyboard_protocol_ansi(ansi); } else { pane_terminal.seed_keyboard_protocol_flags(keyboard_protocol_flags); } if let Some(ansi) = initial_history_ansi.as_deref() { pane_terminal.seed_history_ansi(ansi); } let terminal = Arc::new(PaneTerminal::new(pane_terminal)); let child_pid = Arc::new(AtomicU32::new(child_pid)); let reported_cwd = Arc::new(Mutex::new(None)); let kitty_keyboard_flags = Arc::new(AtomicU16::new(keyboard_protocol_flags)); let content_seq = Arc::new(AtomicU64::new(0)); let content_write_lock = Arc::new(Mutex::new(())); let detection_content_seq = Arc::new(AtomicU64::new(0)); let io = { let terminal = terminal.clone(); let response_writer = response_tx.clone(); let render_notify = render_notify.clone(); let render_dirty = render_dirty.clone(); let content_seq = content_seq.clone(); let content_write_lock = content_write_lock.clone(); let detection_content_seq = detection_content_seq.clone(); let child_pid = child_pid.clone(); let read_events = events.clone(); let reported_cwd = reported_cwd.clone(); let rt = tokio::runtime::Handle::current(); let delay_rt = rt.clone(); let on_read = Box::new(move |bytes: &[u8]| { let _content_write_guard = match content_write_lock.lock() { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), }; content_seq.fetch_add(1, Ordering::AcqRel); let shell_pid = child_pid.load(Ordering::Acquire); let result = terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer); content_seq.fetch_add(1, Ordering::Release); drop(_content_write_guard); publish_terminal_bells(pane_id, result.terminal_bells, &read_events); observe_detection_content_change(bytes, &detection_content_seq); let title_requested = result.terminal_title_changed && render_dirty.request_terminal_title(pane_id); let render_requested = result.request_render && render_dirty.request_pty(pane_id); if title_requested || render_requested { render_notify.notify_one(); } if let Some(delay) = result.render_delay { let render_notify = render_notify.clone(); let render_dirty = render_dirty.clone(); delay_rt.spawn(async move { tokio::time::sleep(delay).await; if render_dirty.request_pty(pane_id) { render_notify.notify_one(); } }); } if let Some(cwd) = result.reported_cwd.clone() { publish_reported_cwd(pane_id, cwd, &reported_cwd, &read_events); } for content in result.clipboard_writes { if let Err(err) = read_events.try_send(AppEvent::ClipboardWrite { content }) { warn!( pane = pane_id.raw(), err = %err, "failed to queue OSC 52 clipboard write" ); } } PtyReadResult { terminal_responses: result.terminal_responses, } }); let exit_events = events.clone(); let on_reader_exit = Box::new(move || { let _ = rt.block_on(exit_events.send(AppEvent::PaneDied { pane_id })); debug!(pane = pane_id.raw(), "handoff PTY actor exiting"); }); PaneRuntimeIo::Actor(PtyIoActor::spawn(PtyIoActorConfig { pane_id: pane_id.raw(), master_fd, initially_quiesced: true, on_read, on_reader_exit: Some(on_reader_exit), })?) }; let full_lifecycle_authority_active = Arc::new(AtomicBool::new(false)); let (detect_handle, detect_reset_notify, pending_release) = spawn_basic_detection_task( pane_id, child_pid.clone(), terminal.clone(), detection_content_seq.clone(), full_lifecycle_authority_active.clone(), events, ); Ok(Self { pane_id, terminal, io, current_size: Cell::new((rows, cols, cell_width_px, cell_height_px)), child_pid, reported_cwd, child_wait_completed: None, kitty_keyboard_flags, content_seq, content_write_lock, detection_content_seq, full_lifecycle_authority_active, detect_reset_notify, pending_release, preserve_processes_on_drop: true, detect_handle: Some(detect_handle), }) } // Runtime construction needs to thread PTY size, environment, theme, render hooks, and detection policy together. #[allow(clippy::too_many_arguments)] fn spawn_command_builder( pane_id: PaneId, rows: u16, cols: u16, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, host_terminal_appearance: Option, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, cmd: CommandBuilder, spawn_error_message: &'static str, initial_state: SpawnInitialState<'_>, agent_detection: AgentDetection, ) -> std::io::Result { crate::logging::pane_spawn_started(pane_id.raw(), rows, cols, scrollback_limit_bytes); let (response_tx, _response_rx) = mpsc::channel::(1); let mut terminal = crate::ghostty::Terminal::new(cols, rows, scrollback_limit_bytes) .map_err(|e| std::io::Error::other(e.to_string()))?; if crate::kitty_graphics::is_enabled() { terminal .enable_kitty_graphics() .map_err(|e| std::io::Error::other(e.to_string()))?; } let pane_terminal = GhosttyPaneTerminal::new(terminal, response_tx.clone())?; pane_terminal.apply_host_terminal_theme(host_terminal_theme); let _ = pane_terminal.apply_host_terminal_appearance(host_terminal_appearance); pane_terminal.set_windows_powershell_prompt_cwd_reporting( initial_state.windows_powershell_prompt_cwd_reporting, ); if let Some(ansi) = initial_state.history_ansi { pane_terminal.seed_history_ansi(ansi); } let terminal = Arc::new(PaneTerminal::new(pane_terminal)); let kitty_keyboard_flags = Arc::new(AtomicU16::new(0)); let content_write_lock = Arc::new(Mutex::new(())); let spawned = crate::pty::backend::spawn_with_portable_pty(rows, cols, cmd) .inspect_err(|err| error!(pane = pane_id.raw(), err = %err, "{spawn_error_message}"))?; // --- Child watcher task --- let child_pid = Arc::new(AtomicU32::new(0)); let reported_cwd = Arc::new(Mutex::new(None)); let child_wait_completed = Arc::new(AtomicBool::new(false)); let content_seq = Arc::new(AtomicU64::new(0)); let detection_content_seq = Arc::new(AtomicU64::new(0)); let full_lifecycle_authority_active = Arc::new(AtomicBool::new(false)); { let child_pid = child_pid.clone(); let child_wait_completed = child_wait_completed.clone(); let events = events.clone(); let rt = tokio::runtime::Handle::current(); let mut child = spawned.child; if let Some(pid) = child.process_id() { child_pid.store(pid, Ordering::Release); crate::logging::pane_spawned(pane_id.raw(), pid); } tokio::task::spawn_blocking(move || { match child.wait() { Ok(status) => { let status_text = format!("{status:?}"); crate::logging::pane_exited(pane_id.raw(), &status_text); } Err(e) => crate::logging::pane_exit_failed(pane_id.raw(), &e.to_string()), } child_wait_completed.store(true, Ordering::Release); // Use blocking send — PaneDied is critical, must not be dropped if let Err(e) = rt.block_on(events.send(AppEvent::PaneDied { pane_id })) { error!(pane = pane_id.raw(), err = %e, "failed to send PaneDied event"); } }); } let io = { let terminal = terminal.clone(); let response_writer = response_tx.clone(); let render_notify = render_notify.clone(); let render_dirty = render_dirty.clone(); let content_seq = content_seq.clone(); let content_write_lock = content_write_lock.clone(); let detection_content_seq = detection_content_seq.clone(); let child_pid = child_pid.clone(); let events = events.clone(); let reported_cwd = reported_cwd.clone(); let rt = tokio::runtime::Handle::current(); let on_read = Box::new(move |bytes: &[u8]| { let _content_write_guard = match content_write_lock.lock() { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), }; content_seq.fetch_add(1, Ordering::AcqRel); let shell_pid = child_pid.load(Ordering::Acquire); let result = terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer); content_seq.fetch_add(1, Ordering::Release); drop(_content_write_guard); publish_terminal_bells(pane_id, result.terminal_bells, &events); if agent_detection == AgentDetection::Enabled { observe_detection_content_change(bytes, &detection_content_seq); } let title_requested = result.terminal_title_changed && render_dirty.request_terminal_title(pane_id); let render_requested = result.request_render && render_dirty.request_pty(pane_id); if title_requested || render_requested { render_notify.notify_one(); } if let Some(delay) = result.render_delay { let render_notify = render_notify.clone(); let render_dirty = render_dirty.clone(); rt.spawn(async move { tokio::time::sleep(delay).await; if render_dirty.request_pty(pane_id) { render_notify.notify_one(); } }); } if let Some(cwd) = result.reported_cwd.clone() { publish_reported_cwd(pane_id, cwd, &reported_cwd, &events); } for content in result.clipboard_writes { if let Err(err) = events.try_send(AppEvent::ClipboardWrite { content }) { warn!( pane = pane_id.raw(), err = %err, "failed to send OSC 52 clipboard write" ); } } PtyReadResult { terminal_responses: result.terminal_responses, } }); PaneRuntimeIo::Actor(PtyIoActor::spawn(PtyIoActorConfig { pane_id: pane_id.raw(), #[cfg(unix)] master_fd: spawned.master_fd, #[cfg(windows)] master: spawned.master, initially_quiesced: false, on_read, on_reader_exit: None, })?) }; // --- Detection task --- let (detect_handle, detect_reset_notify, pending_release) = if agent_detection == AgentDetection::Enabled { use crate::detect; use std::time::{Duration, Instant}; const TICK_UNIDENTIFIED: Duration = Duration::from_millis(500); const TICK_IDENTIFIED: Duration = Duration::from_millis(300); const TICK_PENDING_RELEASE: Duration = Duration::from_millis(50); let child_pid = child_pid.clone(); let terminal = terminal.clone(); let state_events = events.clone(); let detection_content_seq = detection_content_seq.clone(); let full_lifecycle_authority_active_for_task = full_lifecycle_authority_active.clone(); let render_notify = render_notify.clone(); let render_dirty = render_dirty.clone(); let detect_reset_notify = Arc::new(Notify::new()); let detect_reset = detect_reset_notify.clone(); let pending_release = Arc::new(Mutex::new(None)); let pending_release_for_task = pending_release.clone(); let handle = tokio::spawn(async move { let mut agent_presence = AgentDetectionPresence::from_agent(initial_state.detected_agent); let mut state = AgentState::Idle; let mut last_visible_idle = initial_state.detected_agent.is_some(); let mut last_process_check = Instant::now(); #[cfg(windows)] let mut last_observation = (Instant::now(), Some(0)); let mut last_foreground_pgid = None; let mut has_process_probe = false; let mut acquisition_started_at = None; let mut last_content_change_at = None; let mut pending_foreground_shell_clear = false; let mut foreground_shell_exit_reported = false; let mut release_was_active = false; let mut pending_restore_probe = initial_state.detected_agent.is_some(); let mut last_visible_blocker = false; let mut last_visible_working = false; let mut last_visible_signal_refresh = None; let mut last_detection_text = String::new(); let mut last_screen_scan_detection_content_seq = None; let mut agent_startup_grace_until = None; let mut pending_idle = PendingIdleConfirmation::default(); tokio::time::sleep(Duration::from_millis(50)).await; loop { let now_for_tick = Instant::now(); let tick = if active_pending_release(&pending_release_for_task, now_for_tick) .is_some() || terminal.has_transient_default_color_override() { TICK_PENDING_RELEASE } else if pending_idle.active() { AGENT_PENDING_IDLE_RECHECK } else if agent_presence.current_agent().is_none() { TICK_UNIDENTIFIED } else { TICK_IDENTIFIED }; tokio::select! { _ = tokio::time::sleep(tick) => {} _ = detect_reset.notified() => { agent_presence = AgentDetectionPresence::from_agent(None); state = AgentState::Unknown; last_visible_idle = false; last_foreground_pgid = None; has_process_probe = false; acquisition_started_at = None; last_content_change_at = None; pending_foreground_shell_clear = false; foreground_shell_exit_reported = false; release_was_active = false; pending_restore_probe = false; last_visible_blocker = false; last_visible_working = false; last_visible_signal_refresh = None; last_detection_text.clear(); last_screen_scan_detection_content_seq = None; agent_startup_grace_until = None; pending_idle.clear(); } } let now = Instant::now(); let suppressed_agent = active_pending_release(&pending_release_for_task, now); if suppressed_agent.is_none() && release_was_active { has_process_probe = false; acquisition_started_at = None; last_content_change_at = None; } release_was_active = suppressed_agent.is_some(); let pid = child_pid.load(Ordering::Acquire); let mut agent = agent_presence.current_agent(); let lifecycle_authority_active = full_lifecycle_authority_active_for_task.load(Ordering::Acquire); let process_probe_input = ProcessProbeInput { current_agent: agent, suppressed_agent, foreground_pgid: last_foreground_pgid, last_foreground_pgid, has_process_probe, acquisition_age: acquisition_started_at .map(|started| now.duration_since(started)), pending_foreground_shell_clear, pending_restore_probe, elapsed_since_process_check: now.duration_since(last_process_check), }; #[cfg(windows)] let content_seq = detection_content_seq.load(Ordering::Relaxed); #[cfg(windows)] let last_content_seq = last_observation.1; #[cfg(windows)] let foreground_observation_due = should_observe_foreground_process_group( lifecycle_authority_active, last_content_seq != Some(content_seq) && (last_content_seq.is_some() || now.duration_since(last_observation.0) >= TICK_IDENTIFIED), now.duration_since(last_observation.0), process_probe_input, ); #[cfg(not(windows))] let foreground_observation_due = true; let foreground_pgid = match (pid, foreground_observation_due) { (0, _) => None, (_, true) => detect::foreground_process_group_id(pid), _ => last_foreground_pgid, }; #[cfg(windows)] if pid > 0 && foreground_observation_due { let retry = last_content_seq.is_some() && last_content_seq != Some(content_seq); last_observation = (now, (!retry).then_some(content_seq)); } let process_group_changed = foreground_group_changed(foreground_pgid, last_foreground_pgid); let should_check_process = pid > 0 && { let process_probe_input = ProcessProbeInput { foreground_pgid, ..process_probe_input }; !should_skip_process_probe_for_lifecycle_authority( lifecycle_authority_active, process_probe_input, ) && should_probe_foreground_job(process_probe_input) }; let mut agent_changed = false; if should_check_process { last_process_check = now; let had_process_probe = has_process_probe; has_process_probe = true; if pid > 0 { let probe = probe_foreground_process(pid, foreground_pgid); let process_name = probe.process_name; let process_group_id = probe.process_group_id; let tracked_process_group_id = process_group_for_change_tracking( foreground_pgid, process_group_id, ); let foreground_is_pane_shell = probe.foreground_is_pane_shell; let mut new_agent = probe.agent; if let Some(suppressed_agent) = suppressed_agent { if new_agent == Some(suppressed_agent) { new_agent = None; } else if let Ok(mut pending_release) = pending_release_for_task.lock() { *pending_release = None; } } let previous_agent = agent_presence.current_agent(); let foreground_action = foreground_shell_agent_action( previous_agent, new_agent, foreground_is_pane_shell, foreground_shell_exit_reported, ); let changed = apply_foreground_shell_agent_action( &mut agent_presence, foreground_action, previous_agent, new_agent, &mut pending_foreground_shell_clear, &mut foreground_shell_exit_reported, ); last_foreground_pgid = tracked_process_group_id; if new_agent.is_some() { acquisition_started_at = None; last_content_change_at = None; } else if agent_presence.current_agent().is_none() && had_process_probe && process_group_changed { acquisition_started_at = Some(now); } pending_restore_probe = false; if changed { agent = agent_presence.current_agent(); if agent != previous_agent || foreground_action == ForegroundShellAgentAction::ReportReplacementProcess { pending_idle.clear(); last_screen_scan_detection_content_seq = None; // A replacement agent must not inherit OSC // evidence from the previous process; a first // acquisition keeps the evidence its own // process already emitted. clear_osc_evidence_for_agent_transition( &terminal, previous_agent, ); if let Some(agent) = agent { agent_startup_grace_until = Some(now + AGENT_STARTUP_GRACE_WINDOW); state = AgentState::Unknown; last_visible_idle = false; last_visible_blocker = false; last_visible_working = false; last_visible_signal_refresh = None; publish_agent_process_detected_event( state_events.clone(), pane_id, agent, now, ) .await; } else { agent_startup_grace_until = None; } } if let Some(process_name) = process_name { info!( pane = pane_id.raw(), previous_agent = ?previous_agent, ?agent, process = %process_name, pgid = ?process_group_id, "agent changed" ); } else { info!( pane = pane_id.raw(), previous_agent = ?previous_agent, ?agent, pgid = ?process_group_id, "agent changed" ); } agent_changed = true; } } } let pid = child_pid.load(Ordering::Acquire); // Keep the terminal restore side effect separate from render notification state. #[allow(clippy::collapsible_if)] if pid > 0 && terminal.maybe_restore_host_terminal_theme(pane_id, pid) { if render_dirty.request_pty(pane_id) { render_notify.notify_one(); } } let process_exited = pending_foreground_shell_clear && agent.is_some() && !foreground_shell_exit_reported; if lifecycle_authority_active && !process_exited { pending_idle.clear(); continue; } if let Some(until) = agent_startup_grace_until { if process_exited { agent_startup_grace_until = None; last_screen_scan_detection_content_seq = None; pending_idle.clear(); } else { if now < until { pending_idle.clear(); continue; } agent_startup_grace_until = None; pending_idle.clear(); continue; } } let current_detection_content_seq = if agent.is_some() { Some(detection_content_seq.load(Ordering::Relaxed)) } else { None }; match decide_detection_screen_read(DetectionScreenReadInput { state, agent, pending_idle_active: pending_idle.active(), agent_changed, process_exited, current_detection_content_seq, last_screen_scan_detection_content_seq, }) { DetectionScreenReadDecision::Read => {} DetectionScreenReadDecision::Skip => continue, } let content = terminal.detection_text(); last_screen_scan_detection_content_seq = current_detection_content_seq; let content_changed = content != last_detection_text; last_detection_text.clone_from(&content); if detect::should_skip_state_update(agent, &content) { pending_idle.clear(); continue; } sync_content_change_acquisition( agent_presence.current_agent(), suppressed_agent, process_group_changed, content_changed, now, &mut acquisition_started_at, &mut last_content_change_at, ); let osc_title = terminal.agent_osc_title(); let osc_progress = terminal.agent_osc_progress(); let Some(screen_detection) = detection_update_for_publish_with_osc( agent, &content, &osc_title, &osc_progress, process_exited, ) else { pending_idle.clear(); continue; }; match decide_screen_detection_publish( ScreenDetectionPublishInput { screen_detection, current_state: state, last_visible_idle, last_visible_blocker, last_visible_working, last_visible_signal_refresh, process_exited, agent_changed, now, }, &mut pending_idle, ) { DetectionPublishDecision::NoPublish => {} DetectionPublishDecision::Publish { state: new_state, visible_idle, visible_blocker, visible_working, process_exited: publish_process_exited, } => { apply_agent_detection_publish_update( state_events.clone(), pane_id, agent, AgentDetectionPublishUpdate { state: new_state, visible_idle, visible_blocker, visible_working, process_exited: publish_process_exited, }, now, &mut state, &mut last_visible_idle, &mut last_visible_blocker, &mut last_visible_working, &mut last_visible_signal_refresh, &mut foreground_shell_exit_reported, ) .await; } } } }); ( Some(handle.abort_handle()), detect_reset_notify, pending_release, ) } else { (None, Arc::new(Notify::new()), Arc::new(Mutex::new(None))) }; Ok(Self { pane_id, terminal, io, current_size: Cell::new((rows, cols, 0, 0)), child_pid, reported_cwd, child_wait_completed: Some(child_wait_completed), kitty_keyboard_flags, content_seq, content_write_lock, detection_content_seq, full_lifecycle_authority_active, detect_reset_notify, pending_release, preserve_processes_on_drop: false, detect_handle, }) } pub fn begin_graceful_release(&self, agent: Agent) { if let Ok(mut pending_release) = self.pending_release.lock() { *pending_release = Some(PendingAgentRelease { agent, until: std::time::Instant::now() + RELEASE_REACQUIRE_SUPPRESSION, }); } self.detect_reset_notify.notify_one(); } pub fn reset_agent_detection(&self) { self.detect_reset_notify.notify_one(); } #[cfg(test)] pub(crate) fn agent_detection_reset_notify_for_test(&self) -> Arc { self.detect_reset_notify.clone() } pub fn set_full_lifecycle_authority_active(&self, active: bool) { let previous = self .full_lifecycle_authority_active .swap(active, Ordering::AcqRel); if active && !previous { self.detect_reset_notify.notify_one(); } } pub(crate) fn current_size(&self) -> (u16, u16) { let (rows, cols, _, _) = self.current_size.get(); (rows, cols) } pub(crate) fn content_seq(&self) -> u64 { self.content_seq.load(Ordering::Acquire) } /// Resize if the dimensions actually changed. pub fn resize(&self, rows: u16, cols: u16, cell_width_px: u32, cell_height_px: u32) { let rows = rows.max(2); let cols = cols.max(4); let size = (rows, cols, cell_width_px, cell_height_px); if self.current_size.get() == size { return; } self.current_size.set(size); let _content_write_guard = match self.content_write_lock.lock() { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), }; self.content_seq.fetch_add(1, Ordering::AcqRel); let terminal_responses = self .terminal .resize(rows, cols, cell_width_px, cell_height_px); self.content_seq.fetch_add(1, Ordering::Release); drop(_content_write_guard); mark_detection_content_changed(&self.detection_content_seq); self.io.resize( rows, cols, cell_width_px, cell_height_px, terminal_responses, ); } #[cfg(unix)] pub fn nudge_child_redraw_after_handoff(&self) { let (rows, cols, cell_width_px, cell_height_px) = self.current_size.get(); self.io .nudge_child_redraw_after_handoff(rows, cols, cell_width_px, cell_height_px); } /// Scroll up by N lines (into scrollback history). pub fn scroll_up(&self, lines: usize) { self.terminal.scroll_up(lines); } /// Scroll down by N lines (toward live output). pub fn scroll_down(&self, lines: usize) { self.terminal.scroll_down(lines); } /// Reset scroll to live view (offset = 0). pub fn scroll_reset(&self) { self.terminal.scroll_reset(); } /// Set scrollback offset measured from the live bottom of the terminal. pub fn set_scroll_offset_from_bottom(&self, lines: usize) { self.terminal.set_scroll_offset_from_bottom(lines); } pub fn scroll_metrics(&self) -> Option { self.terminal.scroll_metrics() } pub(crate) fn search_text_window( &self, query: &str, case_sensitive: bool, direction: crate::pane::TerminalSearchDirection, cursor: crate::pane::TerminalTextPoint, previous: Option<( crate::pane::TerminalTextPoint, crate::pane::TerminalTextPoint, )>, limit: usize, ) -> crate::pane::TerminalSearchWindow { self.terminal .search_text_window(query, case_sensitive, direction, cursor, previous, limit) } pub(crate) fn word_motion_target( &self, row: u32, col: u16, motion: crate::pane::TerminalWordMotion, ) -> Option { self.terminal.word_motion_target(row, col, motion) } pub(crate) fn terminal_dimensions(&self) -> Option<(u16, u16)> { self.terminal.dimensions() } pub(crate) fn paragraph_motion_target( &self, row: u32, direction: i8, ) -> Option { self.terminal.paragraph_motion_target(row, direction) } #[cfg(any(unix, test))] pub fn input_state(&self) -> Option { self.terminal.input_state() } pub fn bracketed_paste_enabled(&self) -> bool { self.terminal.bracketed_paste_enabled() } pub fn focus_reporting_enabled(&self) -> bool { self.terminal.focus_reporting_enabled() } pub fn mouse_reporting_enabled(&self) -> bool { self.terminal.mouse_reporting_enabled() } pub fn sgr_pixel_mouse_enabled(&self) -> bool { self.terminal.sgr_pixel_mouse_enabled() } pub fn plain_page_keys_use_host_scrollback(&self) -> Option { self.terminal.plain_page_keys_use_host_scrollback() } pub fn alternate_screen_active(&self) -> bool { self.terminal.alternate_screen_active() } pub fn cursor_state(&self, area: Rect, show_cursor: bool) -> Option { if !show_cursor { return None; } let cursor = self.terminal.cursor_state()?; if cursor.x >= area.width || cursor.y >= area.height { return None; } Some(TerminalCursorState { x: area.x + cursor.x, y: area.y + cursor.y, visible: cursor.visible, shape: cursor.shape, }) } pub fn synchronized_output_active(&self) -> bool { self.terminal.synchronized_output_active() } pub fn visible_text(&self) -> String { self.terminal.visible_text() } pub fn visible_ansi(&self) -> String { self.terminal.visible_ansi() } pub fn detection_text(&self) -> String { self.terminal.detection_text() } pub fn terminal_title(&self) -> Option { self.terminal.terminal_title() } pub fn agent_osc_title(&self) -> String { self.terminal.agent_osc_title() } pub fn agent_osc_progress(&self) -> String { self.terminal.agent_osc_progress() } pub(crate) fn recent_text_snapshot(&self, lines: usize) -> TerminalReadSnapshot { self.terminal.recent_text_snapshot(lines) } pub(crate) fn recent_ansi_snapshot(&self, lines: usize) -> TerminalReadSnapshot { self.terminal.recent_ansi_snapshot(lines) } pub(crate) fn recent_unwrapped_text_snapshot(&self, lines: usize) -> TerminalReadSnapshot { self.terminal.recent_unwrapped_text_snapshot(lines) } pub fn recent_unwrapped_ansi(&self, lines: usize) -> String { self.terminal.recent_unwrapped_ansi(lines) } pub(crate) fn recent_unwrapped_ansi_snapshot(&self, lines: usize) -> TerminalReadSnapshot { self.terminal.recent_unwrapped_ansi_snapshot(lines) } pub fn snapshot_history(&self) -> Option { let ansi = self.recent_unwrapped_ansi(usize::MAX); (!ansi.trim().is_empty()).then_some(ansi) } pub fn extract_selection(&self, selection: &crate::selection::Selection) -> Option { self.terminal.extract_selection(selection) } pub fn render(&self, frame: &mut Frame, area: Rect, show_cursor: bool) { self.terminal.render(frame, area, show_cursor); } pub(crate) fn collect_dirty_patch( &self, area_width: u16, area_height: u16, ) -> TerminalDirtyPatchOutcome { self.terminal.collect_dirty_patch(area_width, area_height) } pub fn visible_hyperlinks(&self, area: Rect) -> Vec<((u16, u16), String, String)> { self.terminal.visible_hyperlinks(area) } pub fn kitty_image_placements_with_data_filter( &self, needs_data: F, ) -> Vec where F: FnMut(crate::ghostty::KittyImageDescriptor) -> bool, { self.terminal .kitty_image_placements_with_data_filter(needs_data) } pub fn keyboard_protocol(&self) -> crate::input::KeyboardProtocol { let fallback = crate::input::KeyboardProtocol::from_kitty_flags( self.kitty_keyboard_flags.load(Ordering::Relaxed), ); self.terminal.keyboard_protocol(fallback) } pub fn modify_other_keys_level(&self) -> u8 { self.terminal.modify_other_keys_level() } pub fn encode_terminal_key(&self, key: crate::input::TerminalKey) -> Vec { self.terminal .encode_terminal_key(key, self.keyboard_protocol()) } pub fn try_send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::TrySendError> { self.io.try_send_bytes(bytes) } pub fn queue_user_input_submission( &self, text: Bytes, enter: Bytes, delay: std::time::Duration, ) -> std::io::Result>> { self.io.queue_user_input_submission(text, enter, delay) } pub fn try_send_paste(&self, text: String) -> Result<(), mpsc::error::TrySendError> { self.try_send_bytes(self.paste_payload(text)) } fn paste_payload(&self, text: String) -> Bytes { let bracketed = self.bracketed_paste_enabled(); let payload = if bracketed { format!("\x1b[200~{text}\x1b[201~") } else { text }; Bytes::from(payload) } pub fn try_send_focus_event(&self, event: crate::ghostty::FocusEvent) -> bool { if !self.focus_reporting_enabled() { return false; } let Ok(bytes) = crate::ghostty::encode_focus(event) else { return false; }; if let Err(err) = self.try_send_bytes(Bytes::from(bytes)) { warn!(err = %err, ?event, "failed to forward pane focus event"); } true } pub fn wheel_routing(&self) -> Option { self.terminal.wheel_routing() } pub(crate) fn screen_text_snapshot( &self, ) -> Option<( crate::ghostty::ActiveScreen, u16, Vec, )> { self.terminal.screen_text_snapshot() } pub fn encode_mouse_button( &self, kind: crossterm::event::MouseEventKind, position: crate::input::mouse::Position, modifiers: crossterm::event::KeyModifiers, ) -> Option> { if !self.mouse_reporting_enabled() { return None; } self.terminal.encode_mouse_button(kind, position, modifiers) } pub(crate) fn encode_mouse_motion( &self, kind: crossterm::event::MouseEventKind, position: crate::input::mouse::Position, modifiers: crossterm::event::KeyModifiers, ) -> Option> { self.terminal.encode_mouse_motion(kind, position, modifiers) } pub(crate) fn encode_mouse_wheel( &self, kind: crossterm::event::MouseEventKind, position: crate::input::mouse::Position, modifiers: crossterm::event::KeyModifiers, ) -> Option> { if self.wheel_routing()? != WheelRouting::MouseReport { return None; } self.terminal.encode_mouse_wheel(kind, position, modifiers) } pub(crate) fn pixel_size(&self) -> Option<(u32, u32)> { let (rows, cols, cell_width_px, cell_height_px) = self.current_size.get(); let width = u32::from(cols).checked_mul(cell_width_px)?; let height = u32::from(rows).checked_mul(cell_height_px)?; (width > 0 && height > 0).then_some((width, height)) } pub fn encode_alternate_scroll( &self, kind: crossterm::event::MouseEventKind, ) -> Option> { if self.wheel_routing()? != WheelRouting::AlternateScroll { return None; } let key = match kind { crossterm::event::MouseEventKind::ScrollUp => crossterm::event::KeyCode::Up, crossterm::event::MouseEventKind::ScrollDown => crossterm::event::KeyCode::Down, _ => return None, }; Some(self.encode_terminal_key(crate::input::TerminalKey::new( key, crossterm::event::KeyModifiers::empty(), ))) } /// Get the current working directory of the child shell process. pub fn cwd(&self) -> Option { if let Some(cwd) = self .reported_cwd .lock() .ok() .and_then(|reported_cwd| reported_cwd.clone()) { return Some(cwd); } let pid = self.child_pid.load(Ordering::Relaxed); crate::platform::process_cwd(pid) } pub fn child_pid(&self) -> Option { let pid = self.child_pid.load(Ordering::Acquire); (pid > 0).then_some(pid) } pub fn follow_cwd(&self) -> Option { #[cfg(unix)] { let leader_cwd = self .io .foreground_process_group_id() .and_then(usable_process_cwd); leader_cwd.or_else(|| self.cwd()) } #[cfg(not(unix))] { self.cwd() } } /// Get the current working directory of the process group controlling the pane PTY. pub fn foreground_cwd(&self) -> Option { #[cfg(unix)] { let pid = self.child_pid.load(Ordering::Acquire); let shell_cwd = absolute_process_cwd(pid); let foreground_pgid = self .io .foreground_process_group_id() .or_else(|| crate::platform::foreground_process_group_id(pid)); let leader_cwd = foreground_pgid.and_then(absolute_process_cwd); // The group leader's cwd is authoritative (issue #3270): a helper // process that chdirs elsewhere inside the same foreground group // must not override it. Scan other members only when the leader's // cwd cannot be read at all. leader_cwd .or_else(|| foreground_member_cwd_different_from_shell(pid, shell_cwd.as_ref())) } #[cfg(not(unix))] { None } } } #[cfg(test)] impl PaneRuntime { pub(crate) fn test_with_channel(cols: u16, rows: u16) -> (Self, mpsc::Receiver) { Self::test_with_channel_and_scrollback_bytes(cols, rows, 0, &[], 4) } pub(crate) fn test_with_channel_capacity( cols: u16, rows: u16, capacity: usize, ) -> (Self, mpsc::Receiver) { Self::test_with_channel_and_scrollback_bytes(cols, rows, 0, &[], capacity) } pub(crate) fn test_with_screen_bytes(cols: u16, rows: u16, bytes: &[u8]) -> Self { Self::test_with_scrollback_bytes(cols, rows, 0, bytes) } pub(crate) fn test_process_pty_bytes(&self, bytes: &[u8]) { let _content_write_guard = match self.content_write_lock.lock() { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), }; self.content_seq.fetch_add(1, Ordering::AcqRel); let (tx, _rx) = mpsc::channel(1); let _ = self.terminal.process_pty_bytes(self.pane_id, 0, bytes, &tx); self.content_seq.fetch_add(1, Ordering::Release); } pub(crate) fn test_with_scrollback_bytes( cols: u16, rows: u16, scrollback_limit_bytes: usize, bytes: &[u8], ) -> Self { Self::test_with_channel_and_scrollback_bytes(cols, rows, scrollback_limit_bytes, bytes, 4).0 } pub(crate) fn test_with_channel_and_scrollback_bytes( cols: u16, rows: u16, scrollback_limit_bytes: usize, bytes: &[u8], channel_capacity: usize, ) -> (Self, mpsc::Receiver) { let (tx, rx) = mpsc::channel(channel_capacity); let (resize_tx, _resize_rx) = watch::channel((rows, cols, 0, 0)); let mut terminal = crate::ghostty::Terminal::new(cols, rows, scrollback_limit_bytes).unwrap(); terminal.write(bytes); ( Self { pane_id: PaneId::from_raw(0), terminal: Arc::new(PaneTerminal::new( GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(), )), io: PaneRuntimeIo::TestChannel { sender: tx, resize_tx, }, current_size: Cell::new((rows, cols, 0, 0)), child_pid: Arc::new(AtomicU32::new(0)), reported_cwd: Arc::new(Mutex::new(None)), child_wait_completed: None, kitty_keyboard_flags: Arc::new(AtomicU16::new(0)), content_seq: Arc::new(AtomicU64::new(0)), content_write_lock: Arc::new(Mutex::new(())), detection_content_seq: Arc::new(AtomicU64::new(0)), full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)), detect_reset_notify: Arc::new(Notify::new()), pending_release: Arc::new(Mutex::new(None)), preserve_processes_on_drop: true, detect_handle: Some(tokio::spawn(async {}).abort_handle()), }, rx, ) } } #[cfg(test)] mod tests { use super::*; #[test] fn pane_launch_env_removes_outer_codex_thread_id() { let mut cmd = CommandBuilder::new("shell"); cmd.env("CODEX_THREAD_ID", "outer-session"); apply_pane_launch_env(&mut cmd, &PaneLaunchEnv::default()); assert!(cmd.get_env("CODEX_THREAD_ID").is_none()); } #[test] fn pane_terminal_identity_removes_outer_windows_terminal_session() { let mut cmd = CommandBuilder::new("shell"); cmd.env("WT_SESSION", "outer-session"); apply_pane_terminal_env(&mut cmd); assert!(cmd.get_env("WT_SESSION").is_none()); } #[tokio::test] async fn cwd_returns_accepted_report_without_rechecking_filesystem() { let stamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("clock should be after unix epoch") .as_nanos(); let cwd = std::env::temp_dir().join(format!( "herdr-reported-cwd-cache-{}-{stamp}", std::process::id() )); std::fs::create_dir(&cwd).expect("create reported cwd"); let (runtime, _rx) = PaneRuntime::test_with_channel(80, 24); let (events, _event_rx) = mpsc::channel(1); publish_reported_cwd(runtime.pane_id, cwd.clone(), &runtime.reported_cwd, &events); assert_eq!( runtime.reported_cwd.lock().unwrap().as_ref(), Some(&cwd), "test setup must pass cache admission" ); std::fs::remove_dir(&cwd).expect("remove reported cwd after admission"); assert_eq!(runtime.cwd(), Some(cwd)); } #[cfg(unix)] #[test] fn process_cwd_does_not_require_traversing_the_directory_path() { use std::os::unix::fs::PermissionsExt; let stamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("clock should be after unix epoch") .as_nanos(); let base = std::env::temp_dir().join(format!( "herdr-process-cwd-no-stat-{}-{stamp}", std::process::id() )); let private = base.join("private"); let cwd = private.join("cwd"); std::fs::create_dir_all(&cwd).expect("create process cwd"); let mut child = std::process::Command::new("/bin/sh") .args(["-c", "sleep 30"]) .current_dir(&cwd) .spawn() .expect("spawn process in cwd"); let expected_cwd = crate::platform::process_cwd(child.id()) .expect("resolve process cwd before restricting traversal"); std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o000)) .expect("make cwd path untraversable"); let path_is_traversable = cwd.is_dir(); let observed = (!path_is_traversable) .then(|| absolute_process_cwd(child.id())) .flatten(); std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o755)) .expect("restore cwd path permissions"); let _ = child.kill(); let _ = child.wait(); std::fs::remove_dir_all(&base).expect("remove process cwd"); if path_is_traversable { eprintln!("skipping untraversable cwd assertion for privileged test process"); return; } assert_eq!(observed, Some(expected_cwd)); } #[cfg(unix)] #[tokio::test] async fn follow_cwd_falls_back_to_reported_pane_cwd_without_foreground_group() { let (runtime, _rx) = PaneRuntime::test_with_channel(80, 24); let cwd = std::env::temp_dir(); *runtime.reported_cwd.lock().unwrap() = Some(cwd.clone()); assert_eq!(runtime.follow_cwd(), Some(cwd)); } #[test] fn shutdown_liveness_treats_reaped_direct_child_as_gone() { assert!(!process_alive_for_shutdown(42, 42, true, |_| true)); } #[test] fn shutdown_liveness_keeps_unreaped_direct_child_alive() { assert!(process_alive_for_shutdown(42, 42, false, |_| true)); } #[test] fn shutdown_liveness_keeps_other_session_processes_alive() { assert!(process_alive_for_shutdown(43, 42, true, |_| true)); } #[test] fn shutdown_liveness_treats_missing_process_as_gone() { assert!(!process_alive_for_shutdown(43, 42, false, |_| false)); } #[cfg(unix)] fn capture_shell_output(command: &str, extra_env: &[(&str, &str)]) -> String { let pair = native_pty_system() .openpty(PtySize { rows: 24, cols: 80, pixel_width: 0, pixel_height: 0, }) .unwrap(); let output_path = std::env::temp_dir().join(format!( "herdr-pane-term-test-{}-{}.txt", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); let mut cmd = CommandBuilder::new("/bin/sh"); cmd.arg("-c"); cmd.arg(format!("{command} > '{}'", output_path.display())); cmd.cwd(std::env::current_dir().unwrap()); cmd.env("TERM", "xterm-ghostty"); cmd.env("COLORTERM", "falsecolor"); apply_pane_terminal_env(&mut cmd); for (key, value) in extra_env { cmd.env(key, value); } let mut child = pair.slave.spawn_command(cmd).unwrap(); let status = child.wait().unwrap(); assert!(status.success(), "shell command failed: {status:?}"); let output = std::fs::read_to_string(&output_path).unwrap(); let _ = std::fs::remove_file(output_path); output } #[test] fn pane_shell_prefers_configured_shell() { assert_eq!( pane_shell_from("/usr/bin/nu", Some("/bin/bash".to_string())), "/usr/bin/nu" ); } #[cfg(not(windows))] #[test] fn pane_shell_falls_back_to_shell_env() { assert_eq!( pane_shell_from("", Some("/bin/bash".to_string())), "/bin/bash" ); } #[cfg(windows)] #[test] fn pane_shell_ignores_shell_env_on_windows() { assert_eq!( pane_shell_from("", Some("c:\\windows\\system32\\cmd.exe".to_string())), default_pane_shell() ); } #[test] fn pane_shell_ignores_empty_values() { assert_eq!( pane_shell_from(" ", Some(" ".to_string())), default_pane_shell() ); assert_eq!(pane_shell_from("", None), default_pane_shell()); } #[test] fn shell_mode_auto_uses_login_shell_only_on_macos() { assert!(shell_mode_uses_login_shell( crate::config::ShellModeConfig::Auto, ShellLaunchTarget::Macos )); assert!(!shell_mode_uses_login_shell( crate::config::ShellModeConfig::Auto, ShellLaunchTarget::OtherUnix )); assert!(!shell_mode_uses_login_shell( crate::config::ShellModeConfig::Auto, ShellLaunchTarget::Windows )); assert!(shell_mode_uses_login_shell( crate::config::ShellModeConfig::Login, ShellLaunchTarget::OtherUnix )); assert!(!shell_mode_uses_login_shell( crate::config::ShellModeConfig::NonLogin, ShellLaunchTarget::Macos )); } #[cfg(unix)] #[test] fn login_shell_builder_uses_default_prog_with_resolved_shell_env() { let cmd = pane_shell_command_builder_for_target( PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::Login), ShellLaunchTarget::OtherUnix, ) .unwrap(); assert!(cmd.is_default_prog()); assert_eq!( cmd.get_env("SHELL").and_then(std::ffi::OsStr::to_str), Some("/bin/sh") ); } #[cfg(unix)] #[test] fn auto_shell_builder_uses_login_shell_on_macos_target() { let cmd = pane_shell_command_builder_for_target( PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::Auto), ShellLaunchTarget::Macos, ) .unwrap(); assert!(cmd.is_default_prog()); assert_eq!( cmd.get_env("SHELL").and_then(std::ffi::OsStr::to_str), Some("/bin/sh") ); } #[test] fn auto_shell_builder_keeps_direct_shell_on_non_macos_target() { let cmd = pane_shell_command_builder_for_target( PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::Auto), ShellLaunchTarget::OtherUnix, ) .unwrap(); assert!(!cmd.is_default_prog()); assert_eq!(cmd.get_argv(), &[std::ffi::OsString::from("/bin/sh")]); } #[test] fn windows_powershell_builder_injects_prompt_cwd_shell_integration() { for shell in [ "powershell.exe", "pwsh.exe", "C:\\Program Files\\PowerShell\\7\\pwsh.exe", ] { let cmd = pane_shell_command_builder_for_target( PaneShellConfig::new(shell, crate::config::ShellModeConfig::NonLogin), ShellLaunchTarget::Windows, ) .unwrap(); assert_eq!( cmd.get_argv(), &[ std::ffi::OsString::from(shell), std::ffi::OsString::from("-NoExit"), std::ffi::OsString::from("-Command"), std::ffi::OsString::from(WINDOWS_POWERSHELL_SHELL_INTEGRATION_COMMAND), ] ); } let script = WINDOWS_POWERSHELL_SHELL_INTEGRATION_COMMAND; let cwd_sync = script .find("[Environment]::CurrentDirectory = $loc.ProviderPath") .expect("wrapper must synchronize the Win32 process cwd"); let osc_report = script.find("]9;9;").expect("wrapper must emit OSC 9;9"); assert!(cwd_sync < osc_report, "cwd sync must precede OSC report"); assert!( script.contains("$global:__HerdrOriginalPrompt = $function:prompt"), "must wrap the profile-defined prompt: {script}" ); assert!( script.contains("$null -eq $global:__HerdrOriginalPrompt"), "wrap must be idempotent for nested sessions: {script}" ); assert!( script.contains("'FileSystem'"), "must not report non-filesystem provider paths: {script}" ); assert!( !script.contains('"'), "double quotes corrupt the powershell.exe command-line round-trip: {script}" ); let invoke_original = script .find("@(& $global:__HerdrOriginalPrompt)") .expect("wrapper must invoke the original prompt"); let cwd_lookup = script .find("$loc =") .expect("wrapper must look up the current location"); assert!( invoke_original < cwd_lookup, "original prompt must run first or $? is reset before a status-aware prompt reads it: {script}" ); } #[test] fn windows_non_powershell_builder_launches_plain_shell() { let cmd = pane_shell_command_builder_for_target( PaneShellConfig::new("cmd.exe", crate::config::ShellModeConfig::NonLogin), ShellLaunchTarget::Windows, ) .unwrap(); assert_eq!(cmd.get_argv(), &[std::ffi::OsString::from("cmd.exe")]); } #[test] fn unix_powershell_builder_launches_plain_shell() { let cmd = pane_shell_command_builder_for_target( PaneShellConfig::new("pwsh", crate::config::ShellModeConfig::NonLogin), ShellLaunchTarget::OtherUnix, ) .unwrap(); assert_eq!(cmd.get_argv(), &[std::ffi::OsString::from("pwsh")]); } #[test] fn windows_powershell_pane_shell_predicate_requires_windows_and_non_login() { let pwsh = PaneShellConfig::new("pwsh.exe", crate::config::ShellModeConfig::NonLogin); assert!(uses_windows_powershell_pane_shell_for_target( pwsh, ShellLaunchTarget::Windows )); assert!(!uses_windows_powershell_pane_shell_for_target( pwsh, ShellLaunchTarget::OtherUnix )); assert!(!uses_windows_powershell_pane_shell_for_target( pwsh, ShellLaunchTarget::Macos )); assert!(!uses_windows_powershell_pane_shell_for_target( PaneShellConfig::new("pwsh.exe", crate::config::ShellModeConfig::Login), ShellLaunchTarget::Windows )); assert!(!uses_windows_powershell_pane_shell_for_target( PaneShellConfig::new("cmd.exe", crate::config::ShellModeConfig::NonLogin), ShellLaunchTarget::Windows )); } #[test] fn login_shell_builder_rejects_missing_shell_instead_of_falling_back() { let err = pane_shell_command_builder_for_target( PaneShellConfig::new( "/__herdr_missing_shell__", crate::config::ShellModeConfig::Login, ), ShellLaunchTarget::OtherUnix, ) .unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::NotFound); } #[cfg(unix)] #[test] fn login_shell_builder_resolves_bare_shell_names_from_path() { let _lock = crate::integration::integration_env_lock(); let base = std::env::temp_dir().join(format!( "herdr-login-shell-path-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); let bin = base.join("bin"); std::fs::create_dir_all(&bin).unwrap(); let shell = bin.join("fake-shell"); std::fs::write(&shell, "#!/bin/sh\nexit 0\n").unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap(); } let original_path = std::env::var_os("PATH"); std::env::set_var("PATH", &bin); let cmd = pane_shell_command_builder_for_target( PaneShellConfig::new("fake-shell", crate::config::ShellModeConfig::Login), ShellLaunchTarget::OtherUnix, ) .unwrap(); assert!(cmd.is_default_prog()); assert_eq!( cmd.get_env("SHELL").and_then(std::ffi::OsStr::to_str), shell.to_str() ); match original_path { Some(path) => std::env::set_var("PATH", path), None => std::env::remove_var("PATH"), } let _ = std::fs::remove_dir_all(base); } #[cfg(unix)] #[test] fn login_shell_resolution_preserves_shell_paths() { assert_eq!(resolve_shell_for_login_mode("/bin/sh").unwrap(), "/bin/sh"); } #[test] fn non_login_shell_builder_execs_resolved_shell_directly() { let cmd = pane_shell_command_builder(PaneShellConfig::new( "/bin/sh", crate::config::ShellModeConfig::NonLogin, )) .unwrap(); assert!(!cmd.is_default_prog()); assert_eq!(cmd.get_argv(), &[std::ffi::OsString::from("/bin/sh")]); } #[cfg(unix)] #[test] fn pane_terminal_identity_overrides_outer_terminal_env() { let output = capture_shell_output("printf '%s\\n%s\\n' \"$TERM\" \"$COLORTERM\"", &[]); assert_eq!(output, "xterm-256color\ntruecolor\n"); } #[cfg(unix)] #[test] fn pane_terminal_identity_allows_explicit_override() { let output = capture_shell_output( "printf '%s\\n%s\\n' \"$TERM\" \"$COLORTERM\"", &[("TERM", "vt100"), ("COLORTERM", "24bit")], ); assert_eq!(output, "vt100\n24bit\n"); } #[cfg(unix)] #[tokio::test] async fn handoff_history_ansi_captures_primary_screen() { let runtime = PaneRuntime::test_with_scrollback_bytes(40, 5, 4096, b"handoff-primary-history\r\n"); let history = runtime.handoff_history_ansi().unwrap(); assert!(history.contains("handoff-primary-history")); } #[cfg(unix)] #[tokio::test] async fn handoff_history_ansi_skips_alternate_screen() { let runtime = PaneRuntime::test_with_scrollback_bytes( 40, 5, 4096, b"primary\r\n\x1b[?1049halt-screen", ); assert!(runtime.handoff_history_ansi().is_none()); } #[cfg(unix)] #[tokio::test] async fn handoff_runtime_state_captures_terminal_input_and_title_state() { let runtime = PaneRuntime::test_with_screen_bytes( 80, 24, b"\x1b[>5u\x1b[>4;2m\x1b[?1h\x1b[?2004h\x1b[?1004h\x1b[?1002h\x1b[?1006h\x1b[?2031h", ); runtime.test_process_pty_bytes("\x1b]2;✳ 修复🙂标题\x1b\\".as_bytes()); runtime.terminal.clear_agent_osc_state(); assert_eq!(runtime.agent_osc_title(), ""); let pane = runtime.handoff_runtime_state(12); assert_eq!(pane.keyboard_protocol_flags, 5); assert_eq!(pane.terminal_title.as_deref(), Some("✳ 修复🙂标题")); assert_eq!( pane.input_state, Some(InputState { alternate_screen: false, application_cursor: true, bracketed_paste: true, focus_reporting: true, mouse_protocol_mode: crate::input::MouseProtocolMode::ButtonMotion, mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Sgr, mouse_alternate_scroll: true, modify_other_keys: true, color_scheme_reporting: true, }) ); } #[cfg(unix)] #[test] fn truncate_handoff_history_keeps_recent_utf8_boundary() { let history = format!("old\n{}\nrecent\n", "é".repeat(8)); let truncated = truncate_handoff_history(history, 20); assert_eq!(truncated, "recent\n"); assert!(truncated.is_char_boundary(0)); } #[cfg(unix)] #[test] fn truncate_handoff_history_drops_partial_long_line() { let history = format!("old\n{}", "x".repeat(64)); let truncated = truncate_handoff_history(history, 12); assert!(truncated.is_empty()); } #[tokio::test] async fn focus_events_are_forwarded_when_enabled() { let (tx, mut rx) = mpsc::channel(4); let (resize_tx, _resize_rx) = watch::channel((80, 24, 0, 0)); let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); terminal .mode_set(crate::ghostty::MODE_FOCUS_EVENT, true) .unwrap(); let runtime = PaneRuntime { pane_id: PaneId::from_raw(0), terminal: Arc::new(PaneTerminal::new( GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(), )), io: PaneRuntimeIo::TestChannel { sender: tx, resize_tx, }, current_size: Cell::new((80, 24, 0, 0)), child_pid: Arc::new(AtomicU32::new(0)), reported_cwd: Arc::new(Mutex::new(None)), child_wait_completed: None, kitty_keyboard_flags: Arc::new(AtomicU16::new(0)), content_seq: Arc::new(AtomicU64::new(0)), content_write_lock: Arc::new(Mutex::new(())), detection_content_seq: Arc::new(AtomicU64::new(0)), full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)), detect_reset_notify: Arc::new(Notify::new()), pending_release: Arc::new(Mutex::new(None)), preserve_processes_on_drop: true, detect_handle: Some(tokio::spawn(async {}).abort_handle()), }; assert!(runtime.try_send_focus_event(crate::ghostty::FocusEvent::Gained)); assert_eq!(rx.recv().await.unwrap(), Bytes::from_static(b"\x1b[I")); } #[tokio::test] async fn focus_events_are_suppressed_when_disabled() { let (tx, mut rx) = mpsc::channel(4); let (resize_tx, _resize_rx) = watch::channel((80, 24, 0, 0)); let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); let runtime = PaneRuntime { pane_id: PaneId::from_raw(0), terminal: Arc::new(PaneTerminal::new( GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(), )), io: PaneRuntimeIo::TestChannel { sender: tx, resize_tx, }, current_size: Cell::new((80, 24, 0, 0)), child_pid: Arc::new(AtomicU32::new(0)), reported_cwd: Arc::new(Mutex::new(None)), child_wait_completed: None, kitty_keyboard_flags: Arc::new(AtomicU16::new(0)), content_seq: Arc::new(AtomicU64::new(0)), content_write_lock: Arc::new(Mutex::new(())), detection_content_seq: Arc::new(AtomicU64::new(0)), full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)), detect_reset_notify: Arc::new(Notify::new()), pending_release: Arc::new(Mutex::new(None)), preserve_processes_on_drop: true, detect_handle: Some(tokio::spawn(async {}).abort_handle()), }; assert!(!runtime.try_send_focus_event(crate::ghostty::FocusEvent::Gained)); assert!( tokio::time::timeout(std::time::Duration::from_millis(10), rx.recv()) .await .is_err() ); } #[tokio::test] async fn subscribed_idle_child_receives_color_scheme_transition() { let (runtime, mut rx) = PaneRuntime::test_with_channel(80, 24); runtime.apply_host_terminal_appearance(Some(crate::terminal_theme::HostAppearance::Dark)); runtime.test_process_pty_bytes(b"\x1b[?2031h"); runtime.apply_host_terminal_appearance(Some(crate::terminal_theme::HostAppearance::Light)); assert_eq!(rx.recv().await, Some(Bytes::from_static(b"\x1b[?997;2n"))); } #[test] fn foreground_shell_reports_process_exit_before_clearing_agent() { assert_eq!( foreground_shell_agent_action(Some(Agent::Codex), None, true, false), ForegroundShellAgentAction::ReportProcessExit ); assert_eq!( foreground_shell_agent_action(Some(Agent::Codex), None, true, true), ForegroundShellAgentAction::ClearAgent ); } #[test] fn same_agent_after_reported_exit_is_a_replacement_process() { assert_eq!( foreground_shell_agent_action(Some(Agent::Pi), Some(Agent::Pi), false, true), ForegroundShellAgentAction::ReportReplacementProcess ); } #[test] fn unknown_non_shell_foreground_job_is_not_immediate_clear_signal() { assert_eq!( foreground_shell_agent_action(Some(Agent::Claude), None, false, false), ForegroundShellAgentAction::ObserveProbe ); } #[tokio::test] async fn first_agent_acquisition_keeps_osc_evidence_replacement_clears_it() { let runtime = PaneRuntime::test_with_screen_bytes(80, 24, b""); runtime.test_process_pty_bytes(b"\x1b]2;startup title\x1b\\\x1b]9;4;1;\x1b\\"); clear_osc_evidence_for_agent_transition(&runtime.terminal, None); assert_eq!(runtime.agent_osc_title(), "startup title"); assert_eq!(runtime.agent_osc_progress(), "4;1;"); clear_osc_evidence_for_agent_transition(&runtime.terminal, Some(Agent::Claude)); assert_eq!(runtime.agent_osc_title(), ""); assert_eq!(runtime.agent_osc_progress(), ""); } #[test] fn reported_process_exit_clears_before_unknown_foreground_probe() { assert_eq!( foreground_shell_agent_action(Some(Agent::Claude), None, false, true), ForegroundShellAgentAction::ClearAgent ); } #[test] fn foreground_agent_job_is_not_clear_signal() { assert_eq!( foreground_shell_agent_action(Some(Agent::Claude), Some(Agent::OpenCode), true, false,), ForegroundShellAgentAction::ObserveProbe ); } fn foreground_process(pid: u32, name: &str) -> crate::platform::ForegroundProcess { crate::platform::ForegroundProcess { pid, name: name.to_string(), argv0: None, argv: None, cmdline: None, } } #[test] fn foreground_agent_hint_accepts_pane_shell_environment() { let job = crate::platform::ForegroundJob { process_group_id: 42, processes: vec![foreground_process(42, "bash")], }; assert_eq!( agent_hint_for_foreground_job_members(&job, |pid| { (pid == 42).then_some(Agent::Claude) }), Some(Agent::Claude) ); } #[test] fn foreground_agent_hint_accepts_non_leader_foreground_process_environment() { let job = crate::platform::ForegroundJob { process_group_id: 99, processes: vec![ foreground_process(99, "fence"), foreground_process(100, "pi"), ], }; assert_eq!( agent_hint_for_foreground_job_members(&job, |pid| { (pid == 100).then_some(Agent::Codex) }), Some(Agent::Codex) ); } #[test] fn foreground_agent_hint_wins_over_process_name_detection() { let job = crate::platform::ForegroundJob { process_group_id: 99, processes: vec![foreground_process(99, "codex")], }; let result = probe_foreground_process_from_jobs( 42, Some(99), Some(job), || None, |pid| (pid == 99).then_some(Agent::Claude), ); assert_eq!(result.agent, Some(Agent::Claude)); assert_eq!(result.process_name.as_deref(), Some("claude")); } #[test] fn foreground_agent_hint_on_inherited_child_environment_is_authoritative() { let job = crate::platform::ForegroundJob { process_group_id: 99, processes: vec![foreground_process(99, "vim")], }; let result = probe_foreground_process_from_jobs( 42, Some(99), None, || Some(job), |pid| (pid == 99).then_some(Agent::Claude), ); assert_eq!(result.agent, Some(Agent::Claude)); assert_eq!(result.process_name.as_deref(), Some("claude")); } #[test] fn non_leader_agent_hint_does_not_override_identifiable_leader() { let job = crate::platform::ForegroundJob { process_group_id: 99, processes: vec![ foreground_process(99, "codex"), foreground_process(100, "vim"), ], }; let result = probe_foreground_process_from_jobs( 42, Some(99), None, || Some(job), |pid| (pid == 100).then_some(Agent::Claude), ); assert_eq!(result.agent, Some(Agent::Codex)); assert_eq!(result.process_name.as_deref(), Some("codex")); } #[test] fn non_leader_agent_hint_wins_when_leader_is_unidentified() { let job = crate::platform::ForegroundJob { process_group_id: 99, processes: vec![ foreground_process(99, "some_vm"), foreground_process(100, "vim"), ], }; let result = probe_foreground_process_from_jobs( 42, Some(99), None, || Some(job), |pid| (pid == 100).then_some(Agent::Claude), ); assert_eq!(result.agent, Some(Agent::Claude)); assert_eq!(result.process_name.as_deref(), Some("claude")); } fn process_probe_input() -> ProcessProbeInput { ProcessProbeInput { current_agent: None, suppressed_agent: None, foreground_pgid: Some(42), last_foreground_pgid: Some(42), has_process_probe: true, acquisition_age: None, pending_foreground_shell_clear: false, pending_restore_probe: false, elapsed_since_process_check: std::time::Duration::from_secs(1), } } #[test] fn windows_foreground_observation_schedule_preserves_lifecycle_checks() { let quiet = ProcessProbeInput { current_agent: Some(Agent::Codex), ..process_probe_input() }; let before_safety_bound = PROCESS_RECHECK_IDENTIFIED - std::time::Duration::from_millis(1); let content_retry = std::time::Duration::from_millis(300); let observe = |lifecycle, content_changed, elapsed, input| { should_observe_foreground_process_group(lifecycle, content_changed, elapsed, input) }; let content_due = |last: Option, current, elapsed| { last != Some(current) && (last.is_some() || elapsed >= content_retry) }; assert!(!observe(false, false, before_safety_bound, quiet)); assert!(observe(false, true, before_safety_bound, quiet)); assert!(observe(true, true, before_safety_bound, quiet)); assert!(content_due(Some(0), 1, std::time::Duration::ZERO)); assert!(!content_due( None, 1, content_retry - std::time::Duration::from_millis(1) )); assert!(content_due(None, 1, content_retry)); assert!(observe( false, false, before_safety_bound, ProcessProbeInput { elapsed_since_process_check: PROCESS_RECHECK_IDENTIFIED, ..quiet } )); assert!(observe(true, false, PROCESS_RECHECK_IDENTIFIED, quiet)); for immediate in [ ProcessProbeInput { has_process_probe: false, ..quiet }, ProcessProbeInput { current_agent: None, acquisition_age: Some(std::time::Duration::ZERO), ..quiet }, ProcessProbeInput { pending_restore_probe: true, ..quiet }, ProcessProbeInput { suppressed_agent: Some(Agent::Codex), ..quiet }, ProcessProbeInput { pending_foreground_shell_clear: true, ..quiet }, ] { assert!(observe(false, false, std::time::Duration::ZERO, immediate)); } } #[test] fn unchanged_unidentified_foreground_group_skips_full_process_probe() { assert!(!should_probe_foreground_job(process_probe_input())); } #[test] fn unidentified_foreground_group_change_runs_full_process_probe() { assert!(should_probe_foreground_job(ProcessProbeInput { foreground_pgid: Some(43), ..process_probe_input() })); } #[test] fn unidentified_pane_gets_initial_process_probe() { assert!(should_probe_foreground_job(ProcessProbeInput { has_process_probe: false, ..process_probe_input() })); } #[test] fn stable_unidentified_foreground_group_has_no_safety_process_probe() { assert!(!should_probe_foreground_job(ProcessProbeInput { elapsed_since_process_check: PROCESS_RECHECK_MISSING_FOREGROUND_GROUP, ..process_probe_input() })); } #[test] fn unidentified_pane_without_foreground_group_uses_safety_process_probe() { assert!(!should_probe_foreground_job(ProcessProbeInput { foreground_pgid: None, last_foreground_pgid: None, ..process_probe_input() })); assert!(should_probe_foreground_job(ProcessProbeInput { foreground_pgid: None, last_foreground_pgid: None, elapsed_since_process_check: PROCESS_RECHECK_MISSING_FOREGROUND_GROUP, ..process_probe_input() })); } #[test] fn unidentified_pane_probes_when_foreground_group_disappears() { assert!(should_probe_foreground_job(ProcessProbeInput { foreground_pgid: None, last_foreground_pgid: Some(42), ..process_probe_input() })); } #[test] fn inferred_group_does_not_trigger_a_probe_on_every_tick() { let tracked = process_group_for_change_tracking(None, Some(300)); assert_eq!(tracked, None); assert!(!should_probe_foreground_job(ProcessProbeInput { current_agent: Some(Agent::Claude), foreground_pgid: None, last_foreground_pgid: tracked, elapsed_since_process_check: std::time::Duration::from_millis(300), ..process_probe_input() })); } #[test] fn pending_shell_clear_and_restore_force_process_probes() { assert!(should_probe_foreground_job(ProcessProbeInput { current_agent: Some(Agent::Codex), pending_foreground_shell_clear: true, ..process_probe_input() })); assert!(should_probe_foreground_job(ProcessProbeInput { current_agent: Some(Agent::Codex), pending_restore_probe: true, ..process_probe_input() })); } #[test] fn lifecycle_authority_skips_stable_routine_process_probe() { assert!(should_skip_process_probe_for_lifecycle_authority( true, ProcessProbeInput { current_agent: Some(Agent::Pi), elapsed_since_process_check: PROCESS_RECHECK_IDENTIFIED, ..process_probe_input() } )); assert!(!should_skip_process_probe_for_lifecycle_authority( false, ProcessProbeInput { current_agent: Some(Agent::Pi), elapsed_since_process_check: PROCESS_RECHECK_IDENTIFIED, ..process_probe_input() } )); } #[test] fn lifecycle_authority_keeps_periodic_probes_without_an_observed_group() { let input = ProcessProbeInput { current_agent: Some(Agent::Pi), foreground_pgid: None, last_foreground_pgid: None, elapsed_since_process_check: PROCESS_RECHECK_IDENTIFIED, ..process_probe_input() }; assert!(!should_skip_process_probe_for_lifecycle_authority( true, input )); assert!(should_probe_foreground_job(input)); } #[test] fn lifecycle_authority_preserves_process_exit_and_release_probes() { assert!(!should_skip_process_probe_for_lifecycle_authority( true, ProcessProbeInput { current_agent: Some(Agent::Pi), pending_foreground_shell_clear: true, ..process_probe_input() } )); assert!(!should_skip_process_probe_for_lifecycle_authority( true, ProcessProbeInput { current_agent: Some(Agent::Pi), suppressed_agent: Some(Agent::Pi), ..process_probe_input() } )); } #[test] fn lifecycle_authority_preserves_initial_and_foreground_group_change_probes() { assert!(!should_skip_process_probe_for_lifecycle_authority( true, ProcessProbeInput { current_agent: None, has_process_probe: false, ..process_probe_input() } )); assert!(!should_skip_process_probe_for_lifecycle_authority( true, ProcessProbeInput { current_agent: Some(Agent::Pi), foreground_pgid: Some(43), ..process_probe_input() } )); } #[test] fn pending_release_forces_initial_process_probe() { assert!(should_probe_foreground_job(ProcessProbeInput { current_agent: Some(Agent::Codex), suppressed_agent: Some(Agent::Codex), has_process_probe: false, ..process_probe_input() })); } #[test] fn pending_release_forces_process_probe_after_runtime_identity_clears() { assert!(should_probe_foreground_job(ProcessProbeInput { current_agent: None, suppressed_agent: Some(Agent::Codex), has_process_probe: false, ..process_probe_input() })); } #[test] fn pending_release_skips_repeated_probe_when_foreground_group_is_stable() { assert!(!should_probe_foreground_job(ProcessProbeInput { current_agent: None, suppressed_agent: Some(Agent::Codex), ..process_probe_input() })); } #[test] fn pending_release_probes_when_foreground_group_changes() { assert!(should_probe_foreground_job(ProcessProbeInput { current_agent: None, suppressed_agent: Some(Agent::Codex), foreground_pgid: Some(43), ..process_probe_input() })); } #[test] fn acquisition_window_catches_delayed_same_group_wrapper_startup() { assert!(!should_probe_foreground_job(ProcessProbeInput { current_agent: None, acquisition_age: Some(std::time::Duration::from_millis(1250)), elapsed_since_process_check: PROCESS_ACQUISITION_FAST_RECHECK - std::time::Duration::from_millis(1), ..process_probe_input() })); assert!(should_probe_foreground_job(ProcessProbeInput { current_agent: None, acquisition_age: Some(std::time::Duration::from_millis(1250)), elapsed_since_process_check: PROCESS_ACQUISITION_FAST_RECHECK, ..process_probe_input() })); assert!(should_probe_foreground_job(ProcessProbeInput { current_agent: None, acquisition_age: Some(std::time::Duration::from_secs(5)), elapsed_since_process_check: PROCESS_ACQUISITION_SLOW_RECHECK, ..process_probe_input() })); assert!(!should_probe_foreground_job(ProcessProbeInput { current_agent: None, acquisition_age: Some(PROCESS_ACQUISITION_WINDOW + std::time::Duration::from_millis(1),), elapsed_since_process_check: PROCESS_ACQUISITION_SLOW_RECHECK, ..process_probe_input() })); } #[test] fn content_change_starts_bounded_unidentified_acquisition_window() { let now = std::time::Instant::now(); let mut acquisition_started_at = None; let mut last_content_change_at = None; sync_content_change_acquisition( None, None, false, true, now, &mut acquisition_started_at, &mut last_content_change_at, ); assert_eq!(acquisition_started_at, Some(now)); assert_eq!(last_content_change_at, Some(now)); let later = now + std::time::Duration::from_secs(1); sync_content_change_acquisition( None, None, false, true, later, &mut acquisition_started_at, &mut last_content_change_at, ); assert_eq!( acquisition_started_at, Some(now), "changed frames should not refresh the acquisition window" ); assert_eq!(last_content_change_at, Some(later)); let quiet_after_window = later + PROCESS_ACQUISITION_WINDOW + PROCESS_ACQUISITION_IDLE_RESET; sync_content_change_acquisition( None, None, false, false, quiet_after_window, &mut acquisition_started_at, &mut last_content_change_at, ); assert_eq!(acquisition_started_at, None); assert_eq!(last_content_change_at, None); let next_burst = quiet_after_window + std::time::Duration::from_secs(1); sync_content_change_acquisition( None, None, false, true, next_burst, &mut acquisition_started_at, &mut last_content_change_at, ); assert_eq!(acquisition_started_at, Some(next_burst)); assert_eq!(last_content_change_at, Some(next_burst)); } #[test] fn content_change_does_not_start_acquisition_when_process_probe_has_other_signal() { let now = std::time::Instant::now(); let mut acquisition_started_at = None; let mut last_content_change_at = None; sync_content_change_acquisition( Some(Agent::Codex), None, false, true, now, &mut acquisition_started_at, &mut last_content_change_at, ); assert_eq!(acquisition_started_at, None); assert_eq!(last_content_change_at, None); sync_content_change_acquisition( None, Some(Agent::Codex), false, true, now, &mut acquisition_started_at, &mut last_content_change_at, ); assert_eq!(acquisition_started_at, None); assert_eq!(last_content_change_at, None); sync_content_change_acquisition( None, None, true, true, now, &mut acquisition_started_at, &mut last_content_change_at, ); assert_eq!(acquisition_started_at, None); assert_eq!(last_content_change_at, None); } #[test] fn content_change_restarts_stale_process_group_acquisition_window() { let now = std::time::Instant::now(); let stale_start = now - PROCESS_ACQUISITION_WINDOW - std::time::Duration::from_millis(1); let mut acquisition_started_at = Some(stale_start); let mut last_content_change_at = None; sync_content_change_acquisition( None, None, false, true, now, &mut acquisition_started_at, &mut last_content_change_at, ); assert_eq!(acquisition_started_at, Some(now)); assert_eq!(last_content_change_at, Some(now)); } #[test] fn release_expiry_can_force_reacquire_probe_by_resetting_probe_state() { assert!(should_probe_foreground_job(ProcessProbeInput { current_agent: None, has_process_probe: false, ..process_probe_input() })); } #[test] fn identified_agent_uses_shorter_safety_process_probe() { assert!(!should_probe_foreground_job(ProcessProbeInput { current_agent: Some(Agent::Codex), elapsed_since_process_check: PROCESS_RECHECK_IDENTIFIED - std::time::Duration::from_millis(1), ..process_probe_input() })); assert!(should_probe_foreground_job(ProcessProbeInput { current_agent: Some(Agent::Codex), elapsed_since_process_check: PROCESS_RECHECK_IDENTIFIED, ..process_probe_input() })); } #[test] fn identified_agent_probes_when_foreground_group_disappears() { assert!(should_probe_foreground_job(ProcessProbeInput { current_agent: Some(Agent::Codex), foreground_pgid: None, last_foreground_pgid: Some(42), elapsed_since_process_check: PROCESS_RECHECK_IDENTIFIED - std::time::Duration::from_millis(1), ..process_probe_input() })); } #[test] fn stable_missing_foreground_group_uses_safety_process_probe() { assert!(!should_probe_foreground_job(ProcessProbeInput { current_agent: Some(Agent::Codex), foreground_pgid: None, last_foreground_pgid: None, elapsed_since_process_check: PROCESS_RECHECK_IDENTIFIED - std::time::Duration::from_millis(1), ..process_probe_input() })); assert!(should_probe_foreground_job(ProcessProbeInput { current_agent: Some(Agent::Codex), foreground_pgid: None, last_foreground_pgid: None, elapsed_since_process_check: PROCESS_RECHECK_IDENTIFIED, ..process_probe_input() })); } #[test] fn transient_process_miss_keeps_current_agent_detected() { let mut presence = AgentDetectionPresence::from_agent(Some(Agent::Pi)); let changed = presence.observe_process_probe(None); assert!(!changed, "one miss should not clear the detected agent"); assert_eq!(presence.current_agent(), Some(Agent::Pi)); } #[test] fn agent_only_clears_after_confirmation_misses() { let mut presence = AgentDetectionPresence::from_agent(Some(Agent::Pi)); for attempt in 1..AGENT_MISS_CONFIRMATION_ATTEMPTS { let changed = presence.observe_process_probe(None); assert!( !changed, "miss {attempt} should stay in the confirmation window" ); assert_eq!(presence.current_agent(), Some(Agent::Pi)); } let changed = presence.observe_process_probe(None); assert!(changed, "last confirmation miss should clear the agent"); assert_eq!(presence.current_agent(), None); } #[tokio::test] async fn set_full_lifecycle_authority_active_notifies_only_on_activation_transitions() { let runtime = PaneRuntime::test_with_screen_bytes(80, 24, b""); let reset_notify = runtime.agent_detection_reset_notify_for_test(); runtime.set_full_lifecycle_authority_active(true); tokio::time::timeout( std::time::Duration::from_millis(50), reset_notify.notified(), ) .await .expect("false-to-true transition should notify detection reset"); runtime.set_full_lifecycle_authority_active(true); assert!( tokio::time::timeout( std::time::Duration::from_millis(20), reset_notify.notified() ) .await .is_err(), "repeated true-to-true sync should not notify detection reset" ); runtime.set_full_lifecycle_authority_active(false); assert!( tokio::time::timeout( std::time::Duration::from_millis(20), reset_notify.notified() ) .await .is_err(), "true-to-false transition should not notify detection reset" ); runtime.set_full_lifecycle_authority_active(true); tokio::time::timeout( std::time::Duration::from_millis(50), reset_notify.notified(), ) .await .expect("re-entering active authority should notify detection reset"); } #[cfg(unix)] #[tokio::test] async fn spawned_pty_reader_aggregates_terminal_bells() { let (events, mut event_rx) = mpsc::channel(8); let pane_id = PaneId::from_raw(42); let runtime = PaneRuntime::spawn_shell_command( pane_id, 24, 80, std::env::temp_dir(), "printf '\\a\\a'; sleep 0.05", &PaneLaunchEnv::default(), AgentDetection::Disabled, 0, crate::terminal_theme::TerminalTheme::default(), None, events, Arc::new(Notify::new()), Arc::new(RenderSignal::new()), ) .unwrap(); let bell = tokio::time::timeout(std::time::Duration::from_secs(2), async { loop { if let Some(AppEvent::TerminalBell { pane_id: delivered_pane, count, }) = event_rx.recv().await { break (delivered_pane, count); } } }) .await .expect("PTY reader should publish terminal bells"); assert_eq!(bell, (pane_id, 2)); runtime.shutdown(); } #[tokio::test] async fn state_changed_event_waits_for_queue_space_instead_of_dropping() { let (tx, mut rx) = mpsc::channel(1); let pane_id = PaneId::from_raw(42); tx.try_send(AppEvent::UpdateReady { version: "9.9.9".into(), install_command: "herdr update".into(), }) .unwrap(); let publish = publish_state_changed_event( tx.clone(), pane_id, Some(Agent::Pi), AgentState::Idle, false, false, false, std::time::Instant::now(), ); tokio::pin!(publish); let blocked = tokio::time::timeout(std::time::Duration::from_millis(20), async { (&mut publish).await; }) .await; assert!( blocked.is_err(), "publisher should wait for queue space instead of dropping StateChanged" ); let first = tokio::time::timeout(std::time::Duration::from_millis(50), rx.recv()) .await .expect("queue should yield first event") .expect("sender still alive"); assert!(matches!(first, AppEvent::UpdateReady { .. })); tokio::time::timeout(std::time::Duration::from_millis(50), async { (&mut publish).await; }) .await .expect("publisher should complete once queue space is available"); let second = tokio::time::timeout(std::time::Duration::from_millis(50), rx.recv()) .await .expect("queue should yield second event") .expect("sender still alive"); assert!(matches!( second, AppEvent::StateChanged { pane_id: delivered_pane, agent: Some(Agent::Pi), state: AgentState::Idle, visible_blocker: false, visible_working: false, process_exited: false, observed_at: _, } if delivered_pane == pane_id )); } }