mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
5669 lines
203 KiB
Rust
5669 lines
203 KiB
Rust
use std::cell::Cell;
|
|
use std::io;
|
|
use std::path::Path;
|
|
use std::sync::{
|
|
atomic::{AtomicBool, AtomicU16, AtomicU32, AtomicU64, Ordering},
|
|
Arc, Mutex, OnceLock,
|
|
};
|
|
|
|
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(unix)]
|
|
pub use self::terminal::InputState;
|
|
use self::terminal::{GhosttyPaneTerminal, PaneTerminal};
|
|
pub(crate) use self::terminal::{
|
|
TerminalCompressionStep, TerminalDirtyPatch, TerminalDirtyPatchOutcome, TerminalReadSnapshot,
|
|
TerminalSearchDirection, TerminalSearchWindow, TerminalTextPoint, TerminalWordMotion,
|
|
};
|
|
pub use self::{
|
|
state::PaneState,
|
|
terminal::{ScrollMetrics, TerminalCursorState},
|
|
};
|
|
|
|
pub(crate) struct TerminalDirtyPatchSnapshot {
|
|
pub patch: TerminalDirtyPatchOutcome,
|
|
pub content_revision: u64,
|
|
pub scroll_metrics: Option<ScrollMetrics>,
|
|
pub mouse_reporting: bool,
|
|
pub sgr_pixel_mouse: bool,
|
|
pub alternate_screen_active: bool,
|
|
pub graphics_may_have_placements: bool,
|
|
}
|
|
|
|
const RELEASE_REACQUIRE_SUPPRESSION: std::time::Duration = std::time::Duration::from_secs(1);
|
|
const TERMINAL_COMPRESSION_IDLE: std::time::Duration = std::time::Duration::from_millis(250);
|
|
const TERMINAL_COMPRESSION_STEP: std::time::Duration = std::time::Duration::from_millis(1);
|
|
pub(crate) const PANE_TERM: &str = "xterm-256color";
|
|
const PANE_COLORTERM: &str = "truecolor";
|
|
|
|
fn terminal_compression_permits() -> Arc<tokio::sync::Semaphore> {
|
|
static PERMITS: OnceLock<Arc<tokio::sync::Semaphore>> = OnceLock::new();
|
|
PERMITS
|
|
.get_or_init(|| Arc::new(tokio::sync::Semaphore::new(4)))
|
|
.clone()
|
|
}
|
|
|
|
fn spawn_blocking_with_compression_permit<T, F>(
|
|
permit: tokio::sync::OwnedSemaphorePermit,
|
|
operation: F,
|
|
) -> tokio::task::JoinHandle<T>
|
|
where
|
|
T: Send + 'static,
|
|
F: FnOnce() -> T + Send + 'static,
|
|
{
|
|
tokio::task::spawn_blocking(move || {
|
|
let _permit = permit;
|
|
operation()
|
|
})
|
|
}
|
|
|
|
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");
|
|
// OMP sets OMPCODE for shells it spawns. A pane launched from inside OMP
|
|
// must not inherit it or its root agent would look like a nested session.
|
|
cmd.env_remove("OMPCODE");
|
|
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
|
|
}
|
|
|
|
/// Drops retained OSC evidence when changing away from an identified agent.
|
|
/// First acquisition keeps bytes that the newly identified process may have
|
|
/// emitted before the process probe recognized it.
|
|
fn clear_osc_evidence_for_agent_transition(terminal: &PaneTerminal, previous_agent: Option<Agent>) {
|
|
if previous_agent.is_some() {
|
|
terminal.clear_agent_osc_state();
|
|
}
|
|
}
|
|
|
|
fn apply_foreground_shell_agent_action(
|
|
agent_presence: &mut AgentDetectionPresence,
|
|
action: ForegroundShellAgentAction,
|
|
previous_agent: Option<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 replacement agent must not inherit OSC evidence
|
|
// from the previous process; a first acquisition keeps
|
|
// the evidence its own process already emitted.
|
|
clear_osc_evidence_for_agent_transition(&terminal, previous_agent);
|
|
if let Some(agent) = agent {
|
|
agent_startup_grace_until = Some(now + AGENT_STARTUP_GRACE_WINDOW);
|
|
state = AgentState::Unknown;
|
|
last_visible_idle = false;
|
|
last_visible_blocker = false;
|
|
last_visible_working = false;
|
|
last_visible_signal_refresh = None;
|
|
publish_agent_process_detected_event(
|
|
state_events.clone(),
|
|
pane_id,
|
|
agent,
|
|
now,
|
|
)
|
|
.await;
|
|
} else {
|
|
agent_startup_grace_until = None;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let process_exited = pending_foreground_shell_clear
|
|
&& agent.is_some()
|
|
&& !foreground_shell_exit_reported;
|
|
|
|
if lifecycle_authority_active && !process_exited {
|
|
pending_idle.clear();
|
|
continue;
|
|
}
|
|
|
|
if let Some(until) = agent_startup_grace_until {
|
|
if process_exited {
|
|
agent_startup_grace_until = None;
|
|
pending_idle.clear();
|
|
} else {
|
|
if now < until {
|
|
pending_idle.clear();
|
|
continue;
|
|
}
|
|
agent_startup_grace_until = None;
|
|
last_screen_scan_detection_content_seq = None;
|
|
pending_idle.clear();
|
|
continue;
|
|
}
|
|
}
|
|
|
|
let current_detection_content_seq = if agent.is_some() {
|
|
Some(detection_content_seq.load(Ordering::Relaxed))
|
|
} else {
|
|
None
|
|
};
|
|
match decide_detection_screen_read(DetectionScreenReadInput {
|
|
state,
|
|
agent,
|
|
pending_idle_active: pending_idle.active(),
|
|
agent_changed,
|
|
process_exited,
|
|
current_detection_content_seq,
|
|
last_screen_scan_detection_content_seq,
|
|
}) {
|
|
DetectionScreenReadDecision::Read => {}
|
|
DetectionScreenReadDecision::Skip => continue,
|
|
}
|
|
|
|
let content = terminal.detection_text();
|
|
last_screen_scan_detection_content_seq = current_detection_content_seq;
|
|
let content_changed = content != last_detection_text;
|
|
last_detection_text.clone_from(&content);
|
|
if !process_exited && crate::detect::should_skip_state_update(agent, &content) {
|
|
pending_idle.clear();
|
|
continue;
|
|
}
|
|
sync_content_change_acquisition(
|
|
agent_presence.current_agent(),
|
|
suppressed_agent,
|
|
process_group_changed,
|
|
content_changed,
|
|
now,
|
|
&mut acquisition_started_at,
|
|
&mut last_content_change_at,
|
|
);
|
|
|
|
let osc_title = terminal.agent_osc_title();
|
|
let osc_progress = terminal.agent_osc_progress();
|
|
let Some(screen_detection) = detection_update_for_publish_with_osc(
|
|
agent,
|
|
&content,
|
|
&osc_title,
|
|
&osc_progress,
|
|
process_exited,
|
|
) else {
|
|
pending_idle.clear();
|
|
continue;
|
|
};
|
|
match decide_screen_detection_publish(
|
|
ScreenDetectionPublishInput {
|
|
screen_detection,
|
|
current_state: state,
|
|
last_visible_idle,
|
|
last_visible_blocker,
|
|
last_visible_working,
|
|
last_visible_signal_refresh,
|
|
process_exited,
|
|
agent_changed,
|
|
now,
|
|
},
|
|
&mut pending_idle,
|
|
) {
|
|
DetectionPublishDecision::NoPublish => {}
|
|
DetectionPublishDecision::Publish {
|
|
state: new_state,
|
|
visible_idle,
|
|
visible_blocker,
|
|
visible_working,
|
|
process_exited: publish_process_exited,
|
|
} => {
|
|
apply_agent_detection_publish_update(
|
|
state_events.clone(),
|
|
pane_id,
|
|
agent,
|
|
AgentDetectionPublishUpdate {
|
|
state: new_state,
|
|
visible_idle,
|
|
visible_blocker,
|
|
visible_working,
|
|
process_exited: publish_process_exited,
|
|
},
|
|
now,
|
|
&mut state,
|
|
&mut last_visible_idle,
|
|
&mut last_visible_blocker,
|
|
&mut last_visible_working,
|
|
&mut last_visible_signal_refresh,
|
|
&mut foreground_shell_exit_reported,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
(handle.abort_handle(), detect_reset_notify, pending_release)
|
|
}
|
|
|
|
impl AgentDetectionPresence {
|
|
fn from_agent(current_agent: Option<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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Clone)]
|
|
struct TerminalCompressionWake {
|
|
notify: Arc<Notify>,
|
|
generation: Arc<AtomicU64>,
|
|
}
|
|
|
|
impl TerminalCompressionWake {
|
|
fn wake(&self) {
|
|
self.generation.fetch_add(1, Ordering::Release);
|
|
self.notify.notify_one();
|
|
}
|
|
}
|
|
|
|
/// Drives libghostty-vt's caller-owned compression after terminal activity settles.
|
|
struct TerminalCompressionTask {
|
|
wake: TerminalCompressionWake,
|
|
#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
|
|
completed_passes: Arc<AtomicU64>,
|
|
handle: tokio::task::AbortHandle,
|
|
}
|
|
|
|
impl Drop for TerminalCompressionTask {
|
|
fn drop(&mut self) {
|
|
self.handle.abort();
|
|
}
|
|
}
|
|
|
|
impl TerminalCompressionTask {
|
|
fn spawn(pane_id: PaneId, terminal: Arc<PaneTerminal>) -> Self {
|
|
let wake = TerminalCompressionWake {
|
|
notify: Arc::new(Notify::new()),
|
|
generation: Arc::new(AtomicU64::new(0)),
|
|
};
|
|
let task_notify = wake.notify.clone();
|
|
let task_generation = wake.generation.clone();
|
|
#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
|
|
let completed_passes = Arc::new(AtomicU64::new(0));
|
|
#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
|
|
let task_completed_passes = completed_passes.clone();
|
|
let handle = tokio::spawn(async move {
|
|
run_terminal_compression_task(
|
|
pane_id,
|
|
terminal,
|
|
task_notify,
|
|
task_generation,
|
|
#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
|
|
task_completed_passes,
|
|
)
|
|
.await;
|
|
})
|
|
.abort_handle();
|
|
Self {
|
|
wake,
|
|
#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
|
|
completed_passes,
|
|
handle,
|
|
}
|
|
}
|
|
|
|
fn wake(&self) {
|
|
self.wake.wake();
|
|
}
|
|
|
|
fn notifier(&self) -> TerminalCompressionWake {
|
|
self.wake.clone()
|
|
}
|
|
|
|
fn abort(&self) {
|
|
self.handle.abort();
|
|
}
|
|
|
|
#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
|
|
fn completed_passes(&self) -> u64 {
|
|
self.completed_passes.load(Ordering::Acquire)
|
|
}
|
|
}
|
|
|
|
async fn run_terminal_compression_task(
|
|
pane_id: PaneId,
|
|
terminal: Arc<PaneTerminal>,
|
|
notify: Arc<Notify>,
|
|
generation: Arc<AtomicU64>,
|
|
#[cfg(all(test, any(target_os = "linux", target_os = "macos")))] completed_passes: Arc<
|
|
AtomicU64,
|
|
>,
|
|
) {
|
|
let mut observed_generation = generation.load(Ordering::Acquire);
|
|
let mut activity = loop {
|
|
match terminal.try_compression_activity() {
|
|
Ok(Some(activity)) => break activity,
|
|
Ok(None) => tokio::time::sleep(TERMINAL_COMPRESSION_IDLE).await,
|
|
Err(err) => {
|
|
warn!(pane = pane_id.raw(), err = %err, "failed to read terminal compression activity");
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
|
|
'schedule: loop {
|
|
loop {
|
|
tokio::time::sleep(TERMINAL_COMPRESSION_IDLE).await;
|
|
let current = match terminal.try_compression_activity() {
|
|
Ok(Some(current)) => current,
|
|
Ok(None) => continue,
|
|
Err(err) => {
|
|
warn!(pane = pane_id.raw(), err = %err, "failed to read terminal compression activity");
|
|
return;
|
|
}
|
|
};
|
|
let current_generation = generation.load(Ordering::Acquire);
|
|
if activity == current && observed_generation == current_generation {
|
|
break;
|
|
}
|
|
activity = current;
|
|
observed_generation = current_generation;
|
|
}
|
|
|
|
loop {
|
|
let current_generation = generation.load(Ordering::Acquire);
|
|
if observed_generation != current_generation {
|
|
observed_generation = current_generation;
|
|
continue 'schedule;
|
|
}
|
|
|
|
let permit = match terminal_compression_permits().acquire_owned().await {
|
|
Ok(permit) => permit,
|
|
Err(_) => return,
|
|
};
|
|
let current_generation = generation.load(Ordering::Acquire);
|
|
if observed_generation != current_generation {
|
|
observed_generation = current_generation;
|
|
continue 'schedule;
|
|
}
|
|
|
|
let terminal_for_step = terminal.clone();
|
|
let step = spawn_blocking_with_compression_permit(permit, move || {
|
|
terminal_for_step.try_compress_incremental_if_activity(activity)
|
|
})
|
|
.await;
|
|
let step = match step {
|
|
Ok(Ok(step)) => step,
|
|
Ok(Err(err)) => {
|
|
warn!(pane = pane_id.raw(), err = %err, "failed to compress terminal scrollback");
|
|
return;
|
|
}
|
|
Err(err) => {
|
|
warn!(pane = pane_id.raw(), err = %err, "terminal compression worker failed");
|
|
return;
|
|
}
|
|
};
|
|
|
|
match step {
|
|
TerminalCompressionStep::Busy => continue 'schedule,
|
|
TerminalCompressionStep::ActivityChanged(current) => {
|
|
activity = current;
|
|
observed_generation = generation.load(Ordering::Acquire);
|
|
continue 'schedule;
|
|
}
|
|
TerminalCompressionStep::Compressed(
|
|
crate::ghostty::TerminalCompressionResult::Unsupported,
|
|
) => return,
|
|
TerminalCompressionStep::Compressed(
|
|
crate::ghostty::TerminalCompressionResult::Pending,
|
|
) => tokio::time::sleep(TERMINAL_COMPRESSION_STEP).await,
|
|
TerminalCompressionStep::Compressed(
|
|
crate::ghostty::TerminalCompressionResult::Complete,
|
|
) => {
|
|
#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
|
|
completed_passes.fetch_add(1, Ordering::Release);
|
|
loop {
|
|
notify.notified().await;
|
|
let current_generation = generation.load(Ordering::Acquire);
|
|
if observed_generation != current_generation {
|
|
observed_generation = current_generation;
|
|
continue 'schedule;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// PTY runtime for a pane. Owns the terminal, I/O channels, and background tasks.
|
|
/// Dropping this aborts async tasks and closes the PTY. An already-running bounded
|
|
/// compression step may finish before releasing its terminal reference.
|
|
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>,
|
|
content_write_lock: Arc<Mutex<()>>,
|
|
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
|
|
compression: TerminalCompressionTask,
|
|
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 { .. } => {}
|
|
}
|
|
}
|
|
|
|
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 queue_user_input_submission(
|
|
&self,
|
|
text: Bytes,
|
|
enter: Bytes,
|
|
delay: std::time::Duration,
|
|
deadline: Option<std::time::Instant>,
|
|
) -> std::io::Result<std::sync::mpsc::Receiver<std::io::Result<()>>> {
|
|
match self {
|
|
PaneRuntimeIo::Actor(actor) => {
|
|
#[cfg(windows)]
|
|
return actor.queue_user_input_submission(text, enter, delay, deadline);
|
|
#[cfg(unix)]
|
|
{
|
|
let _ = deadline;
|
|
actor.queue_user_input_submission(text, enter, delay)
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
PaneRuntimeIo::TestChannel { sender, .. } => {
|
|
let _ = deadline;
|
|
let sender = sender.clone();
|
|
let (reply_tx, reply_rx) = std::sync::mpsc::channel();
|
|
std::thread::spawn(move || {
|
|
let result = sender
|
|
.try_send(text)
|
|
.map_err(std::io::Error::other)
|
|
.and_then(|()| {
|
|
std::thread::sleep(delay);
|
|
sender.try_send(enter).map_err(std::io::Error::other)
|
|
});
|
|
let _ = reply_tx.send(result);
|
|
});
|
|
Ok(reply_rx)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum WheelRouting {
|
|
HostScroll,
|
|
MouseReport,
|
|
AlternateScroll,
|
|
}
|
|
|
|
impl Drop for PaneRuntime {
|
|
fn drop(&mut self) {
|
|
// Abort detection task immediately and terminate the owned session.
|
|
// The PTY actor shuts down before the process/session policy runs.
|
|
if let Some(handle) = &self.detect_handle {
|
|
handle.abort();
|
|
}
|
|
self.compression.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 {
|
|
default_windows_pane_shell(std::env::var_os("PATH"))
|
|
}
|
|
|
|
/// Windows has no `$SHELL`, so an unset `[terminal] default_shell` has to name
|
|
/// a concrete executable. `powershell.exe` (5.1) is the only one guaranteed to
|
|
/// exist, but `pwsh` (7+) is what every other Windows terminal prefers when it
|
|
/// is installed. Return the first launchable `pwsh.exe` on `PATH` as a full
|
|
/// path so the pane launches exactly the binary that was validated; keep the
|
|
/// inbox shell when none is found. An explicit `default_shell` still wins.
|
|
#[cfg(any(windows, test))]
|
|
fn default_windows_pane_shell(path: Option<std::ffi::OsString>) -> String {
|
|
path.as_deref()
|
|
.into_iter()
|
|
.flat_map(std::env::split_paths)
|
|
.filter(|dir| dir.is_absolute())
|
|
.map(|dir| dir.join("pwsh.exe"))
|
|
.find(|candidate| is_windows_executable_file(candidate))
|
|
.and_then(|path| path.into_os_string().into_string().ok())
|
|
.unwrap_or_else(|| "powershell.exe".into())
|
|
}
|
|
|
|
/// Validate that `path` looks like a Windows executable image the current
|
|
/// machine can launch: a DOS `MZ` header whose `e_lfanew` points at a `PE\0\0`
|
|
/// signature with a non-empty, executable-image COFF header for a compatible
|
|
/// machine and a PE32/PE32+ optional header that fits in the file.
|
|
///
|
|
/// `portable-pty` resolves the configured shell with `Path::exists` and hands
|
|
/// it straight to `CreateProcessW`, which does not fall back to later `PATH`
|
|
/// entries, so a malformed, DLL, or foreign-architecture `pwsh.exe` must be
|
|
/// skipped here instead of selected. Actually launching the candidate is the
|
|
/// only way to prove it loads, and this deliberately does not do that.
|
|
#[cfg(any(windows, test))]
|
|
fn is_windows_executable_file(path: &std::path::Path) -> bool {
|
|
is_windows_executable_file_for_host(path, windows_host_native_machine())
|
|
}
|
|
|
|
/// Native processor architecture of the host. Non-Windows builds only reach
|
|
/// this from the cross-platform unit tests, which model an x64 host.
|
|
#[cfg(any(windows, test))]
|
|
fn windows_host_native_machine() -> u16 {
|
|
#[cfg(windows)]
|
|
{
|
|
crate::platform::native_machine_type()
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
0x8664
|
|
}
|
|
}
|
|
|
|
#[cfg(any(windows, test))]
|
|
fn is_windows_executable_file_for_host(path: &std::path::Path, native_machine: u16) -> bool {
|
|
use std::io::{Read, Seek, SeekFrom};
|
|
|
|
const PE_OFFSET_FIELD: usize = 0x3c;
|
|
const DOS_HEADER_LEN: u64 = 0x40;
|
|
const PE_SIGNATURE: &[u8; 4] = b"PE\0\0";
|
|
const COFF_HEADER_LEN: usize = 20;
|
|
const SECTION_HEADER_LEN: u64 = 40;
|
|
const OPTIONAL_HEADER_MAGIC_LEN: u64 = 2;
|
|
const IMAGE_FILE_EXECUTABLE_IMAGE: u16 = 0x0002;
|
|
const IMAGE_FILE_DLL: u16 = 0x2000;
|
|
|
|
let Ok(mut file) = std::fs::File::open(path) else {
|
|
return false;
|
|
};
|
|
let Ok(len) = file.metadata().map(|metadata| metadata.len()) else {
|
|
return false;
|
|
};
|
|
|
|
let mut dos_header = [0u8; DOS_HEADER_LEN as usize];
|
|
if file.read_exact(&mut dos_header).is_err() || &dos_header[..2] != b"MZ" {
|
|
return false;
|
|
}
|
|
let pe_offset = u32::from_le_bytes([
|
|
dos_header[PE_OFFSET_FIELD],
|
|
dos_header[PE_OFFSET_FIELD + 1],
|
|
dos_header[PE_OFFSET_FIELD + 2],
|
|
dos_header[PE_OFFSET_FIELD + 3],
|
|
]) as u64;
|
|
let pe_header_len = PE_SIGNATURE.len() as u64 + COFF_HEADER_LEN as u64;
|
|
if pe_offset < DOS_HEADER_LEN || pe_offset.saturating_add(pe_header_len) > len {
|
|
return false;
|
|
}
|
|
if file.seek(SeekFrom::Start(pe_offset)).is_err() {
|
|
return false;
|
|
}
|
|
|
|
let mut header = [0u8; 4 + COFF_HEADER_LEN];
|
|
if file.read_exact(&mut header).is_err() || &header[..4] != PE_SIGNATURE {
|
|
return false;
|
|
}
|
|
let machine = u16::from_le_bytes([header[4], header[5]]);
|
|
let number_of_sections = u16::from_le_bytes([header[4 + 2], header[4 + 3]]);
|
|
let optional_header_len = u16::from_le_bytes([header[4 + 16], header[4 + 17]]) as u64;
|
|
let characteristics = u16::from_le_bytes([header[4 + 18], header[4 + 19]]);
|
|
|
|
if !windows_executable_machine_is_compatible(machine, native_machine)
|
|
|| number_of_sections == 0
|
|
|| characteristics & IMAGE_FILE_EXECUTABLE_IMAGE == 0
|
|
|| characteristics & IMAGE_FILE_DLL != 0
|
|
|| optional_header_len < OPTIONAL_HEADER_MAGIC_LEN
|
|
|| pe_offset.saturating_add(
|
|
pe_header_len + optional_header_len + number_of_sections as u64 * SECTION_HEADER_LEN,
|
|
) > len
|
|
{
|
|
return false;
|
|
}
|
|
|
|
let mut magic = [0u8; 2];
|
|
file.read_exact(&mut magic).is_ok() && matches!(u16::from_le_bytes(magic), 0x010b | 0x020b)
|
|
}
|
|
|
|
/// Whether a Windows host can launch a given `IMAGE_FILE_MACHINE_*` image.
|
|
/// A native host runs its own architecture; ARM64 Windows also runs x64 and
|
|
/// x86 through emulation, and x64 Windows runs x86 through WOW64. An unknown
|
|
/// host (detection failed) is treated as the x64 build Herdr ships.
|
|
#[cfg(any(windows, test))]
|
|
fn windows_executable_machine_is_compatible(machine: u16, native_machine: u16) -> bool {
|
|
const MACHINE_I386: u16 = 0x014c;
|
|
const MACHINE_AMD64: u16 = 0x8664;
|
|
const MACHINE_ARM64: u16 = 0xaa64;
|
|
|
|
match native_machine {
|
|
MACHINE_ARM64 => matches!(machine, MACHINE_I386 | MACHINE_AMD64 | MACHINE_ARM64),
|
|
MACHINE_I386 => machine == MACHINE_I386,
|
|
_ => matches!(machine, MACHINE_I386 | MACHINE_AMD64),
|
|
}
|
|
}
|
|
|
|
#[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 } }";
|
|
|
|
/// Login flags appended when launching POSIX-style shells on Windows.
|
|
///
|
|
/// `portable-pty`'s default-program path resolves to `%ComSpec%` (cmd.exe) on
|
|
/// Windows and ignores the `SHELL` env override, and the Unix argv0-prefix
|
|
/// login convention does not exist there. A login shell therefore has to be
|
|
/// launched directly with the shell's own login flag. Only POSIX-style shells
|
|
/// that define one (the sh family, fish, and csh/tcsh) get it; Windows-native
|
|
/// shells such as cmd.exe and PowerShell have no login concept and launch
|
|
/// plain.
|
|
fn windows_login_shell_args(shell: &str) -> &'static [&'static str] {
|
|
let name = shell
|
|
.rsplit(['/', '\\'])
|
|
.next()
|
|
.unwrap_or(shell)
|
|
.to_ascii_lowercase();
|
|
let name = name.strip_suffix(".exe").unwrap_or(&name);
|
|
match name {
|
|
"sh" | "bash" | "zsh" | "ksh" | "dash" | "ash" | "mksh" | "fish" | "csh" | "tcsh" => {
|
|
&["-l"]
|
|
}
|
|
_ => &[],
|
|
}
|
|
}
|
|
|
|
fn pane_shell_command_builder_for_target(
|
|
shell_config: PaneShellConfig<'_>,
|
|
target: ShellLaunchTarget,
|
|
) -> io::Result<CommandBuilder> {
|
|
let shell = pane_shell(shell_config.default_shell);
|
|
// Unix login shells go through portable-pty's default-program builder so
|
|
// they receive the login argv0 convention. Windows has no such convention
|
|
// and the default-program builder would launch cmd.exe, so Windows login
|
|
// shells are built directly below like every other direct-shell target.
|
|
if shell_mode_uses_login_shell(shell_config.mode, target)
|
|
&& target != ShellLaunchTarget::Windows
|
|
{
|
|
let mut cmd = CommandBuilder::new_default_prog();
|
|
cmd.env("SHELL", resolve_shell_for_login_mode(&shell)?);
|
|
return Ok(cmd);
|
|
}
|
|
|
|
let mut cmd = CommandBuilder::new(&shell);
|
|
if shell_mode_uses_login_shell(shell_config.mode, target) {
|
|
cmd.args(windows_login_shell_args(&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 {
|
|
// Login mode no longer routes Windows through the default-program builder,
|
|
// so login PowerShell panes are launched directly and can carry the same
|
|
// prompt-based cwd reporting as non-login panes.
|
|
target == ShellLaunchTarget::Windows
|
|
&& 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.compression.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.compression.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(crate::platform::normalize_cwd_for_launch(&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(crate::platform::normalize_cwd_for_launch(&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(crate::platform::normalize_cwd_for_launch(&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 compression = TerminalCompressionTask::spawn(pane_id, terminal.clone());
|
|
let child_pid = Arc::new(AtomicU32::new(child_pid));
|
|
let reported_cwd = Arc::new(Mutex::new(None));
|
|
let kitty_keyboard_flags = Arc::new(AtomicU16::new(keyboard_protocol_flags));
|
|
let content_seq = Arc::new(AtomicU64::new(0));
|
|
let content_write_lock = Arc::new(Mutex::new(()));
|
|
let detection_content_seq = Arc::new(AtomicU64::new(0));
|
|
|
|
let io = {
|
|
let terminal = terminal.clone();
|
|
let response_writer = response_tx.clone();
|
|
let render_notify = render_notify.clone();
|
|
let render_dirty = render_dirty.clone();
|
|
let content_seq = content_seq.clone();
|
|
let content_write_lock = content_write_lock.clone();
|
|
let detection_content_seq = detection_content_seq.clone();
|
|
let child_pid = child_pid.clone();
|
|
let read_events = events.clone();
|
|
let reported_cwd = reported_cwd.clone();
|
|
let compression_wake = compression.notifier();
|
|
let rt = tokio::runtime::Handle::current();
|
|
let delay_rt = rt.clone();
|
|
let on_read = Box::new(move |bytes: &[u8]| {
|
|
let _content_write_guard = match content_write_lock.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => poisoned.into_inner(),
|
|
};
|
|
content_seq.fetch_add(1, Ordering::AcqRel);
|
|
let shell_pid = child_pid.load(Ordering::Acquire);
|
|
let result =
|
|
terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer);
|
|
content_seq.fetch_add(1, Ordering::Release);
|
|
drop(_content_write_guard);
|
|
compression_wake.wake();
|
|
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 || {
|
|
// Imported handoff panes have no child wait handle, so their exit cause is
|
|
// unknowable. Checkpoint conservatively; normal autosave settles clean exits.
|
|
let _ = rt.block_on(exit_events.send(AppEvent::PaneDied {
|
|
pane_id,
|
|
exit_reason: crate::platform::ChildExitReason::Handoff,
|
|
}));
|
|
debug!(pane = pane_id.raw(), "handoff PTY actor exiting");
|
|
});
|
|
PaneRuntimeIo::Actor(PtyIoActor::spawn(PtyIoActorConfig {
|
|
pane_id: pane_id.raw(),
|
|
master_fd,
|
|
initially_quiesced: true,
|
|
on_read,
|
|
on_reader_exit: Some(on_reader_exit),
|
|
})?)
|
|
};
|
|
|
|
let full_lifecycle_authority_active = Arc::new(AtomicBool::new(false));
|
|
let (detect_handle, detect_reset_notify, pending_release) = spawn_basic_detection_task(
|
|
pane_id,
|
|
child_pid.clone(),
|
|
terminal.clone(),
|
|
detection_content_seq.clone(),
|
|
full_lifecycle_authority_active.clone(),
|
|
events,
|
|
);
|
|
|
|
Ok(Self {
|
|
pane_id,
|
|
terminal,
|
|
io,
|
|
current_size: Cell::new((rows, cols, cell_width_px, cell_height_px)),
|
|
child_pid,
|
|
reported_cwd,
|
|
child_wait_completed: None,
|
|
kitty_keyboard_flags,
|
|
content_seq,
|
|
content_write_lock,
|
|
detection_content_seq,
|
|
full_lifecycle_authority_active,
|
|
detect_reset_notify,
|
|
pending_release,
|
|
preserve_processes_on_drop: true,
|
|
compression,
|
|
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 compression = TerminalCompressionTask::spawn(pane_id, terminal.clone());
|
|
let kitty_keyboard_flags = Arc::new(AtomicU16::new(0));
|
|
let content_write_lock = Arc::new(Mutex::new(()));
|
|
|
|
let spawned = crate::pty::backend::spawn_with_portable_pty(rows, cols, cmd)
|
|
.inspect_err(|err| error!(pane = pane_id.raw(), err = %err, "{spawn_error_message}"))?;
|
|
|
|
// --- Child watcher task ---
|
|
let child_pid = Arc::new(AtomicU32::new(0));
|
|
let reported_cwd = Arc::new(Mutex::new(None));
|
|
let child_wait_completed = Arc::new(AtomicBool::new(false));
|
|
let content_seq = Arc::new(AtomicU64::new(0));
|
|
let detection_content_seq = Arc::new(AtomicU64::new(0));
|
|
let full_lifecycle_authority_active = Arc::new(AtomicBool::new(false));
|
|
{
|
|
let child_pid = child_pid.clone();
|
|
let child_wait_completed = child_wait_completed.clone();
|
|
let events = events.clone();
|
|
let rt = tokio::runtime::Handle::current();
|
|
let mut child = spawned.child;
|
|
if let Some(pid) = child.process_id() {
|
|
child_pid.store(pid, Ordering::Release);
|
|
crate::logging::pane_spawned(pane_id.raw(), pid);
|
|
}
|
|
tokio::task::spawn_blocking(move || {
|
|
let exit_reason = match child.wait() {
|
|
Ok(status) => {
|
|
let exit_reason = crate::platform::classify_child_exit(&status);
|
|
let status_text = format!("{status:?}");
|
|
crate::logging::pane_exited(pane_id.raw(), &status_text);
|
|
exit_reason
|
|
}
|
|
Err(e) => {
|
|
crate::logging::pane_exit_failed(pane_id.raw(), &e.to_string());
|
|
crate::platform::ChildExitReason::WaitFailed
|
|
}
|
|
};
|
|
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,
|
|
exit_reason,
|
|
})) {
|
|
error!(pane = pane_id.raw(), err = %e, "failed to send PaneDied event");
|
|
}
|
|
});
|
|
}
|
|
|
|
let io = {
|
|
let terminal = terminal.clone();
|
|
let response_writer = response_tx.clone();
|
|
let render_notify = render_notify.clone();
|
|
let render_dirty = render_dirty.clone();
|
|
let content_seq = content_seq.clone();
|
|
let content_write_lock = content_write_lock.clone();
|
|
let detection_content_seq = detection_content_seq.clone();
|
|
let child_pid = child_pid.clone();
|
|
let events = events.clone();
|
|
let reported_cwd = reported_cwd.clone();
|
|
let compression_wake = compression.notifier();
|
|
let rt = tokio::runtime::Handle::current();
|
|
let on_read = Box::new(move |bytes: &[u8]| {
|
|
let _content_write_guard = match content_write_lock.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => poisoned.into_inner(),
|
|
};
|
|
content_seq.fetch_add(1, Ordering::AcqRel);
|
|
let shell_pid = child_pid.load(Ordering::Acquire);
|
|
let result =
|
|
terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer);
|
|
content_seq.fetch_add(1, Ordering::Release);
|
|
drop(_content_write_guard);
|
|
compression_wake.wake();
|
|
publish_terminal_bells(pane_id, result.terminal_bells, &events);
|
|
if agent_detection == AgentDetection::Enabled {
|
|
observe_detection_content_change(bytes, &detection_content_seq);
|
|
}
|
|
let title_requested =
|
|
result.terminal_title_changed && render_dirty.request_terminal_title(pane_id);
|
|
let render_requested = result.request_render && render_dirty.request_pty(pane_id);
|
|
if title_requested || render_requested {
|
|
render_notify.notify_one();
|
|
}
|
|
if let Some(delay) = result.render_delay {
|
|
let render_notify = render_notify.clone();
|
|
let render_dirty = render_dirty.clone();
|
|
rt.spawn(async move {
|
|
tokio::time::sleep(delay).await;
|
|
if render_dirty.request_pty(pane_id) {
|
|
render_notify.notify_one();
|
|
}
|
|
});
|
|
}
|
|
if let Some(cwd) = result.reported_cwd.clone() {
|
|
publish_reported_cwd(pane_id, cwd, &reported_cwd, &events);
|
|
}
|
|
for content in result.clipboard_writes {
|
|
if let Err(err) = events.try_send(AppEvent::ClipboardWrite { content }) {
|
|
warn!(
|
|
pane = pane_id.raw(),
|
|
err = %err,
|
|
"failed to send OSC 52 clipboard write"
|
|
);
|
|
}
|
|
}
|
|
PtyReadResult {
|
|
terminal_responses: result.terminal_responses,
|
|
}
|
|
});
|
|
PaneRuntimeIo::Actor(PtyIoActor::spawn(PtyIoActorConfig {
|
|
pane_id: pane_id.raw(),
|
|
#[cfg(unix)]
|
|
master_fd: spawned.master_fd,
|
|
#[cfg(windows)]
|
|
master: spawned.master,
|
|
initially_quiesced: false,
|
|
on_read,
|
|
on_reader_exit: None,
|
|
})?)
|
|
};
|
|
|
|
// --- Detection task ---
|
|
let (detect_handle, detect_reset_notify, pending_release) = if agent_detection
|
|
== AgentDetection::Enabled
|
|
{
|
|
use crate::detect;
|
|
use std::time::{Duration, Instant};
|
|
|
|
const TICK_UNIDENTIFIED: Duration = Duration::from_millis(500);
|
|
const TICK_IDENTIFIED: Duration = Duration::from_millis(300);
|
|
const TICK_PENDING_RELEASE: Duration = Duration::from_millis(50);
|
|
|
|
let child_pid = child_pid.clone();
|
|
let terminal = terminal.clone();
|
|
let state_events = events.clone();
|
|
let detection_content_seq = detection_content_seq.clone();
|
|
let full_lifecycle_authority_active_for_task = full_lifecycle_authority_active.clone();
|
|
let render_notify = render_notify.clone();
|
|
let render_dirty = render_dirty.clone();
|
|
let detect_reset_notify = Arc::new(Notify::new());
|
|
let detect_reset = detect_reset_notify.clone();
|
|
let pending_release = Arc::new(Mutex::new(None));
|
|
let pending_release_for_task = pending_release.clone();
|
|
|
|
let handle = tokio::spawn(async move {
|
|
let mut agent_presence =
|
|
AgentDetectionPresence::from_agent(initial_state.detected_agent);
|
|
let mut state = AgentState::Idle;
|
|
let mut last_visible_idle = initial_state.detected_agent.is_some();
|
|
let mut last_process_check = Instant::now();
|
|
#[cfg(windows)]
|
|
let mut last_observation = (Instant::now(), Some(0));
|
|
let mut last_foreground_pgid = None;
|
|
let mut has_process_probe = false;
|
|
let mut acquisition_started_at = None;
|
|
let mut last_content_change_at = None;
|
|
let mut pending_foreground_shell_clear = false;
|
|
let mut foreground_shell_exit_reported = false;
|
|
let mut release_was_active = false;
|
|
let mut pending_restore_probe = initial_state.detected_agent.is_some();
|
|
let mut last_visible_blocker = false;
|
|
let mut last_visible_working = false;
|
|
let mut last_visible_signal_refresh = None;
|
|
let mut last_detection_text = String::new();
|
|
let mut last_screen_scan_detection_content_seq = None;
|
|
let mut agent_startup_grace_until = None;
|
|
let mut pending_idle = PendingIdleConfirmation::default();
|
|
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
|
|
loop {
|
|
let now_for_tick = Instant::now();
|
|
let tick = if active_pending_release(&pending_release_for_task, now_for_tick)
|
|
.is_some()
|
|
|| terminal.has_transient_default_color_override()
|
|
{
|
|
TICK_PENDING_RELEASE
|
|
} else if pending_idle.active() {
|
|
AGENT_PENDING_IDLE_RECHECK
|
|
} else if agent_presence.current_agent().is_none() {
|
|
TICK_UNIDENTIFIED
|
|
} else {
|
|
TICK_IDENTIFIED
|
|
};
|
|
tokio::select! {
|
|
_ = tokio::time::sleep(tick) => {}
|
|
_ = detect_reset.notified() => {
|
|
agent_presence = AgentDetectionPresence::from_agent(None);
|
|
state = AgentState::Unknown;
|
|
last_visible_idle = false;
|
|
last_foreground_pgid = None;
|
|
has_process_probe = false;
|
|
acquisition_started_at = None;
|
|
last_content_change_at = None;
|
|
pending_foreground_shell_clear = false;
|
|
foreground_shell_exit_reported = false;
|
|
release_was_active = false;
|
|
pending_restore_probe = false;
|
|
last_visible_blocker = false;
|
|
last_visible_working = false;
|
|
last_visible_signal_refresh = None;
|
|
last_detection_text.clear();
|
|
last_screen_scan_detection_content_seq = None;
|
|
agent_startup_grace_until = None;
|
|
pending_idle.clear();
|
|
}
|
|
}
|
|
|
|
let now = Instant::now();
|
|
let suppressed_agent = active_pending_release(&pending_release_for_task, now);
|
|
if suppressed_agent.is_none() && release_was_active {
|
|
has_process_probe = false;
|
|
acquisition_started_at = None;
|
|
last_content_change_at = None;
|
|
}
|
|
release_was_active = suppressed_agent.is_some();
|
|
let pid = child_pid.load(Ordering::Acquire);
|
|
let mut agent = agent_presence.current_agent();
|
|
let lifecycle_authority_active =
|
|
full_lifecycle_authority_active_for_task.load(Ordering::Acquire);
|
|
let process_probe_input = ProcessProbeInput {
|
|
current_agent: agent,
|
|
suppressed_agent,
|
|
foreground_pgid: last_foreground_pgid,
|
|
last_foreground_pgid,
|
|
has_process_probe,
|
|
acquisition_age: acquisition_started_at
|
|
.map(|started| now.duration_since(started)),
|
|
pending_foreground_shell_clear,
|
|
pending_restore_probe,
|
|
elapsed_since_process_check: now.duration_since(last_process_check),
|
|
};
|
|
#[cfg(windows)]
|
|
let content_seq = detection_content_seq.load(Ordering::Relaxed);
|
|
#[cfg(windows)]
|
|
let last_content_seq = last_observation.1;
|
|
#[cfg(windows)]
|
|
let foreground_observation_due = should_observe_foreground_process_group(
|
|
lifecycle_authority_active,
|
|
last_content_seq != Some(content_seq)
|
|
&& (last_content_seq.is_some()
|
|
|| now.duration_since(last_observation.0) >= TICK_IDENTIFIED),
|
|
now.duration_since(last_observation.0),
|
|
process_probe_input,
|
|
);
|
|
#[cfg(not(windows))]
|
|
let foreground_observation_due = true;
|
|
let foreground_pgid = match (pid, foreground_observation_due) {
|
|
(0, _) => None,
|
|
(_, true) => detect::foreground_process_group_id(pid),
|
|
_ => last_foreground_pgid,
|
|
};
|
|
#[cfg(windows)]
|
|
if pid > 0 && foreground_observation_due {
|
|
let retry =
|
|
last_content_seq.is_some() && last_content_seq != Some(content_seq);
|
|
last_observation = (now, (!retry).then_some(content_seq));
|
|
}
|
|
let process_group_changed =
|
|
foreground_group_changed(foreground_pgid, last_foreground_pgid);
|
|
let should_check_process = pid > 0 && {
|
|
let process_probe_input = ProcessProbeInput {
|
|
foreground_pgid,
|
|
..process_probe_input
|
|
};
|
|
!should_skip_process_probe_for_lifecycle_authority(
|
|
lifecycle_authority_active,
|
|
process_probe_input,
|
|
) && should_probe_foreground_job(process_probe_input)
|
|
};
|
|
|
|
let mut agent_changed = false;
|
|
if should_check_process {
|
|
last_process_check = now;
|
|
let had_process_probe = has_process_probe;
|
|
has_process_probe = true;
|
|
if pid > 0 {
|
|
let probe = probe_foreground_process(pid, foreground_pgid);
|
|
let process_name = probe.process_name;
|
|
let process_group_id = probe.process_group_id;
|
|
let tracked_process_group_id = process_group_for_change_tracking(
|
|
foreground_pgid,
|
|
process_group_id,
|
|
);
|
|
let foreground_is_pane_shell = probe.foreground_is_pane_shell;
|
|
let mut new_agent = probe.agent;
|
|
|
|
if let Some(suppressed_agent) = suppressed_agent {
|
|
if new_agent == Some(suppressed_agent) {
|
|
new_agent = None;
|
|
} else if let Ok(mut pending_release) =
|
|
pending_release_for_task.lock()
|
|
{
|
|
*pending_release = None;
|
|
}
|
|
}
|
|
|
|
let previous_agent = agent_presence.current_agent();
|
|
let foreground_action = foreground_shell_agent_action(
|
|
previous_agent,
|
|
new_agent,
|
|
foreground_is_pane_shell,
|
|
foreground_shell_exit_reported,
|
|
);
|
|
let changed = apply_foreground_shell_agent_action(
|
|
&mut agent_presence,
|
|
foreground_action,
|
|
previous_agent,
|
|
new_agent,
|
|
&mut pending_foreground_shell_clear,
|
|
&mut foreground_shell_exit_reported,
|
|
);
|
|
last_foreground_pgid = tracked_process_group_id;
|
|
if new_agent.is_some() {
|
|
acquisition_started_at = None;
|
|
last_content_change_at = None;
|
|
} else if agent_presence.current_agent().is_none()
|
|
&& had_process_probe
|
|
&& process_group_changed
|
|
{
|
|
acquisition_started_at = Some(now);
|
|
}
|
|
pending_restore_probe = false;
|
|
if changed {
|
|
agent = agent_presence.current_agent();
|
|
if agent != previous_agent
|
|
|| foreground_action
|
|
== ForegroundShellAgentAction::ReportReplacementProcess
|
|
{
|
|
pending_idle.clear();
|
|
last_screen_scan_detection_content_seq = None;
|
|
// A replacement agent must not inherit OSC
|
|
// evidence from the previous process; a first
|
|
// acquisition keeps the evidence its own
|
|
// process already emitted.
|
|
clear_osc_evidence_for_agent_transition(
|
|
&terminal,
|
|
previous_agent,
|
|
);
|
|
if let Some(agent) = agent {
|
|
agent_startup_grace_until =
|
|
Some(now + AGENT_STARTUP_GRACE_WINDOW);
|
|
state = AgentState::Unknown;
|
|
last_visible_idle = false;
|
|
last_visible_blocker = false;
|
|
last_visible_working = false;
|
|
last_visible_signal_refresh = None;
|
|
publish_agent_process_detected_event(
|
|
state_events.clone(),
|
|
pane_id,
|
|
agent,
|
|
now,
|
|
)
|
|
.await;
|
|
} else {
|
|
agent_startup_grace_until = None;
|
|
}
|
|
}
|
|
if let Some(process_name) = process_name {
|
|
info!(
|
|
pane = pane_id.raw(),
|
|
previous_agent = ?previous_agent,
|
|
?agent,
|
|
process = %process_name,
|
|
pgid = ?process_group_id,
|
|
"agent changed"
|
|
);
|
|
} else {
|
|
info!(
|
|
pane = pane_id.raw(),
|
|
previous_agent = ?previous_agent,
|
|
?agent,
|
|
pgid = ?process_group_id,
|
|
"agent changed"
|
|
);
|
|
}
|
|
agent_changed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
let pid = child_pid.load(Ordering::Acquire);
|
|
// Keep the terminal restore side effect separate from render notification state.
|
|
#[allow(clippy::collapsible_if)]
|
|
if pid > 0 && terminal.maybe_restore_host_terminal_theme(pane_id, pid) {
|
|
if render_dirty.request_pty(pane_id) {
|
|
render_notify.notify_one();
|
|
}
|
|
}
|
|
|
|
let process_exited = pending_foreground_shell_clear
|
|
&& agent.is_some()
|
|
&& !foreground_shell_exit_reported;
|
|
|
|
if lifecycle_authority_active && !process_exited {
|
|
pending_idle.clear();
|
|
continue;
|
|
}
|
|
|
|
if let Some(until) = agent_startup_grace_until {
|
|
if process_exited {
|
|
agent_startup_grace_until = None;
|
|
last_screen_scan_detection_content_seq = None;
|
|
pending_idle.clear();
|
|
} else {
|
|
if now < until {
|
|
pending_idle.clear();
|
|
continue;
|
|
}
|
|
agent_startup_grace_until = None;
|
|
pending_idle.clear();
|
|
continue;
|
|
}
|
|
}
|
|
|
|
let current_detection_content_seq = if agent.is_some() {
|
|
Some(detection_content_seq.load(Ordering::Relaxed))
|
|
} else {
|
|
None
|
|
};
|
|
match decide_detection_screen_read(DetectionScreenReadInput {
|
|
state,
|
|
agent,
|
|
pending_idle_active: pending_idle.active(),
|
|
agent_changed,
|
|
process_exited,
|
|
current_detection_content_seq,
|
|
last_screen_scan_detection_content_seq,
|
|
}) {
|
|
DetectionScreenReadDecision::Read => {}
|
|
DetectionScreenReadDecision::Skip => continue,
|
|
}
|
|
|
|
let content = terminal.detection_text();
|
|
last_screen_scan_detection_content_seq = current_detection_content_seq;
|
|
let content_changed = content != last_detection_text;
|
|
last_detection_text.clone_from(&content);
|
|
if detect::should_skip_state_update(agent, &content) {
|
|
pending_idle.clear();
|
|
continue;
|
|
}
|
|
sync_content_change_acquisition(
|
|
agent_presence.current_agent(),
|
|
suppressed_agent,
|
|
process_group_changed,
|
|
content_changed,
|
|
now,
|
|
&mut acquisition_started_at,
|
|
&mut last_content_change_at,
|
|
);
|
|
|
|
let osc_title = terminal.agent_osc_title();
|
|
let osc_progress = terminal.agent_osc_progress();
|
|
let Some(screen_detection) = detection_update_for_publish_with_osc(
|
|
agent,
|
|
&content,
|
|
&osc_title,
|
|
&osc_progress,
|
|
process_exited,
|
|
) else {
|
|
pending_idle.clear();
|
|
continue;
|
|
};
|
|
match decide_screen_detection_publish(
|
|
ScreenDetectionPublishInput {
|
|
screen_detection,
|
|
current_state: state,
|
|
last_visible_idle,
|
|
last_visible_blocker,
|
|
last_visible_working,
|
|
last_visible_signal_refresh,
|
|
process_exited,
|
|
agent_changed,
|
|
now,
|
|
},
|
|
&mut pending_idle,
|
|
) {
|
|
DetectionPublishDecision::NoPublish => {}
|
|
DetectionPublishDecision::Publish {
|
|
state: new_state,
|
|
visible_idle,
|
|
visible_blocker,
|
|
visible_working,
|
|
process_exited: publish_process_exited,
|
|
} => {
|
|
apply_agent_detection_publish_update(
|
|
state_events.clone(),
|
|
pane_id,
|
|
agent,
|
|
AgentDetectionPublishUpdate {
|
|
state: new_state,
|
|
visible_idle,
|
|
visible_blocker,
|
|
visible_working,
|
|
process_exited: publish_process_exited,
|
|
},
|
|
now,
|
|
&mut state,
|
|
&mut last_visible_idle,
|
|
&mut last_visible_blocker,
|
|
&mut last_visible_working,
|
|
&mut last_visible_signal_refresh,
|
|
&mut foreground_shell_exit_reported,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
(
|
|
Some(handle.abort_handle()),
|
|
detect_reset_notify,
|
|
pending_release,
|
|
)
|
|
} else {
|
|
(None, Arc::new(Notify::new()), Arc::new(Mutex::new(None)))
|
|
};
|
|
|
|
Ok(Self {
|
|
pane_id,
|
|
terminal,
|
|
io,
|
|
current_size: Cell::new((rows, cols, 0, 0)),
|
|
child_pid,
|
|
reported_cwd,
|
|
child_wait_completed: Some(child_wait_completed),
|
|
kitty_keyboard_flags,
|
|
content_seq,
|
|
content_write_lock,
|
|
detection_content_seq,
|
|
full_lifecycle_authority_active,
|
|
detect_reset_notify,
|
|
pending_release,
|
|
preserve_processes_on_drop: false,
|
|
compression,
|
|
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()
|
|
}
|
|
|
|
pub fn set_full_lifecycle_authority_active(&self, active: bool) {
|
|
let previous = self
|
|
.full_lifecycle_authority_active
|
|
.swap(active, Ordering::AcqRel);
|
|
if active && !previous {
|
|
self.detect_reset_notify.notify_one();
|
|
}
|
|
}
|
|
|
|
pub(crate) fn current_size(&self) -> (u16, u16) {
|
|
let (rows, cols, _, _) = self.current_size.get();
|
|
(rows, cols)
|
|
}
|
|
|
|
pub(crate) fn content_seq(&self) -> u64 {
|
|
self.content_seq.load(Ordering::Acquire)
|
|
}
|
|
|
|
/// Resize if the dimensions actually changed.
|
|
pub fn resize(&self, rows: u16, cols: u16, cell_width_px: u32, cell_height_px: u32) {
|
|
let rows = rows.max(2);
|
|
let cols = cols.max(4);
|
|
let size = (rows, cols, cell_width_px, cell_height_px);
|
|
if self.current_size.get() == size {
|
|
return;
|
|
}
|
|
self.current_size.set(size);
|
|
let _content_write_guard = match self.content_write_lock.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => poisoned.into_inner(),
|
|
};
|
|
self.content_seq.fetch_add(1, Ordering::AcqRel);
|
|
let terminal_responses = self
|
|
.terminal
|
|
.resize(rows, cols, cell_width_px, cell_height_px);
|
|
self.content_seq.fetch_add(1, Ordering::Release);
|
|
drop(_content_write_guard);
|
|
self.compression.wake();
|
|
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);
|
|
self.compression.wake();
|
|
}
|
|
|
|
/// Scroll down by N lines (toward live output).
|
|
pub fn scroll_down(&self, lines: usize) {
|
|
self.terminal.scroll_down(lines);
|
|
self.compression.wake();
|
|
}
|
|
|
|
pub fn clear_screen(&self) -> Result<(), String> {
|
|
let guard = match self.content_write_lock.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => poisoned.into_inner(),
|
|
};
|
|
self.content_seq.fetch_add(1, Ordering::AcqRel);
|
|
let result = self.terminal.clear_screen();
|
|
self.content_seq.fetch_add(1, Ordering::Release);
|
|
drop(guard);
|
|
self.compression.wake();
|
|
mark_detection_content_changed(&self.detection_content_seq);
|
|
result
|
|
}
|
|
|
|
/// Reset scroll to live view (offset = 0).
|
|
pub fn scroll_reset(&self) {
|
|
self.terminal.scroll_reset();
|
|
self.compression.wake();
|
|
}
|
|
|
|
/// 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);
|
|
self.compression.wake();
|
|
}
|
|
|
|
pub fn scroll_metrics(&self) -> Option<ScrollMetrics> {
|
|
self.terminal.scroll_metrics()
|
|
}
|
|
|
|
pub(crate) fn search_text_window(
|
|
&self,
|
|
query: &str,
|
|
case_sensitive: bool,
|
|
direction: crate::pane::TerminalSearchDirection,
|
|
cursor: crate::pane::TerminalTextPoint,
|
|
previous: Option<(
|
|
crate::pane::TerminalTextPoint,
|
|
crate::pane::TerminalTextPoint,
|
|
)>,
|
|
limit: usize,
|
|
) -> crate::pane::TerminalSearchWindow {
|
|
let result = self.terminal.search_text_window(
|
|
query,
|
|
case_sensitive,
|
|
direction,
|
|
cursor,
|
|
previous,
|
|
limit,
|
|
);
|
|
self.compression.wake();
|
|
result
|
|
}
|
|
|
|
pub(crate) fn word_motion_target(
|
|
&self,
|
|
row: u32,
|
|
col: u16,
|
|
motion: crate::pane::TerminalWordMotion,
|
|
) -> Option<crate::pane::TerminalTextPoint> {
|
|
let result = self.terminal.word_motion_target(row, col, motion);
|
|
self.compression.wake();
|
|
result
|
|
}
|
|
|
|
pub(crate) fn terminal_dimensions(&self) -> Option<(u16, u16)> {
|
|
self.terminal.dimensions()
|
|
}
|
|
|
|
pub(crate) fn paragraph_motion_target(
|
|
&self,
|
|
row: u32,
|
|
direction: i8,
|
|
) -> Option<crate::pane::TerminalTextPoint> {
|
|
let result = self.terminal.paragraph_motion_target(row, direction);
|
|
self.compression.wake();
|
|
result
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
pub fn input_state(&self) -> Option<InputState> {
|
|
self.terminal.input_state()
|
|
}
|
|
|
|
pub fn bracketed_paste_enabled(&self) -> bool {
|
|
self.terminal.bracketed_paste_enabled()
|
|
}
|
|
|
|
pub fn focus_reporting_enabled(&self) -> bool {
|
|
self.terminal.focus_reporting_enabled()
|
|
}
|
|
|
|
pub fn mouse_reporting_enabled(&self) -> bool {
|
|
self.terminal.mouse_reporting_enabled()
|
|
}
|
|
|
|
pub fn sgr_pixel_mouse_enabled(&self) -> bool {
|
|
self.terminal.sgr_pixel_mouse_enabled()
|
|
}
|
|
|
|
pub fn plain_page_keys_use_host_scrollback(&self) -> Option<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 {
|
|
let result = self.terminal.recent_text_snapshot(lines);
|
|
self.compression.wake();
|
|
result
|
|
}
|
|
|
|
pub(crate) fn recent_ansi_snapshot(&self, lines: usize) -> TerminalReadSnapshot {
|
|
let result = self.terminal.recent_ansi_snapshot(lines);
|
|
self.compression.wake();
|
|
result
|
|
}
|
|
|
|
pub(crate) fn recent_unwrapped_text_snapshot(&self, lines: usize) -> TerminalReadSnapshot {
|
|
let result = self.terminal.recent_unwrapped_text_snapshot(lines);
|
|
self.compression.wake();
|
|
result
|
|
}
|
|
|
|
pub fn recent_unwrapped_ansi(&self, lines: usize) -> String {
|
|
let result = self.terminal.recent_unwrapped_ansi(lines);
|
|
self.compression.wake();
|
|
result
|
|
}
|
|
|
|
pub(crate) fn recent_unwrapped_ansi_snapshot(&self, lines: usize) -> TerminalReadSnapshot {
|
|
let result = self.terminal.recent_unwrapped_ansi_snapshot(lines);
|
|
self.compression.wake();
|
|
result
|
|
}
|
|
|
|
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> {
|
|
let result = self.terminal.extract_selection(selection);
|
|
self.compression.wake();
|
|
result
|
|
}
|
|
|
|
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_snapshot(
|
|
&self,
|
|
area_width: u16,
|
|
area_height: u16,
|
|
) -> Option<TerminalDirtyPatchSnapshot> {
|
|
// PTY/resize writers announce changes before locking the terminal core.
|
|
// Exclude them until rows and metadata have been paired with their revision.
|
|
let _content_guard = self
|
|
.content_write_lock
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let revision = self.content_seq();
|
|
if !revision.is_multiple_of(2) {
|
|
return None;
|
|
}
|
|
let patch = self.terminal.collect_dirty_patch(area_width, area_height);
|
|
if matches!(patch, TerminalDirtyPatchOutcome::Fallback) {
|
|
return None;
|
|
}
|
|
let snapshot = TerminalDirtyPatchSnapshot {
|
|
patch,
|
|
content_revision: revision,
|
|
scroll_metrics: self.scroll_metrics(),
|
|
mouse_reporting: self.mouse_reporting_enabled(),
|
|
sgr_pixel_mouse: self.sgr_pixel_mouse_enabled(),
|
|
alternate_screen_active: self.alternate_screen_active(),
|
|
graphics_may_have_placements: crate::kitty_graphics::is_enabled()
|
|
&& self.kitty_graphics_may_have_placements(),
|
|
};
|
|
(self.content_seq() == revision).then_some(snapshot)
|
|
}
|
|
|
|
pub fn visible_hyperlinks(&self, area: Rect) -> Vec<((u16, u16), String, String)> {
|
|
self.terminal.visible_hyperlinks(area)
|
|
}
|
|
|
|
pub(crate) fn link_regions_at(
|
|
&self,
|
|
col: u16,
|
|
row: u16,
|
|
resolve: fn(&str, usize) -> Option<std::ops::Range<usize>>,
|
|
) -> Vec<crate::api::schema::PaneLinkRegion> {
|
|
self.terminal.link_regions_at(col, row, resolve)
|
|
}
|
|
|
|
pub(crate) fn link_target_at(&self, col: u16, row: u16) -> Option<crate::ghostty::LinkTarget> {
|
|
self.terminal.link_target_at(col, row)
|
|
}
|
|
|
|
pub(crate) fn kitty_graphics_may_have_placements(&self) -> bool {
|
|
self.terminal.kitty_graphics_may_have_placements()
|
|
}
|
|
|
|
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 modify_other_keys_level(&self) -> u8 {
|
|
self.terminal.modify_other_keys_level()
|
|
}
|
|
|
|
pub fn encode_terminal_key(&self, key: crate::input::TerminalKey) -> Vec<u8> {
|
|
self.terminal
|
|
.encode_terminal_key(key, self.keyboard_protocol())
|
|
}
|
|
|
|
pub fn try_send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::TrySendError<Bytes>> {
|
|
self.io.try_send_bytes(bytes)
|
|
}
|
|
|
|
pub fn queue_user_input_submission(
|
|
&self,
|
|
text: Bytes,
|
|
enter: Bytes,
|
|
delay: std::time::Duration,
|
|
deadline: Option<std::time::Instant>,
|
|
) -> std::io::Result<std::sync::mpsc::Receiver<std::io::Result<()>>> {
|
|
self.io
|
|
.queue_user_input_submission(text, enter, delay, deadline)
|
|
}
|
|
|
|
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 text = crate::platform::prepare_paste_text_for_pty(text);
|
|
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>,
|
|
)> {
|
|
let result = self.terminal.screen_text_snapshot();
|
|
self.compression.wake();
|
|
result
|
|
}
|
|
|
|
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);
|
|
|
|
// The group leader's cwd is authoritative (issue #3270): a helper
|
|
// process that chdirs elsewhere inside the same foreground group
|
|
// must not override it. Scan other members only when the leader's
|
|
// cwd cannot be read at all.
|
|
leader_cwd
|
|
.or_else(|| foreground_member_cwd_different_from_shell(pid, shell_cwd.as_ref()))
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
{
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl PaneRuntime {
|
|
pub(crate) fn test_with_channel(cols: u16, rows: u16) -> (Self, mpsc::Receiver<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_contend_during_dirty_collection(
|
|
&self,
|
|
bytes: Vec<u8>,
|
|
) -> (std::sync::mpsc::Sender<()>, std::thread::JoinHandle<bool>) {
|
|
let terminal = self.terminal.clone();
|
|
let sequence = self.content_seq.clone();
|
|
let write_lock = self.content_write_lock.clone();
|
|
let pane_id = self.pane_id;
|
|
let (start_tx, start_rx) = std::sync::mpsc::channel();
|
|
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
|
|
let (release_tx, release_rx) = std::sync::mpsc::channel();
|
|
self.terminal
|
|
.ghostty
|
|
.core
|
|
.lock()
|
|
.unwrap()
|
|
.dirty_collection_hook = Some(Box::new(move || {
|
|
start_tx.send(()).unwrap();
|
|
ready_rx
|
|
.recv_timeout(std::time::Duration::from_secs(5))
|
|
.unwrap();
|
|
}));
|
|
let writer = std::thread::spawn(move || {
|
|
start_rx
|
|
.recv_timeout(std::time::Duration::from_secs(5))
|
|
.unwrap();
|
|
let guard = match write_lock.try_lock() {
|
|
Ok(guard) => Some(guard),
|
|
Err(std::sync::TryLockError::WouldBlock) => None,
|
|
Err(error) => panic!("poisoned content lock: {error}"),
|
|
};
|
|
let announced = guard.is_some();
|
|
if announced {
|
|
sequence.fetch_add(1, Ordering::AcqRel);
|
|
assert!(matches!(
|
|
terminal.ghostty.core.try_lock(),
|
|
Err(std::sync::TryLockError::WouldBlock)
|
|
));
|
|
}
|
|
ready_tx.send(()).unwrap();
|
|
let _ = release_rx.recv();
|
|
let _guard = guard.unwrap_or_else(|| {
|
|
let guard = write_lock.lock().unwrap();
|
|
sequence.fetch_add(1, Ordering::AcqRel);
|
|
guard
|
|
});
|
|
let (tx, _rx) = mpsc::channel(1);
|
|
let _ = terminal.process_pty_bytes(pane_id, 0, &bytes, &tx);
|
|
sequence.fetch_add(1, Ordering::Release);
|
|
announced
|
|
});
|
|
(release_tx, writer)
|
|
}
|
|
|
|
pub(crate) fn test_process_pty_bytes(&self, bytes: &[u8]) {
|
|
let _content_write_guard = match self.content_write_lock.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => poisoned.into_inner(),
|
|
};
|
|
self.content_seq.fetch_add(1, Ordering::AcqRel);
|
|
let (tx, _rx) = mpsc::channel(1);
|
|
let _ = self.terminal.process_pty_bytes(self.pane_id, 0, bytes, &tx);
|
|
self.content_seq.fetch_add(1, Ordering::Release);
|
|
self.compression.wake();
|
|
}
|
|
|
|
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);
|
|
let pane_id = PaneId::from_raw(0);
|
|
let terminal = Arc::new(PaneTerminal::new(
|
|
GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(),
|
|
));
|
|
let compression = TerminalCompressionTask::spawn(pane_id, terminal.clone());
|
|
|
|
(
|
|
Self {
|
|
pane_id,
|
|
terminal,
|
|
io: PaneRuntimeIo::TestChannel {
|
|
sender: tx,
|
|
resize_tx,
|
|
},
|
|
current_size: Cell::new((rows, cols, 0, 0)),
|
|
child_pid: Arc::new(AtomicU32::new(0)),
|
|
reported_cwd: Arc::new(Mutex::new(None)),
|
|
child_wait_completed: None,
|
|
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
|
|
content_seq: Arc::new(AtomicU64::new(0)),
|
|
content_write_lock: Arc::new(Mutex::new(())),
|
|
detection_content_seq: Arc::new(AtomicU64::new(0)),
|
|
full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)),
|
|
detect_reset_notify: Arc::new(Notify::new()),
|
|
pending_release: Arc::new(Mutex::new(None)),
|
|
preserve_processes_on_drop: true,
|
|
compression,
|
|
detect_handle: Some(tokio::spawn(async {}).abort_handle()),
|
|
},
|
|
rx,
|
|
)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn clear_pane_preserves_wrapped_input_and_unfinished_vt_sequence() {
|
|
let (runtime, mut rx) = PaneRuntime::test_with_channel_and_scrollback_bytes(
|
|
10,
|
|
5,
|
|
100_000,
|
|
b"old\r\nold\r\nold\r\nold\r\nold\r\n\x1b[32m$ abcdefghijklmnop\x1b[1A\x1b[4G\x1b[",
|
|
4,
|
|
);
|
|
let before = runtime.content_seq();
|
|
runtime.scroll_up(1);
|
|
runtime.clear_screen().unwrap();
|
|
let snapshot = runtime.collect_dirty_patch_snapshot(10, 5).unwrap();
|
|
assert!(snapshot.content_revision > before);
|
|
assert!(!matches!(snapshot.patch, TerminalDirtyPatchOutcome::Clean));
|
|
let metrics = runtime.scroll_metrics().unwrap();
|
|
assert_eq!(metrics.max_offset_from_bottom, 0);
|
|
assert_eq!(metrics.offset_from_bottom, 0);
|
|
let text = runtime.recent_unwrapped_text_snapshot(100).text;
|
|
assert!(text.contains("$ abcdefghijklmnop"), "{text:?}");
|
|
assert!(!text.contains("old"), "{text:?}");
|
|
runtime.test_process_pty_bytes(b"5 q");
|
|
assert!(!runtime.visible_text().contains("5 q"));
|
|
assert!(rx.try_recv().is_err(), "clear must not send child input");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn clear_pane_preserves_alternate_screen_and_primary_history() {
|
|
let runtime = PaneRuntime::test_with_scrollback_bytes(
|
|
20,
|
|
4,
|
|
100_000,
|
|
b"one\r\ntwo\r\nthree\r\nfour\r\nfive\x1b[?1049halt app",
|
|
);
|
|
let before = runtime.visible_text();
|
|
runtime.clear_screen().unwrap();
|
|
assert_eq!(runtime.visible_text(), before);
|
|
runtime.test_process_pty_bytes(b"\x1b[?1049l");
|
|
assert!(runtime
|
|
.recent_unwrapped_text_snapshot(100)
|
|
.text
|
|
.contains("one"));
|
|
runtime.clear_screen().unwrap();
|
|
assert!(!runtime
|
|
.recent_unwrapped_text_snapshot(100)
|
|
.text
|
|
.contains("one"));
|
|
assert!(runtime.visible_text().contains("five"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dirty_patch_snapshot_keeps_clean_metadata_and_terminal_fallback() {
|
|
let (runtime, _rx) = PaneRuntime::test_with_channel(20, 4);
|
|
runtime
|
|
.collect_dirty_patch_snapshot(20, 4)
|
|
.expect("initial snapshot");
|
|
runtime.test_process_pty_bytes(b"\x1b[?1003h\x1b[?1016h");
|
|
let snapshot = runtime
|
|
.collect_dirty_patch_snapshot(20, 4)
|
|
.expect("mode snapshot");
|
|
assert!(matches!(snapshot.patch, TerminalDirtyPatchOutcome::Clean));
|
|
assert_eq!(snapshot.content_revision, runtime.content_seq());
|
|
assert!(snapshot.content_revision.is_multiple_of(2));
|
|
assert!(snapshot.mouse_reporting);
|
|
assert!(snapshot.sgr_pixel_mouse);
|
|
assert!(!snapshot.alternate_screen_active);
|
|
|
|
runtime.test_process_pty_bytes(b"\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\");
|
|
assert!(runtime.collect_dirty_patch_snapshot(20, 4).is_none());
|
|
assert!(runtime.content_write_lock.try_lock().is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dirty_patch_snapshot_tracks_serialized_scroll_and_resize() {
|
|
let runtime = PaneRuntime::test_with_scrollback_bytes(
|
|
20,
|
|
4,
|
|
100_000,
|
|
b"one\r\ntwo\r\nthree\r\nfour\r\nfive\r\nsix",
|
|
);
|
|
runtime
|
|
.collect_dirty_patch_snapshot(20, 4)
|
|
.expect("live snapshot");
|
|
runtime.scroll_up(1);
|
|
let scrolled = runtime
|
|
.collect_dirty_patch_snapshot(20, 4)
|
|
.expect("scrolled snapshot");
|
|
assert_eq!(
|
|
scrolled.scroll_metrics.expect("metrics").offset_from_bottom,
|
|
1
|
|
);
|
|
runtime.scroll_reset();
|
|
runtime.resize(5, 24, 0, 0);
|
|
let resized = runtime
|
|
.collect_dirty_patch_snapshot(24, 5)
|
|
.expect("resized snapshot");
|
|
let metrics = resized.scroll_metrics.expect("resized metrics");
|
|
assert_eq!(metrics.offset_from_bottom, 0);
|
|
assert_eq!(metrics.viewport_rows, 5);
|
|
assert!(resized.content_revision.is_multiple_of(2));
|
|
let TerminalDirtyPatchOutcome::Patch(patch) = resized.patch else {
|
|
panic!("resize must dirty the viewport");
|
|
};
|
|
assert_eq!(patch.rows.len(), 5);
|
|
assert!(patch.rows.iter().all(|(_, cells)| cells.len() == 24));
|
|
}
|
|
|
|
#[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_launch_env_removes_outer_ompcode_marker() {
|
|
let mut cmd = CommandBuilder::new("shell");
|
|
cmd.env("OMPCODE", "1");
|
|
|
|
apply_pane_launch_env(&mut cmd, &PaneLaunchEnv::default());
|
|
|
|
assert!(cmd.get_env("OMPCODE").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());
|
|
}
|
|
|
|
const TEST_EXECUTABLE_IMAGE: u16 = 0x0002;
|
|
const TEST_DLL: u16 = 0x2000;
|
|
const TEST_MACHINE_AMD64: u16 = 0x8664;
|
|
|
|
/// Structurally valid PE32+ image, with fields chosen so tests can make it
|
|
/// malformed one way at a time.
|
|
fn windows_test_pe(machine: u16, characteristics: u16, number_of_sections: u16) -> Vec<u8> {
|
|
const PE_OFFSET: u32 = 0x80;
|
|
const COFF_HEADER_LEN: usize = 20;
|
|
const OPTIONAL_HEADER_LEN: u16 = 0x70;
|
|
const SECTION_HEADER_LEN: usize = 40;
|
|
|
|
let pe = PE_OFFSET as usize;
|
|
let mut bytes = vec![
|
|
0u8;
|
|
pe + 4
|
|
+ COFF_HEADER_LEN
|
|
+ OPTIONAL_HEADER_LEN as usize
|
|
+ number_of_sections as usize * SECTION_HEADER_LEN
|
|
];
|
|
bytes[..2].copy_from_slice(b"MZ");
|
|
bytes[0x3c..0x40].copy_from_slice(&PE_OFFSET.to_le_bytes());
|
|
bytes[pe..pe + 4].copy_from_slice(b"PE\0\0");
|
|
bytes[pe + 4..pe + 6].copy_from_slice(&machine.to_le_bytes());
|
|
bytes[pe + 4 + 2..pe + 4 + 4].copy_from_slice(&number_of_sections.to_le_bytes());
|
|
bytes[pe + 4 + 16..pe + 4 + 18].copy_from_slice(&OPTIONAL_HEADER_LEN.to_le_bytes());
|
|
bytes[pe + 4 + 18..pe + 4 + 20].copy_from_slice(&characteristics.to_le_bytes());
|
|
bytes[pe + 24..pe + 26].copy_from_slice(&0x020bu16.to_le_bytes());
|
|
bytes
|
|
}
|
|
|
|
#[test]
|
|
fn windows_default_pane_shell_prefers_pwsh_on_path() {
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"herdr-windows-default-shell-pwsh-{}",
|
|
std::process::id()
|
|
));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let pwsh = dir.join("pwsh.exe");
|
|
let image = windows_test_pe(TEST_MACHINE_AMD64, TEST_EXECUTABLE_IMAGE, 1);
|
|
std::fs::write(&pwsh, image).unwrap();
|
|
let path = std::env::join_paths([&dir]).unwrap();
|
|
|
|
let resolved = default_windows_pane_shell(Some(path));
|
|
let expected = pwsh.into_os_string().into_string().unwrap();
|
|
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
assert_eq!(resolved, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_default_pane_shell_falls_back_to_inbox_powershell() {
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"herdr-windows-default-shell-fallback-{}",
|
|
std::process::id()
|
|
));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
// A file that is not a launchable executable must not be selected.
|
|
std::fs::write(dir.join("pwsh.exe"), b"not a PE image").unwrap();
|
|
let path = std::env::join_paths([&dir]).unwrap();
|
|
|
|
let invalid = default_windows_pane_shell(Some(path));
|
|
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
assert_eq!(invalid, "powershell.exe");
|
|
assert_eq!(default_windows_pane_shell(None), "powershell.exe");
|
|
}
|
|
|
|
#[test]
|
|
fn windows_default_pane_shell_rejects_non_executable_pe_images() {
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"herdr-windows-default-shell-invalid-{}",
|
|
std::process::id()
|
|
));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let pwsh = dir.join("pwsh.exe");
|
|
let path = || std::env::join_paths([&dir]).unwrap();
|
|
|
|
let truncated_header = {
|
|
let mut bytes = b"MZ".to_vec();
|
|
bytes.resize(0x40, 0);
|
|
bytes[0x3c..0x40].copy_from_slice(&0x50u32.to_le_bytes());
|
|
bytes
|
|
};
|
|
// One declared section, but the file ends before its 40-byte header.
|
|
let truncated_section_table = {
|
|
let mut bytes = windows_test_pe(TEST_MACHINE_AMD64, TEST_EXECUTABLE_IMAGE, 1);
|
|
bytes.truncate(bytes.len() - 1);
|
|
bytes
|
|
};
|
|
let cases: [(&str, Vec<u8>); 7] = [
|
|
("empty", Vec::new()),
|
|
("dos signature only", b"MZ".to_vec()),
|
|
("truncated pe header", truncated_header),
|
|
("truncated section table", truncated_section_table),
|
|
(
|
|
"dll",
|
|
windows_test_pe(TEST_MACHINE_AMD64, TEST_EXECUTABLE_IMAGE | TEST_DLL, 1),
|
|
),
|
|
(
|
|
"no executable bit",
|
|
windows_test_pe(TEST_MACHINE_AMD64, 0, 1),
|
|
),
|
|
(
|
|
"foreign machine",
|
|
windows_test_pe(0x0200, TEST_EXECUTABLE_IMAGE, 1),
|
|
),
|
|
];
|
|
|
|
for (label, bytes) in cases {
|
|
std::fs::write(&pwsh, &bytes).unwrap();
|
|
assert_eq!(
|
|
default_windows_pane_shell(Some(path())),
|
|
"powershell.exe",
|
|
"case {label:?}"
|
|
);
|
|
}
|
|
|
|
std::fs::write(
|
|
&pwsh,
|
|
windows_test_pe(TEST_MACHINE_AMD64, TEST_EXECUTABLE_IMAGE, 0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
default_windows_pane_shell(Some(path())),
|
|
"powershell.exe",
|
|
"case zero sections"
|
|
);
|
|
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_executable_machine_compatibility_tracks_native_host() {
|
|
const I386: u16 = 0x014c;
|
|
const AMD64: u16 = 0x8664;
|
|
const ARM64: u16 = 0xaa64;
|
|
|
|
assert!(windows_executable_machine_is_compatible(I386, AMD64));
|
|
assert!(windows_executable_machine_is_compatible(AMD64, AMD64));
|
|
assert!(!windows_executable_machine_is_compatible(ARM64, AMD64));
|
|
assert!(windows_executable_machine_is_compatible(I386, ARM64));
|
|
assert!(windows_executable_machine_is_compatible(AMD64, ARM64));
|
|
assert!(windows_executable_machine_is_compatible(ARM64, ARM64));
|
|
assert!(windows_executable_machine_is_compatible(I386, I386));
|
|
assert!(!windows_executable_machine_is_compatible(AMD64, I386));
|
|
assert!(!windows_executable_machine_is_compatible(0x0200, AMD64));
|
|
}
|
|
|
|
#[test]
|
|
fn windows_executable_file_rejects_arm64_image_on_amd64_host() {
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"herdr-windows-default-shell-arm64-{}",
|
|
std::process::id()
|
|
));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let pwsh = dir.join("pwsh.exe");
|
|
std::fs::write(&pwsh, windows_test_pe(0xaa64, TEST_EXECUTABLE_IMAGE, 1)).unwrap();
|
|
|
|
let on_amd64 = is_windows_executable_file_for_host(&pwsh, 0x8664);
|
|
let on_arm64 = is_windows_executable_file_for_host(&pwsh, 0xaa64);
|
|
let on_x86 = is_windows_executable_file_for_host(&pwsh, 0x014c);
|
|
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
assert!(!on_amd64, "x64 Windows cannot launch a pure ARM64 image");
|
|
assert!(on_arm64);
|
|
assert!(!on_x86);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_default_pane_shell_skips_invalid_pwsh_candidates() {
|
|
let base = std::env::temp_dir().join(format!(
|
|
"herdr-windows-default-shell-skip-{}",
|
|
std::process::id()
|
|
));
|
|
let invalid_dir = base.join("invalid");
|
|
let valid_dir = base.join("valid");
|
|
std::fs::create_dir_all(&invalid_dir).unwrap();
|
|
std::fs::create_dir_all(&valid_dir).unwrap();
|
|
std::fs::write(invalid_dir.join("pwsh.exe"), b"MZ").unwrap();
|
|
let pwsh = valid_dir.join("pwsh.exe");
|
|
std::fs::write(
|
|
&pwsh,
|
|
windows_test_pe(TEST_MACHINE_AMD64, TEST_EXECUTABLE_IMAGE, 1),
|
|
)
|
|
.unwrap();
|
|
let path = std::env::join_paths([&invalid_dir, &valid_dir]).unwrap();
|
|
|
|
let resolved = default_windows_pane_shell(Some(path));
|
|
let expected = pwsh.into_os_string().into_string().unwrap();
|
|
|
|
let _ = std::fs::remove_dir_all(base);
|
|
assert_eq!(resolved, expected);
|
|
}
|
|
|
|
#[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 windows_login_shell_builder_launches_configured_shell_with_login_flag() {
|
|
let cmd = pane_shell_command_builder_for_target(
|
|
PaneShellConfig::new("bash.exe", crate::config::ShellModeConfig::Login),
|
|
ShellLaunchTarget::Windows,
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(
|
|
!cmd.is_default_prog(),
|
|
"Windows login mode must not fall back to the default program (cmd.exe)"
|
|
);
|
|
assert_eq!(
|
|
cmd.get_argv(),
|
|
&[
|
|
std::ffi::OsString::from("bash.exe"),
|
|
std::ffi::OsString::from("-l"),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_login_shell_builder_launches_native_shells_without_flag() {
|
|
for shell in ["cmd.exe", "C:\\Windows\\System32\\cmd.exe"] {
|
|
let cmd = pane_shell_command_builder_for_target(
|
|
PaneShellConfig::new(shell, crate::config::ShellModeConfig::Login),
|
|
ShellLaunchTarget::Windows,
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(!cmd.is_default_prog(), "shell {shell:?}");
|
|
assert_eq!(
|
|
cmd.get_argv(),
|
|
&[std::ffi::OsString::from(shell)],
|
|
"shell {shell:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn windows_login_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::Login),
|
|
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),
|
|
],
|
|
"shell {shell:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn windows_login_shell_args_maps_posix_shells_to_login_flag() {
|
|
for shell in [
|
|
"sh",
|
|
"sh.exe",
|
|
"bash",
|
|
"bash.exe",
|
|
"BASH.EXE",
|
|
"zsh",
|
|
"zsh.exe",
|
|
"ksh",
|
|
"dash",
|
|
"ash",
|
|
"mksh",
|
|
"fish",
|
|
"fish.exe",
|
|
"csh",
|
|
"csh.exe",
|
|
"tcsh",
|
|
"tcsh.exe",
|
|
"C:\\Program Files\\Git\\bin\\bash.exe",
|
|
] {
|
|
assert_eq!(
|
|
windows_login_shell_args(shell),
|
|
&["-l"],
|
|
"shell {shell:?} should get the login flag"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn windows_login_shell_args_omits_flag_for_shells_without_login_concept() {
|
|
for shell in [
|
|
"cmd",
|
|
"cmd.exe",
|
|
"powershell",
|
|
"powershell.exe",
|
|
"pwsh",
|
|
"pwsh.exe",
|
|
"nu",
|
|
"nu.exe",
|
|
"C:\\Windows\\System32\\cmd.exe",
|
|
] {
|
|
assert_eq!(
|
|
windows_login_shell_args(shell),
|
|
&[] as &[&str],
|
|
"shell {shell:?} must not get a login flag"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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_powershell() {
|
|
for mode in [
|
|
crate::config::ShellModeConfig::NonLogin,
|
|
crate::config::ShellModeConfig::Login,
|
|
] {
|
|
let pwsh = PaneShellConfig::new("pwsh.exe", mode);
|
|
assert!(
|
|
uses_windows_powershell_pane_shell_for_target(pwsh, ShellLaunchTarget::Windows),
|
|
"mode {mode:?}"
|
|
);
|
|
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("cmd.exe", crate::config::ShellModeConfig::NonLogin),
|
|
ShellLaunchTarget::Windows
|
|
));
|
|
assert!(!uses_windows_powershell_pane_shell_for_target(
|
|
PaneShellConfig::new("cmd.exe", crate::config::ShellModeConfig::Login),
|
|
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 compression_permit_survives_an_aborted_async_waiter() {
|
|
let semaphore = Arc::new(tokio::sync::Semaphore::new(1));
|
|
let permit = semaphore.clone().acquire_owned().await.unwrap();
|
|
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
|
let (release_tx, release_rx) = std::sync::mpsc::channel();
|
|
let handle = spawn_blocking_with_compression_permit(permit, move || {
|
|
let _ = started_tx.send(());
|
|
let _ = release_rx.recv();
|
|
});
|
|
started_rx.await.unwrap();
|
|
|
|
handle.abort();
|
|
assert!(semaphore.clone().try_acquire_owned().is_err());
|
|
|
|
release_tx.send(()).unwrap();
|
|
handle.await.unwrap();
|
|
assert!(semaphore.try_acquire_owned().is_ok());
|
|
}
|
|
|
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
|
#[tokio::test]
|
|
async fn compression_task_rechecks_history_after_a_read() {
|
|
let suffix = "x".repeat(66);
|
|
let history = (1..=2_000)
|
|
.map(|line| format!("{line:05} {suffix}\r\n"))
|
|
.collect::<String>();
|
|
let runtime =
|
|
PaneRuntime::test_with_scrollback_bytes(80, 24, 20_000_000, history.as_bytes());
|
|
|
|
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
|
while runtime.compression.completed_passes() == 0 {
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let completed_before_read = runtime.compression.completed_passes();
|
|
|
|
let snapshot = runtime.recent_unwrapped_text_snapshot(usize::MAX);
|
|
assert!(snapshot.text.contains("00001 "));
|
|
assert!(snapshot.text.contains("02000 "));
|
|
|
|
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
|
while runtime.compression.completed_passes() == completed_before_read {
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
|
#[tokio::test]
|
|
async fn compressed_scrollback_survives_shrink_and_grow_resize() {
|
|
let suffix = "x".repeat(66);
|
|
let history = (1..=2_000)
|
|
.map(|line| format!("{line:05} {suffix}\r\n"))
|
|
.collect::<String>();
|
|
let runtime =
|
|
PaneRuntime::test_with_scrollback_bytes(80, 45, 20_000_000, history.as_bytes());
|
|
|
|
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
|
while runtime.compression.completed_passes() == 0 {
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
runtime.resize(21, 80, 0, 0);
|
|
let completed_after_shrink = runtime.compression.completed_passes();
|
|
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
|
while runtime.compression.completed_passes() == completed_after_shrink {
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
runtime.resize(45, 80, 0, 0);
|
|
|
|
assert_eq!(runtime.current_size(), (45, 80));
|
|
assert_eq!(runtime.terminal_dimensions(), Some((80, 45)));
|
|
assert_eq!(runtime.scroll_metrics().unwrap().viewport_rows, 45);
|
|
let snapshot = runtime.recent_unwrapped_text_snapshot(usize::MAX);
|
|
assert!(snapshot.text.contains("00001 "));
|
|
assert!(snapshot.text.contains("02000 "));
|
|
}
|
|
|
|
#[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 pane_id = PaneId::from_raw(0);
|
|
let terminal = Arc::new(PaneTerminal::new(
|
|
GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(),
|
|
));
|
|
let compression = TerminalCompressionTask::spawn(pane_id, terminal.clone());
|
|
let runtime = PaneRuntime {
|
|
pane_id,
|
|
terminal,
|
|
io: PaneRuntimeIo::TestChannel {
|
|
sender: tx,
|
|
resize_tx,
|
|
},
|
|
current_size: Cell::new((80, 24, 0, 0)),
|
|
child_pid: Arc::new(AtomicU32::new(0)),
|
|
reported_cwd: Arc::new(Mutex::new(None)),
|
|
child_wait_completed: None,
|
|
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
|
|
content_seq: Arc::new(AtomicU64::new(0)),
|
|
content_write_lock: Arc::new(Mutex::new(())),
|
|
detection_content_seq: Arc::new(AtomicU64::new(0)),
|
|
full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)),
|
|
detect_reset_notify: Arc::new(Notify::new()),
|
|
pending_release: Arc::new(Mutex::new(None)),
|
|
preserve_processes_on_drop: true,
|
|
compression,
|
|
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 pane_id = PaneId::from_raw(0);
|
|
let terminal = Arc::new(PaneTerminal::new(
|
|
GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(),
|
|
));
|
|
let compression = TerminalCompressionTask::spawn(pane_id, terminal.clone());
|
|
let runtime = PaneRuntime {
|
|
pane_id,
|
|
terminal,
|
|
io: PaneRuntimeIo::TestChannel {
|
|
sender: tx,
|
|
resize_tx,
|
|
},
|
|
current_size: Cell::new((80, 24, 0, 0)),
|
|
child_pid: Arc::new(AtomicU32::new(0)),
|
|
reported_cwd: Arc::new(Mutex::new(None)),
|
|
child_wait_completed: None,
|
|
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
|
|
content_seq: Arc::new(AtomicU64::new(0)),
|
|
content_write_lock: Arc::new(Mutex::new(())),
|
|
detection_content_seq: Arc::new(AtomicU64::new(0)),
|
|
full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)),
|
|
detect_reset_notify: Arc::new(Notify::new()),
|
|
pending_release: Arc::new(Mutex::new(None)),
|
|
preserve_processes_on_drop: true,
|
|
compression,
|
|
detect_handle: Some(tokio::spawn(async {}).abort_handle()),
|
|
};
|
|
|
|
assert!(!runtime.try_send_focus_event(crate::ghostty::FocusEvent::Gained));
|
|
assert!(
|
|
tokio::time::timeout(std::time::Duration::from_millis(10), rx.recv())
|
|
.await
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn subscribed_idle_child_receives_color_scheme_transition() {
|
|
let (runtime, mut rx) = PaneRuntime::test_with_channel(80, 24);
|
|
runtime.apply_host_terminal_appearance(Some(crate::terminal_theme::HostAppearance::Dark));
|
|
runtime.test_process_pty_bytes(b"\x1b[?2031h");
|
|
|
|
runtime.apply_host_terminal_appearance(Some(crate::terminal_theme::HostAppearance::Light));
|
|
|
|
assert_eq!(rx.recv().await, Some(Bytes::from_static(b"\x1b[?997;2n")));
|
|
}
|
|
|
|
#[test]
|
|
fn foreground_shell_reports_process_exit_before_clearing_agent() {
|
|
assert_eq!(
|
|
foreground_shell_agent_action(Some(Agent::Codex), None, true, false),
|
|
ForegroundShellAgentAction::ReportProcessExit
|
|
);
|
|
assert_eq!(
|
|
foreground_shell_agent_action(Some(Agent::Codex), None, true, true),
|
|
ForegroundShellAgentAction::ClearAgent
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn same_agent_after_reported_exit_is_a_replacement_process() {
|
|
assert_eq!(
|
|
foreground_shell_agent_action(Some(Agent::Pi), Some(Agent::Pi), false, true),
|
|
ForegroundShellAgentAction::ReportReplacementProcess
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_non_shell_foreground_job_is_not_immediate_clear_signal() {
|
|
assert_eq!(
|
|
foreground_shell_agent_action(Some(Agent::Claude), None, false, false),
|
|
ForegroundShellAgentAction::ObserveProbe
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn first_agent_acquisition_keeps_osc_evidence_replacement_clears_it() {
|
|
let runtime = PaneRuntime::test_with_screen_bytes(80, 24, b"");
|
|
runtime.test_process_pty_bytes(b"\x1b]2;startup title\x1b\\\x1b]9;4;1;\x1b\\");
|
|
|
|
clear_osc_evidence_for_agent_transition(&runtime.terminal, None);
|
|
assert_eq!(runtime.agent_osc_title(), "startup title");
|
|
assert_eq!(runtime.agent_osc_progress(), "4;1;");
|
|
|
|
clear_osc_evidence_for_agent_transition(&runtime.terminal, Some(Agent::Claude));
|
|
assert_eq!(runtime.agent_osc_title(), "");
|
|
assert_eq!(runtime.agent_osc_progress(), "");
|
|
}
|
|
|
|
#[test]
|
|
fn reported_process_exit_clears_before_unknown_foreground_probe() {
|
|
assert_eq!(
|
|
foreground_shell_agent_action(Some(Agent::Claude), None, false, true),
|
|
ForegroundShellAgentAction::ClearAgent
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn foreground_agent_job_is_not_clear_signal() {
|
|
assert_eq!(
|
|
foreground_shell_agent_action(Some(Agent::Claude), Some(Agent::OpenCode), true, false,),
|
|
ForegroundShellAgentAction::ObserveProbe
|
|
);
|
|
}
|
|
|
|
fn foreground_process(pid: u32, name: &str) -> crate::platform::ForegroundProcess {
|
|
crate::platform::ForegroundProcess {
|
|
pid,
|
|
name: name.to_string(),
|
|
argv0: None,
|
|
argv: None,
|
|
cmdline: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn foreground_agent_hint_accepts_pane_shell_environment() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 42,
|
|
processes: vec![foreground_process(42, "bash")],
|
|
};
|
|
|
|
assert_eq!(
|
|
agent_hint_for_foreground_job_members(&job, |pid| {
|
|
(pid == 42).then_some(Agent::Claude)
|
|
}),
|
|
Some(Agent::Claude)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn foreground_agent_hint_accepts_non_leader_foreground_process_environment() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 99,
|
|
processes: vec![
|
|
foreground_process(99, "fence"),
|
|
foreground_process(100, "pi"),
|
|
],
|
|
};
|
|
|
|
assert_eq!(
|
|
agent_hint_for_foreground_job_members(&job, |pid| {
|
|
(pid == 100).then_some(Agent::Codex)
|
|
}),
|
|
Some(Agent::Codex)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn foreground_agent_hint_wins_over_process_name_detection() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 99,
|
|
processes: vec![foreground_process(99, "codex")],
|
|
};
|
|
|
|
let result = probe_foreground_process_from_jobs(
|
|
42,
|
|
Some(99),
|
|
Some(job),
|
|
|| None,
|
|
|pid| (pid == 99).then_some(Agent::Claude),
|
|
);
|
|
|
|
assert_eq!(result.agent, Some(Agent::Claude));
|
|
assert_eq!(result.process_name.as_deref(), Some("claude"));
|
|
}
|
|
|
|
#[test]
|
|
fn foreground_agent_hint_on_inherited_child_environment_is_authoritative() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 99,
|
|
processes: vec![foreground_process(99, "vim")],
|
|
};
|
|
|
|
let result = probe_foreground_process_from_jobs(
|
|
42,
|
|
Some(99),
|
|
None,
|
|
|| Some(job),
|
|
|pid| (pid == 99).then_some(Agent::Claude),
|
|
);
|
|
|
|
assert_eq!(result.agent, Some(Agent::Claude));
|
|
assert_eq!(result.process_name.as_deref(), Some("claude"));
|
|
}
|
|
|
|
#[test]
|
|
fn non_leader_agent_hint_does_not_override_identifiable_leader() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 99,
|
|
processes: vec![
|
|
foreground_process(99, "codex"),
|
|
foreground_process(100, "vim"),
|
|
],
|
|
};
|
|
|
|
let result = probe_foreground_process_from_jobs(
|
|
42,
|
|
Some(99),
|
|
None,
|
|
|| Some(job),
|
|
|pid| (pid == 100).then_some(Agent::Claude),
|
|
);
|
|
|
|
assert_eq!(result.agent, Some(Agent::Codex));
|
|
assert_eq!(result.process_name.as_deref(), Some("codex"));
|
|
}
|
|
|
|
#[test]
|
|
fn non_leader_agent_hint_wins_when_leader_is_unidentified() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 99,
|
|
processes: vec![
|
|
foreground_process(99, "some_vm"),
|
|
foreground_process(100, "vim"),
|
|
],
|
|
};
|
|
|
|
let result = probe_foreground_process_from_jobs(
|
|
42,
|
|
Some(99),
|
|
None,
|
|
|| Some(job),
|
|
|pid| (pid == 100).then_some(Agent::Claude),
|
|
);
|
|
|
|
assert_eq!(result.agent, Some(Agent::Claude));
|
|
assert_eq!(result.process_name.as_deref(), Some("claude"));
|
|
}
|
|
|
|
fn process_probe_input() -> ProcessProbeInput {
|
|
ProcessProbeInput {
|
|
current_agent: None,
|
|
suppressed_agent: None,
|
|
foreground_pgid: Some(42),
|
|
last_foreground_pgid: Some(42),
|
|
has_process_probe: true,
|
|
acquisition_age: None,
|
|
pending_foreground_shell_clear: false,
|
|
pending_restore_probe: false,
|
|
elapsed_since_process_check: std::time::Duration::from_secs(1),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn windows_foreground_observation_schedule_preserves_lifecycle_checks() {
|
|
let quiet = ProcessProbeInput {
|
|
current_agent: Some(Agent::Codex),
|
|
..process_probe_input()
|
|
};
|
|
let before_safety_bound = PROCESS_RECHECK_IDENTIFIED - std::time::Duration::from_millis(1);
|
|
let content_retry = std::time::Duration::from_millis(300);
|
|
let observe = |lifecycle, content_changed, elapsed, input| {
|
|
should_observe_foreground_process_group(lifecycle, content_changed, elapsed, input)
|
|
};
|
|
let content_due = |last: Option<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
|
|
));
|
|
}
|
|
}
|