mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 08:01:06 +00:00
4532 lines
160 KiB
Rust
4532 lines
160 KiB
Rust
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, TerminalTextMatch,
|
|
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";
|
|
|
|
#[cfg(test)]
|
|
thread_local! {
|
|
static AGGREGATE_INPUT_STATE_READS: Cell<usize> = const { Cell::new(0) };
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn reset_aggregate_input_state_reads() {
|
|
AGGREGATE_INPUT_STATE_READS.set(0);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn aggregate_input_state_reads() -> usize {
|
|
AGGREGATE_INPUT_STATE_READS.get()
|
|
}
|
|
|
|
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<Agent>,
|
|
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<Option<PendingAgentRelease>>,
|
|
now: std::time::Instant,
|
|
) -> Option<Agent> {
|
|
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<AppEvent>,
|
|
pane_id: PaneId,
|
|
agent: Option<Agent>,
|
|
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<AppEvent>,
|
|
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<AppEvent>,
|
|
pane_id: PaneId,
|
|
agent: Option<Agent>,
|
|
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<std::time::Instant>,
|
|
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<Agent>,
|
|
consecutive_misses: u8,
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn absolute_process_cwd(pid: u32) -> Option<std::path::PathBuf> {
|
|
crate::platform::process_cwd(pid).filter(|cwd| cwd.is_absolute())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn usable_process_cwd(pid: u32) -> Option<std::path::PathBuf> {
|
|
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<std::path::PathBuf> {
|
|
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<Agent>,
|
|
new_agent: Option<Agent>,
|
|
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
|
|
}
|
|
|
|
fn apply_foreground_shell_agent_action(
|
|
agent_presence: &mut AgentDetectionPresence,
|
|
action: ForegroundShellAgentAction,
|
|
previous_agent: Option<Agent>,
|
|
new_agent: Option<Agent>,
|
|
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<Agent>,
|
|
suppressed_agent: Option<Agent>,
|
|
foreground_pgid: Option<u32>,
|
|
last_foreground_pgid: Option<u32>,
|
|
has_process_probe: bool,
|
|
acquisition_age: Option<std::time::Duration>,
|
|
pending_foreground_shell_clear: bool,
|
|
pending_restore_probe: bool,
|
|
elapsed_since_process_check: std::time::Duration,
|
|
}
|
|
|
|
fn foreground_group_changed(
|
|
foreground_pgid: Option<u32>,
|
|
last_foreground_pgid: Option<u32>,
|
|
) -> 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<u32>,
|
|
probed_process_group_id: Option<u32>,
|
|
) -> Option<u32> {
|
|
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<Agent>,
|
|
suppressed_agent: Option<Agent>,
|
|
process_group_changed: bool,
|
|
content_changed: bool,
|
|
now: std::time::Instant,
|
|
acquisition_started_at: &mut Option<std::time::Instant>,
|
|
last_content_change_at: &mut Option<std::time::Instant>,
|
|
) {
|
|
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<u32>,
|
|
foreground_is_pane_shell: bool,
|
|
agent: Option<Agent>,
|
|
process_name: Option<String>,
|
|
}
|
|
|
|
fn agent_hint_for_foreground_job_members(
|
|
job: &crate::platform::ForegroundJob,
|
|
read_hint: impl Fn(u32) -> Option<Agent>,
|
|
) -> Option<Agent> {
|
|
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<Agent>,
|
|
) -> Option<Agent> {
|
|
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<Agent>,
|
|
) -> Option<ProcessProbeResult> {
|
|
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<u32>,
|
|
leader_job: Option<crate::platform::ForegroundJob>,
|
|
foreground_job: impl FnOnce() -> Option<crate::platform::ForegroundJob>,
|
|
read_hint: impl Fn(u32) -> Option<Agent> + 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<u32>) -> 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<AtomicU32>,
|
|
terminal: Arc<PaneTerminal>,
|
|
detection_content_seq: Arc<AtomicU64>,
|
|
full_lifecycle_authority_active: Arc<AtomicBool>,
|
|
state_events: mpsc::Sender<AppEvent>,
|
|
) -> (
|
|
tokio::task::AbortHandle,
|
|
Arc<Notify>,
|
|
Arc<Mutex<Option<PendingAgentRelease>>>,
|
|
) {
|
|
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 new foreground agent must not inherit OSC
|
|
// title/progress evidence from the previous process.
|
|
terminal.clear_agent_osc_state();
|
|
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<Agent>) -> Self {
|
|
Self {
|
|
current_agent,
|
|
consecutive_misses: 0,
|
|
}
|
|
}
|
|
|
|
fn current_agent(&self) -> Option<Agent> {
|
|
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<Agent>) -> 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<PaneTerminal>,
|
|
io: PaneRuntimeIo,
|
|
current_size: Cell<(u16, u16, u32, u32)>,
|
|
child_pid: Arc<AtomicU32>,
|
|
reported_cwd: Arc<Mutex<Option<std::path::PathBuf>>>,
|
|
child_wait_completed: Option<Arc<AtomicBool>>,
|
|
kitty_keyboard_flags: Arc<AtomicU16>,
|
|
content_seq: Arc<AtomicU64>,
|
|
detection_content_seq: Arc<AtomicU64>,
|
|
full_lifecycle_authority_active: Arc<AtomicBool>,
|
|
detect_reset_notify: Arc<Notify>,
|
|
pending_release: Arc<Mutex<Option<PendingAgentRelease>>>,
|
|
preserve_processes_on_drop: bool,
|
|
// Task handles for deterministic shutdown
|
|
detect_handle: Option<tokio::task::AbortHandle>,
|
|
}
|
|
|
|
enum PaneRuntimeIo {
|
|
Actor(PtyIoActorHandle),
|
|
#[cfg(test)]
|
|
TestChannel {
|
|
sender: mpsc::Sender<Bytes>,
|
|
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<std::os::fd::RawFd> {
|
|
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<u32> {
|
|
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<Bytes>,
|
|
) {
|
|
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 { .. } => {}
|
|
}
|
|
}
|
|
|
|
async fn send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::SendError<Bytes>> {
|
|
match self {
|
|
PaneRuntimeIo::Actor(actor) => actor.write_user_input(bytes).await,
|
|
#[cfg(test)]
|
|
PaneRuntimeIo::TestChannel { sender, .. } => sender.send(bytes).await,
|
|
}
|
|
}
|
|
|
|
fn try_send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::TrySendError<Bytes>> {
|
|
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<Bytes>) {
|
|
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 send_bytes_after(&self, bytes: Bytes, delay: std::time::Duration) {
|
|
match self {
|
|
PaneRuntimeIo::Actor(actor) => {
|
|
let actor = actor.clone();
|
|
tokio::spawn(async move {
|
|
tokio::time::sleep(delay).await;
|
|
if let Err(err) = actor.write_user_input(bytes).await {
|
|
warn!(error = %err, "failed to send delayed PTY input");
|
|
}
|
|
});
|
|
}
|
|
#[cfg(test)]
|
|
PaneRuntimeIo::TestChannel { sender, .. } => {
|
|
let sender = sender.clone();
|
|
tokio::spawn(async move {
|
|
tokio::time::sleep(delay).await;
|
|
let _ = sender.send(bytes).await;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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>) -> 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<String> {
|
|
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<CommandBuilder> {
|
|
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<CommandBuilder> {
|
|
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<std::path::PathBuf> {
|
|
(cwd.is_absolute() && cwd.is_dir()).then_some(cwd)
|
|
}
|
|
|
|
fn publish_terminal_bells(pane_id: PaneId, count: u16, events: &mpsc::Sender<AppEvent>) {
|
|
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<Mutex<Option<std::path::PathBuf>>>,
|
|
events: &mpsc::Sender<AppEvent>,
|
|
) {
|
|
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<std::os::fd::RawFd> {
|
|
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<String> {
|
|
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<crate::terminal_theme::HostAppearance>,
|
|
) {
|
|
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<crate::terminal_theme::HostAppearance>,
|
|
shell_config: PaneShellConfig<'_>,
|
|
launch_env: &PaneLaunchEnv,
|
|
events: mpsc::Sender<AppEvent>,
|
|
render_notify: Arc<Notify>,
|
|
render_dirty: Arc<RenderSignal>,
|
|
) -> std::io::Result<Self> {
|
|
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<crate::terminal_theme::HostAppearance>,
|
|
shell_config: PaneShellConfig<'_>,
|
|
launch_env: &PaneLaunchEnv,
|
|
initial_history_ansi: Option<&str>,
|
|
events: mpsc::Sender<AppEvent>,
|
|
render_notify: Arc<Notify>,
|
|
render_dirty: Arc<RenderSignal>,
|
|
) -> std::io::Result<Self> {
|
|
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<crate::terminal_theme::HostAppearance>,
|
|
events: mpsc::Sender<AppEvent>,
|
|
render_notify: Arc<Notify>,
|
|
render_dirty: Arc<RenderSignal>,
|
|
) -> std::io::Result<Self> {
|
|
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<crate::terminal_theme::HostAppearance>,
|
|
events: mpsc::Sender<AppEvent>,
|
|
render_notify: Arc<Notify>,
|
|
render_dirty: Arc<RenderSignal>,
|
|
) -> std::io::Result<Self> {
|
|
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<crate::terminal_theme::HostAppearance>,
|
|
events: mpsc::Sender<AppEvent>,
|
|
render_notify: Arc<Notify>,
|
|
render_dirty: Arc<RenderSignal>,
|
|
) -> std::io::Result<Self> {
|
|
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::<Bytes>(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 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 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]| {
|
|
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);
|
|
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,
|
|
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<crate::terminal_theme::HostAppearance>,
|
|
events: mpsc::Sender<AppEvent>,
|
|
render_notify: Arc<Notify>,
|
|
render_dirty: Arc<RenderSignal>,
|
|
cmd: CommandBuilder,
|
|
spawn_error_message: &'static str,
|
|
initial_state: SpawnInitialState<'_>,
|
|
agent_detection: AgentDetection,
|
|
) -> std::io::Result<Self> {
|
|
crate::logging::pane_spawn_started(pane_id.raw(), rows, cols, scrollback_limit_bytes);
|
|
|
|
let (response_tx, _response_rx) = mpsc::channel::<Bytes>(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 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 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]| {
|
|
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);
|
|
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 new foreground agent must not inherit OSC
|
|
// title/progress evidence from the previous process.
|
|
terminal.clear_agent_osc_state();
|
|
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,
|
|
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<Notify> {
|
|
self.detect_reset_notify.clone()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn agent_detection_enabled_for_test(&self) -> bool {
|
|
self.detect_handle.is_some()
|
|
}
|
|
|
|
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 terminal_responses = self
|
|
.terminal
|
|
.resize(rows, cols, cell_width_px, cell_height_px);
|
|
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<ScrollMetrics> {
|
|
self.terminal.scroll_metrics()
|
|
}
|
|
|
|
pub(crate) fn search_text_matches(
|
|
&self,
|
|
query: &str,
|
|
case_sensitive: bool,
|
|
) -> Vec<crate::pane::TerminalTextMatch> {
|
|
self.terminal.search_text_matches(query, case_sensitive)
|
|
}
|
|
|
|
pub(crate) fn text_match_is_current(&self, text_match: crate::pane::TerminalTextMatch) -> bool {
|
|
self.terminal.text_match_is_current(text_match)
|
|
}
|
|
|
|
pub(crate) fn text_matches_are_current(
|
|
&self,
|
|
text_matches: &[crate::pane::TerminalTextMatch],
|
|
) -> Vec<bool> {
|
|
self.terminal.text_matches_are_current(text_matches)
|
|
}
|
|
|
|
pub(crate) fn word_motion_target(
|
|
&self,
|
|
row: u32,
|
|
col: u16,
|
|
motion: crate::pane::TerminalWordMotion,
|
|
) -> Option<crate::pane::TerminalTextPoint> {
|
|
self.terminal.word_motion_target(row, col, motion)
|
|
}
|
|
|
|
#[cfg(any(unix, test))]
|
|
pub fn input_state(&self) -> Option<InputState> {
|
|
#[cfg(test)]
|
|
AGGREGATE_INPUT_STATE_READS.set(AGGREGATE_INPUT_STATE_READS.get() + 1);
|
|
self.terminal.input_state()
|
|
}
|
|
|
|
pub fn keyboard_report_all_requested(&self) -> bool {
|
|
self.terminal.keyboard_report_all_requested()
|
|
}
|
|
|
|
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<bool> {
|
|
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<TerminalCursorState> {
|
|
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<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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<F>(
|
|
&self,
|
|
needs_data: F,
|
|
) -> Vec<crate::ghostty::KittyImagePlacement>
|
|
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 encode_terminal_key(&self, key: crate::input::TerminalKey) -> Vec<u8> {
|
|
self.terminal
|
|
.encode_terminal_key(key, self.keyboard_protocol())
|
|
}
|
|
|
|
pub async fn send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::SendError<Bytes>> {
|
|
self.io.send_bytes(bytes).await
|
|
}
|
|
|
|
pub fn try_send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::TrySendError<Bytes>> {
|
|
self.io.try_send_bytes(bytes)
|
|
}
|
|
|
|
pub fn send_bytes_after(&self, bytes: Bytes, delay: std::time::Duration) {
|
|
self.io.send_bytes_after(bytes, delay);
|
|
}
|
|
|
|
pub async fn send_paste(&self, text: String) -> Result<(), mpsc::error::SendError<Bytes>> {
|
|
self.send_bytes(self.paste_payload(text)).await
|
|
}
|
|
|
|
pub fn try_send_paste(&self, text: String) -> Result<(), mpsc::error::TrySendError<Bytes>> {
|
|
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<WheelRouting> {
|
|
self.terminal.wheel_routing()
|
|
}
|
|
|
|
pub(crate) fn screen_text_snapshot(
|
|
&self,
|
|
) -> Option<(
|
|
crate::ghostty::ActiveScreen,
|
|
u16,
|
|
Vec<crate::ghostty::ScreenTextRow>,
|
|
)> {
|
|
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<Vec<u8>> {
|
|
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<Vec<u8>> {
|
|
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<Vec<u8>> {
|
|
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<Vec<u8>> {
|
|
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<std::path::PathBuf> {
|
|
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<u32> {
|
|
let pid = self.child_pid.load(Ordering::Acquire);
|
|
(pid > 0).then_some(pid)
|
|
}
|
|
|
|
pub fn follow_cwd(&self) -> Option<std::path::PathBuf> {
|
|
#[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<std::path::PathBuf> {
|
|
#[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);
|
|
|
|
if leader_cwd.as_ref() == shell_cwd.as_ref() {
|
|
foreground_member_cwd_different_from_shell(pid, shell_cwd.as_ref()).or(leader_cwd)
|
|
} else {
|
|
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<Bytes>) {
|
|
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<Bytes>) {
|
|
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]) {
|
|
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<Bytes>) {
|
|
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)),
|
|
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)),
|
|
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)),
|
|
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
|
|
);
|
|
}
|
|
|
|
#[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<u64>, 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
|
|
));
|
|
}
|
|
}
|