use std::sync::mpsc; use std::time::{Duration, Instant}; use bytes::Bytes; use crossterm::event::{KeyModifiers, MouseEventKind}; use tracing::debug; use crate::api::schema::{PaneReadResult, ResponseResult, SuccessResponse}; use crate::terminal::{ScreenSnapshot, TerminalId, TerminalRuntime, UpwardMerge}; const STEP_SETTLE: Duration = Duration::from_millis(120); const MAX_DURATION: Duration = Duration::from_secs(15); const MAX_RESTORE_DURATION: Duration = Duration::from_secs(5); const MAX_UNALIGNED_CHECKS: u8 = 4; const WHEEL_STEP_EVENTS: usize = 3; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Phase { SettleInitial { checks: u8 }, ProbeBottom, RestoreProbe, Harvest { unaligned_checks: u8 }, Restore { stable_checks: u8 }, } pub(crate) struct PendingAltScreenRead { pub(crate) terminal_id: TerminalId, request_id: String, respond_to: mpsc::Sender, fallback_response: String, read: PaneReadResult, lines: usize, unwrap: bool, initial: ScreenSnapshot, previous: ScreenSnapshot, history: Vec, phase: Phase, next_poll_at: Instant, started_at: Instant, restore_started_at: Option, upward_events: usize, reached_top: bool, valid: bool, } impl PendingAltScreenRead { pub(crate) fn start( terminal_id: TerminalId, request_id: String, respond_to: mpsc::Sender, fallback_response: String, read: PaneReadResult, lines: usize, unwrap: bool, initial: ScreenSnapshot, now: Instant, ) -> Self { Self { terminal_id, request_id, respond_to, fallback_response, read, lines, unwrap, previous: initial.clone(), history: initial.rows.clone(), initial, phase: Phase::SettleInitial { checks: 0 }, next_poll_at: now + STEP_SETTLE, started_at: now, restore_started_at: None, upward_events: 0, reached_top: false, valid: true, } } pub(crate) fn next_deadline(&self) -> Instant { self.next_poll_at } pub(crate) fn frozen_snapshot( &self, source: crate::api::schema::ReadSource, lines: Option, ) -> crate::pane::TerminalReadSnapshot { let line_limit = lines.map(|lines| lines.min(1000) as usize); match source { crate::api::schema::ReadSource::Recent | crate::api::schema::ReadSource::RecentUnwrapped => { let limit = line_limit.unwrap_or(80); crate::terminal::snapshot_text( &self.initial.rows, limit, source == crate::api::schema::ReadSource::RecentUnwrapped, self.initial.rows.len() > limit, ) } crate::api::schema::ReadSource::Visible | crate::api::schema::ReadSource::Detection => { let snapshot = crate::terminal::snapshot_text( &self.initial.rows, self.initial.rows.len(), false, false, ); crate::app::limit_snapshot_lines(snapshot.text, line_limit) } } } pub(crate) fn abort(mut self, runtime: Option<&TerminalRuntime>, now: Instant) -> PollOutcome { self.valid = false; match self.phase { Phase::SettleInitial { .. } => self.complete_fallback(), Phase::Harvest { .. } => match runtime { Some(runtime) => self.start_restore(runtime, now), None => self.complete_fallback(), }, Phase::ProbeBottom | Phase::RestoreProbe | Phase::Restore { .. } => { self.poll(runtime, now) } } } pub(crate) fn poll(mut self, runtime: Option<&TerminalRuntime>, now: Instant) -> PollOutcome { if now < self.next_poll_at { return Some(self); } let Some(runtime) = runtime else { return self.complete_fallback(); }; let Some((screen, snapshot)) = runtime.screen_text_snapshot() else { return self.complete_fallback(); }; if screen != crate::ghostty::ActiveScreen::Alternate || snapshot.cols != self.initial.cols || snapshot.rows.len() != self.initial.rows.len() { return self.complete_fallback(); } if self .restore_started_at .is_some_and(|started| now.duration_since(started) >= MAX_RESTORE_DURATION) { return self.complete_fallback(); } if now.duration_since(self.started_at) >= MAX_DURATION && !matches!( self.phase, Phase::ProbeBottom | Phase::Restore { .. } | Phase::RestoreProbe ) { self.valid = false; return self.start_restore(runtime, now); } match self.phase { Phase::SettleInitial { checks } => { if snapshot.similar_text(&self.initial) { if checks >= 1 { if send_wheel( runtime, MouseEventKind::ScrollDown, WHEEL_STEP_EVENTS, &snapshot, ) .is_err() { return self.complete_fallback(); } self.phase = Phase::ProbeBottom; } else { self.phase = Phase::SettleInitial { checks: checks + 1 }; } } else { self.initial = snapshot.clone(); self.previous = snapshot.clone(); self.history = snapshot.rows; self.phase = Phase::SettleInitial { checks: 0 }; } self.next_poll_at = now + STEP_SETTLE; Some(self) } Phase::ProbeBottom => { debug!( terminal_id = %self.terminal_id, at_bottom = snapshot.similar_text(&self.initial), "alternate-screen read bottom probe settled" ); if snapshot.similar_text(&self.initial) { if self.valid { self.start_harvest(runtime, now) } else { self.complete_fallback() } } else if send_wheel( runtime, MouseEventKind::ScrollUp, WHEEL_STEP_EVENTS, &snapshot, ) .is_ok() { self.phase = Phase::RestoreProbe; self.restore_started_at = Some(now); self.next_poll_at = now + STEP_SETTLE; Some(self) } else { self.complete_fallback() } } Phase::RestoreProbe => { if snapshot.similar_text(&self.initial) { self.complete_fallback() } else { self.next_poll_at = now + STEP_SETTLE; Some(self) } } Phase::Harvest { unaligned_checks } => { let merge = crate::terminal::merge_scrolled_up( &mut self.history, &self.previous, &snapshot, ); match merge { UpwardMerge::Advanced { .. } => { self.previous = snapshot; if self.history.len() >= self.lines { self.start_restore(runtime, now) } else { self.start_harvest(runtime, now) } } UpwardMerge::Unchanged => { self.reached_top = true; self.start_restore(runtime, now) } UpwardMerge::Unaligned if unaligned_checks + 1 < MAX_UNALIGNED_CHECKS => { self.phase = Phase::Harvest { unaligned_checks: unaligned_checks + 1, }; self.next_poll_at = now + STEP_SETTLE; Some(self) } UpwardMerge::Unaligned => { self.valid = false; self.start_restore(runtime, now) } } } Phase::Restore { stable_checks } => { if snapshot.similar_text(&self.previous) { if stable_checks >= 1 { if self.valid { self.complete_success() } else { self.complete_fallback() } } else { self.phase = Phase::Restore { stable_checks: stable_checks + 1, }; self.next_poll_at = now + STEP_SETTLE; Some(self) } } else { self.previous = snapshot; if send_wheel( runtime, MouseEventKind::ScrollDown, restore_batch_size(&self.previous), &self.previous, ) .is_ok() { self.phase = Phase::Restore { stable_checks: 0 }; self.next_poll_at = now + STEP_SETTLE; Some(self) } else { self.complete_fallback() } } } } } fn start_harvest(mut self, runtime: &TerminalRuntime, now: Instant) -> PollOutcome { let events = WHEEL_STEP_EVENTS; if send_wheel(runtime, MouseEventKind::ScrollUp, events, &self.previous).is_err() { return self.complete_fallback(); } self.upward_events = self.upward_events.saturating_add(events); self.phase = Phase::Harvest { unaligned_checks: 0, }; self.next_poll_at = now + STEP_SETTLE; Some(self) } fn start_restore(mut self, runtime: &TerminalRuntime, now: Instant) -> PollOutcome { if self.upward_events == 0 { return self.complete_fallback(); } if send_wheel( runtime, MouseEventKind::ScrollDown, self.upward_events, &self.previous, ) .is_err() { return self.complete_fallback(); } self.phase = Phase::Restore { stable_checks: 0 }; self.restore_started_at = Some(now); self.next_poll_at = now + STEP_SETTLE; Some(self) } fn complete_success(mut self) -> PollOutcome { debug!( terminal_id = %self.terminal_id, retained_rows = self.history.len(), requested_rows = self.lines, reached_top = self.reached_top, "alternate-screen read completed" ); let truncated = !self.reached_top || self.history.len() > self.lines; let snapshot = crate::terminal::snapshot_text(&self.history, self.lines, self.unwrap, truncated); self.read.text = snapshot.text; self.read.truncated = snapshot.truncated; let response = serde_json::to_string(&SuccessResponse { id: self.request_id, result: ResponseResult::PaneRead { read: self.read }, }) .unwrap_or(self.fallback_response); let _ = self.respond_to.send(response); None } fn complete_fallback(self) -> PollOutcome { debug!( terminal_id = %self.terminal_id, ?self.phase, retained_rows = self.history.len(), upward_events = self.upward_events, valid = self.valid, "alternate-screen read fell back to passive snapshot" ); let _ = self.respond_to.send(self.fallback_response); None } } pub(crate) type PollOutcome = Option; fn restore_batch_size(snapshot: &ScreenSnapshot) -> usize { snapshot.rows.len().saturating_div(2).max(1) } fn send_wheel( runtime: &TerminalRuntime, kind: MouseEventKind, events: usize, snapshot: &ScreenSnapshot, ) -> Result<(), ()> { if runtime.wheel_routing() != Some(crate::pane::WheelRouting::MouseReport) { return Err(()); } let column = snapshot.cols.saturating_sub(1) / 2; let row = u16::try_from(snapshot.rows.len().saturating_sub(1) / 2).unwrap_or(0); let event = runtime .encode_mouse_wheel(kind, column, row, KeyModifiers::empty()) .ok_or(())?; let mut bytes = Vec::with_capacity(event.len().saturating_mul(events)); for _ in 0..events { bytes.extend_from_slice(&event); } runtime.try_send_bytes(Bytes::from(bytes)).map_err(|_| ()) }