aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api/schema/common.rs7
-rw-r--r--src/api/schema/panes.rs3
-rw-r--r--src/api/schema/tests.rs3
-rw-r--r--src/api/subscriptions.rs1
-rw-r--r--src/api/wait.rs1
-rw-r--r--src/app/api/panes.rs1
-rw-r--r--src/app/api_helpers.rs5
-rw-r--r--src/app/mod.rs1
-rw-r--r--src/cli/pane.rs1
-rw-r--r--src/pane.rs10
-rw-r--r--src/pane/terminal.rs25
-rw-r--r--src/server/alt_screen_read.rs383
-rw-r--r--src/server/headless.rs396
-rw-r--r--src/server/mod.rs1
-rw-r--r--src/terminal/history_read.rs307
-rw-r--r--src/terminal/mod.rs2
-rw-r--r--src/terminal/runtime.rs10
17 files changed, 1155 insertions, 2 deletions
diff --git a/src/api/schema/common.rs b/src/api/schema/common.rs
index e22fa1b3..7313002c 100644
--- a/src/api/schema/common.rs
+++ b/src/api/schema/common.rs
@@ -66,6 +66,13 @@ pub enum ReadSource {
Detection,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub(crate) enum ReadIntent {
+ #[default]
+ Interactive,
+ Passive,
+}
+
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default,
)]
diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs
index 097b6366..647f7446 100644
--- a/src/api/schema/panes.rs
+++ b/src/api/schema/panes.rs
@@ -258,6 +258,9 @@ pub struct PaneReadParams {
pub format: ReadFormat,
#[serde(default = "super::default_true")]
pub strip_ansi: bool,
+ #[serde(skip)]
+ #[schemars(skip)]
+ pub(crate) intent: super::common::ReadIntent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs
index 16894dbf..5fe6dd27 100644
--- a/src/api/schema/tests.rs
+++ b/src/api/schema/tests.rs
@@ -412,10 +412,13 @@ fn pane_read_defaults_to_text_format() {
"#;
let request: Request = serde_json::from_str(json).unwrap();
+ let serialized = serde_json::to_value(&request).unwrap();
+ assert!(serialized["params"].get("intent").is_none());
let Method::PaneRead(params) = request.method else {
panic!("wrong method parsed");
};
assert_eq!(params.format, ReadFormat::Text);
+ assert_eq!(params.intent, ReadIntent::Interactive);
}
#[test]
diff --git a/src/api/subscriptions.rs b/src/api/subscriptions.rs
index 7bcc7b77..8d2f84ec 100644
--- a/src/api/subscriptions.rs
+++ b/src/api/subscriptions.rs
@@ -557,6 +557,7 @@ fn pane_read(
lines,
format: crate::api::schema::ReadFormat::Text,
strip_ansi,
+ intent: crate::api::schema::ReadIntent::Passive,
}),
},
api_tx,
diff --git a/src/api/wait.rs b/src/api/wait.rs
index 4b9c853e..7da9b7a9 100644
--- a/src/api/wait.rs
+++ b/src/api/wait.rs
@@ -64,6 +64,7 @@ pub(super) fn wait_for_output(
lines: params.lines,
format: crate::api::schema::ReadFormat::Text,
strip_ansi: params.strip_ansi,
+ intent: crate::api::schema::ReadIntent::Passive,
}),
};
let response =
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs
index 253fd627..02c9c3d4 100644
--- a/src/app/api/panes.rs
+++ b/src/app/api/panes.rs
@@ -2013,6 +2013,7 @@ mod tests {
lines: Some(2),
format: crate::api::schema::ReadFormat::Text,
strip_ansi: true,
+ intent: crate::api::schema::ReadIntent::Interactive,
},
);
let success: SuccessResponse = serde_json::from_str(&response).unwrap();
diff --git a/src/app/api_helpers.rs b/src/app/api_helpers.rs
index a6dad384..95224b9f 100644
--- a/src/app/api_helpers.rs
+++ b/src/app/api_helpers.rs
@@ -143,7 +143,10 @@ pub(super) fn read_terminal_snapshot(
}
}
-fn limit_snapshot_lines(text: String, limit: Option<usize>) -> crate::pane::TerminalReadSnapshot {
+pub(crate) fn limit_snapshot_lines(
+ text: String,
+ limit: Option<usize>,
+) -> crate::pane::TerminalReadSnapshot {
let Some(limit) = limit else {
return crate::pane::TerminalReadSnapshot {
text,
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 1733e234..2530a05b 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -10,6 +10,7 @@ pub(crate) mod agent_view;
mod agents;
mod api;
mod api_helpers;
+pub(crate) use api_helpers::limit_snapshot_lines;
mod config_io;
mod creation;
mod git_refresh;
diff --git a/src/cli/pane.rs b/src/cli/pane.rs
index 4448840b..7c12e0f9 100644
--- a/src/cli/pane.rs
+++ b/src/cli/pane.rs
@@ -503,6 +503,7 @@ fn pane_read(args: &[String]) -> std::io::Result<i32> {
lines,
format,
strip_ansi,
+ intent: crate::api::schema::ReadIntent::Interactive,
}),
})?;
diff --git a/src/pane.rs b/src/pane.rs
index be1a23da..4a10f8e4 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -2711,6 +2711,16 @@ impl PaneRuntime {
self.terminal.wheel_routing()
}
+ pub(crate) fn screen_text_snapshot(
+ &self,
+ ) -> Option<(
+ crate::ghostty::ActiveScreen,
+ u16,
+ Vec<crate::ghostty::ScreenTextRow>,
+ )> {
+ self.terminal.screen_text_snapshot()
+ }
+
pub fn encode_mouse_button(
&self,
kind: crossterm::event::MouseEventKind,
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs
index 76ab20d3..5a2180bf 100644
--- a/src/pane/terminal.rs
+++ b/src/pane/terminal.rs
@@ -369,6 +369,16 @@ impl PaneTerminal {
self.ghostty.wheel_routing()
}
+ pub(crate) fn screen_text_snapshot(
+ &self,
+ ) -> Option<(
+ crate::ghostty::ActiveScreen,
+ u16,
+ Vec<crate::ghostty::ScreenTextRow>,
+ )> {
+ self.ghostty.screen_text_snapshot()
+ }
+
pub fn cursor_state(&self) -> Option<TerminalCursorState> {
self.ghostty.cursor_state()
}
@@ -1714,6 +1724,21 @@ impl GhosttyPaneTerminal {
.filter(|bytes| !bytes.is_empty())
}
+ pub(crate) fn screen_text_snapshot(
+ &self,
+ ) -> Option<(
+ crate::ghostty::ActiveScreen,
+ u16,
+ Vec<crate::ghostty::ScreenTextRow>,
+ )> {
+ let core = self.core.lock().ok()?;
+ Some((
+ core.terminal.active_screen().ok()?,
+ core.terminal.cols().ok()?,
+ core.terminal.screen_text_rows().ok()?,
+ ))
+ }
+
pub fn visible_text(&self) -> String {
self.core
.lock()
diff --git a/src/server/alt_screen_read.rs b/src/server/alt_screen_read.rs
new file mode 100644
index 00000000..240e96de
--- /dev/null
+++ b/src/server/alt_screen_read.rs
@@ -0,0 +1,383 @@
+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<String>,
+ fallback_response: String,
+ read: PaneReadResult,
+ lines: usize,
+ unwrap: bool,
+ initial: ScreenSnapshot,
+ previous: ScreenSnapshot,
+ history: Vec<crate::ghostty::ScreenTextRow>,
+ phase: Phase,
+ next_poll_at: Instant,
+ started_at: Instant,
+ restore_started_at: Option<Instant>,
+ upward_events: usize,
+ reached_top: bool,
+ valid: bool,
+}
+
+impl PendingAltScreenRead {
+ pub(crate) fn start(
+ terminal_id: TerminalId,
+ request_id: String,
+ respond_to: mpsc::Sender<String>,
+ 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<u32>,
+ ) -> 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<PendingAltScreenRead>;
+
+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(|_| ())
+}
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 343493cd..6fdb83ce 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -275,6 +275,19 @@ const CLIENT_ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(250);
// Headless server
// ---------------------------------------------------------------------------
+struct AltScreenReadSpec {
+ terminal_id: crate::terminal::TerminalId,
+ lines: usize,
+ unwrap: bool,
+ initial: crate::terminal::ScreenSnapshot,
+}
+
+enum AltScreenReadConflict {
+ None,
+ Frozen(crate::pane::TerminalReadSnapshot),
+ Defer,
+}
+
/// The headless server — runs the herdr event loop without a real terminal.
pub struct HeadlessServer {
app: app::App,
@@ -300,6 +313,10 @@ pub struct HeadlessServer {
server_config_diagnostic_without_keybindings: Option<String>,
/// Writable direct attach owner per terminal id string.
terminal_attach_owners: HashMap<String, u64>,
+ /// Deferred application-history reads currently driving alternate-screen viewports.
+ pending_alt_screen_reads: Vec<crate::server::alt_screen_read::PendingAltScreenRead>,
+ /// Reads waiting for an alternate-screen traversal of the same terminal to finish.
+ deferred_alt_screen_reads: Vec<api::ApiRequestMessage>,
/// Monotonic activity counter used to pick the most recently active client.
next_activity_stamp: u64,
/// Shared pane runtime size derived from the foreground client,
@@ -492,6 +509,8 @@ impl HeadlessServer {
server_config_diagnostic,
server_config_diagnostic_without_keybindings,
terminal_attach_owners: HashMap::new(),
+ pending_alt_screen_reads: Vec::new(),
+ deferred_alt_screen_reads: Vec::new(),
next_activity_stamp: 1,
effective_size: (MIN_COLS, MIN_ROWS),
shutting_down: false,
@@ -638,6 +657,13 @@ impl HeadlessServer {
needs_graphics_render = false;
}
+ self.poll_pending_alt_screen_reads(now);
+ if self.process_deferred_alt_screen_reads() {
+ needs_render = true;
+ needs_full_render = true;
+ needs_graphics_render = false;
+ }
+
if latest_app_client(&self.clients).is_some() && self.app.ensure_default_workspace() {
needs_render = true;
needs_full_render = true;
@@ -728,6 +754,13 @@ impl HeadlessServer {
)
.map(|deadline| deadline.min(now + CLIENT_ACCEPT_POLL_INTERVAL))
.or(Some(now + CLIENT_ACCEPT_POLL_INTERVAL));
+ let next_deadline = self
+ .pending_alt_screen_reads
+ .iter()
+ .map(|pending| pending.next_deadline())
+ .fold(next_deadline, |deadline, pending| {
+ Some(deadline.map_or(pending, |current| current.min(pending)))
+ });
let event = {
tokio::select! {
maybe_api = self.app.api_rx.recv() => match maybe_api {
@@ -2563,6 +2596,23 @@ impl HeadlessServer {
return false;
};
+ if self
+ .pending_alt_screen_reads
+ .iter()
+ .any(|pending| pending.terminal_id == real_terminal_id)
+ {
+ self.send_to_client(
+ client_id,
+ ServerMessage::ServerShutdown {
+ reason: Some(format!(
+ "terminal attach failed: terminal {terminal_id} has a read in progress; retry"
+ )),
+ },
+ );
+ self.remove_client_and_resize_if_needed(client_id);
+ return false;
+ }
+
if let Some(existing_owner) = self.terminal_attach_owners.get(&terminal_id).copied() {
if existing_owner != client_id && !takeover {
self.send_to_client(
@@ -3021,6 +3071,188 @@ impl HeadlessServer {
)
}
+ fn agent_read_not_idle_error(
+ &self,
+ request: &api::schema::Request,
+ ) -> Option<api::schema::ErrorBody> {
+ use api::schema::{Method, ReadFormat, ReadSource};
+
+ let Method::AgentRead(params) = &request.method else {
+ return None;
+ };
+ let requested = params.lines?;
+ if params.format != ReadFormat::Text
+ || !matches!(
+ params.source,
+ ReadSource::Recent | ReadSource::RecentUnwrapped
+ )
+ {
+ return None;
+ }
+ let target = self.app.resolve_agent_target(&params.target).ok()?;
+ let terminal = self
+ .app
+ .state
+ .terminals
+ .values()
+ .find(|terminal| terminal.id.as_str() == target.terminal_id)?;
+ if terminal.effective_known_agent().is_none()
+ || terminal.state == crate::detect::AgentState::Idle
+ {
+ return None;
+ }
+ let runtime = self.app.terminal_runtimes.get(&terminal.id)?;
+ let (screen, snapshot) = runtime.screen_text_snapshot()?;
+ if screen != crate::ghostty::ActiveScreen::Alternate
+ || snapshot.rows.len() >= requested.min(1000) as usize
+ {
+ return None;
+ }
+ let status = crate::detect::manifest::agent_state_label(terminal.state);
+ Some(api::schema::ErrorBody {
+ code: "agent_not_idle".into(),
+ message: format!(
+ "cannot read {requested} lines while {} is {status}: its alternate-screen history can only be captured by scrolling while idle. Wait and retry, or use --source visible",
+ params.target
+ ),
+ })
+ }
+
+ fn alt_screen_read_spec(&self, request: &api::schema::Request) -> Option<AltScreenReadSpec> {
+ use api::schema::{Method, ReadFormat, ReadIntent, ReadSource};
+
+ let (target, source, lines, format) = match &request.method {
+ Method::AgentRead(params) => (
+ self.app.resolve_agent_target(&params.target).ok()?,
+ params.source,
+ params.lines,
+ params.format,
+ ),
+ Method::PaneRead(params) if params.intent == ReadIntent::Interactive => (
+ self.app.resolve_terminal_target(&params.pane_id).ok()?,
+ params.source,
+ params.lines,
+ params.format,
+ ),
+ _ => return None,
+ };
+ if format != ReadFormat::Text
+ || !matches!(source, ReadSource::Recent | ReadSource::RecentUnwrapped)
+ {
+ return None;
+ }
+ let lines = lines.unwrap_or(80).min(1000) as usize;
+ if lines == 0
+ || self
+ .terminal_attach_owners
+ .contains_key(target.terminal_id.as_str())
+ || self
+ .pending_alt_screen_reads
+ .iter()
+ .any(|pending| pending.terminal_id.as_str() == target.terminal_id)
+ {
+ return None;
+ }
+ let terminal = self
+ .app
+ .state
+ .terminals
+ .values()
+ .find(|terminal| terminal.id.as_str() == target.terminal_id)?;
+ if terminal.effective_known_agent().is_none()
+ || terminal.state != crate::detect::AgentState::Idle
+ {
+ return None;
+ }
+ let runtime = self.app.terminal_runtimes.get(&terminal.id)?;
+ if runtime.wheel_routing() != Some(crate::pane::WheelRouting::MouseReport) {
+ return None;
+ }
+ let (screen, initial) = runtime.screen_text_snapshot()?;
+ if screen != crate::ghostty::ActiveScreen::Alternate || initial.rows.len() >= lines {
+ return None;
+ }
+ Some(AltScreenReadSpec {
+ terminal_id: terminal.id.clone(),
+ lines,
+ unwrap: source == ReadSource::RecentUnwrapped,
+ initial,
+ })
+ }
+
+ fn poll_pending_alt_screen_reads(&mut self, now: Instant) {
+ let pending = std::mem::take(&mut self.pending_alt_screen_reads);
+ for read in pending {
+ let runtime = self.app.terminal_runtimes.get(&read.terminal_id);
+ let remains_idle = self
+ .app
+ .state
+ .terminals
+ .get(&read.terminal_id)
+ .is_some_and(|terminal| terminal.state == crate::detect::AgentState::Idle);
+ let attached = self
+ .terminal_attach_owners
+ .contains_key(read.terminal_id.as_str());
+ let outcome = if remains_idle && !attached {
+ read.poll(runtime, now)
+ } else {
+ read.abort(runtime, now)
+ };
+ if let Some(read) = outcome {
+ self.pending_alt_screen_reads.push(read);
+ }
+ }
+ }
+
+ fn alt_screen_read_conflict(&self, request: &api::schema::Request) -> AltScreenReadConflict {
+ let (target, source, lines, format) = match &request.method {
+ api::schema::Method::AgentRead(params) => (
+ self.app.resolve_agent_target(&params.target).ok(),
+ params.source,
+ params.lines,
+ params.format,
+ ),
+ api::schema::Method::PaneRead(params) => (
+ self.app.resolve_terminal_target(&params.pane_id).ok(),
+ params.source,
+ params.lines,
+ params.format,
+ ),
+ _ => return AltScreenReadConflict::None,
+ };
+ let Some(target) = target else {
+ return AltScreenReadConflict::None;
+ };
+ let Some(pending) = self
+ .pending_alt_screen_reads
+ .iter()
+ .find(|pending| pending.terminal_id.as_str() == target.terminal_id)
+ else {
+ return AltScreenReadConflict::None;
+ };
+ if format == api::schema::ReadFormat::Text {
+ AltScreenReadConflict::Frozen(pending.frozen_snapshot(source, lines))
+ } else {
+ AltScreenReadConflict::Defer
+ }
+ }
+
+ fn process_deferred_alt_screen_reads(&mut self) -> bool {
+ let deferred = std::mem::take(&mut self.deferred_alt_screen_reads);
+ let mut changed = false;
+ for msg in deferred {
+ match self.alt_screen_read_conflict(&msg.request) {
+ AltScreenReadConflict::None => {
+ changed |= self.handle_api_request_with_shutdown_check(msg);
+ }
+ AltScreenReadConflict::Frozen(_) | AltScreenReadConflict::Defer => {
+ self.deferred_alt_screen_reads.push(msg);
+ }
+ }
+ }
+ changed
+ }
+
/// Drains API requests with shutdown awareness.
///
/// During shutdown, remaining requests get a `server_unavailable` error.
@@ -3089,6 +3321,15 @@ impl HeadlessServer {
return false;
}
+ let frozen_alt_screen_read = match self.alt_screen_read_conflict(&msg.request) {
+ AltScreenReadConflict::None => None,
+ AltScreenReadConflict::Frozen(snapshot) => Some(snapshot),
+ AltScreenReadConflict::Defer => {
+ self.deferred_alt_screen_reads.push(msg);
+ return false;
+ }
+ };
+
let metadata_expired = self.app.expire_due_metadata(Instant::now());
if let api::schema::Method::ServerLiveHandoff(params) = &msg.request.method {
@@ -3193,6 +3434,16 @@ impl HeadlessServer {
};
self.sync_foreground_client_state();
+ if let Some(error) = self.agent_read_not_idle_error(&msg.request) {
+ let response = serde_json::to_string(&api::schema::ErrorResponse {
+ id: msg.request.id.clone(),
+ error,
+ })
+ .unwrap_or_else(|_| "{}".to_owned());
+ let _ = msg.respond_to.send(response);
+ return changed;
+ }
+ let alt_screen_read_spec = self.alt_screen_read_spec(&msg.request);
if matches!(
&msg.request.method,
api::schema::Method::WorktreeCreate(_) | api::schema::Method::WorktreeRemove(_)
@@ -3202,7 +3453,7 @@ impl HeadlessServer {
.handle_deferred_worktree_api_request(msg.request, msg.respond_to);
return changed | deferred_changed;
}
- let response = if matches!(
+ let mut response = if matches!(
&msg.request.method,
api::schema::Method::ServerReloadConfig(_)
) {
@@ -3228,6 +3479,37 @@ impl HeadlessServer {
self.app
.handle_api_request_after_internal_events_drained(msg.request)
};
+ if let Some(snapshot) = frozen_alt_screen_read {
+ if let Ok(mut success) = serde_json::from_str::<api::schema::SuccessResponse>(&response)
+ {
+ if let api::schema::ResponseResult::PaneRead { read } = &mut success.result {
+ read.text = snapshot.text;
+ read.truncated = snapshot.truncated;
+ if let Ok(serialized) = serde_json::to_string(&success) {
+ response = serialized;
+ }
+ }
+ }
+ }
+ if let Some(spec) = alt_screen_read_spec {
+ if let Ok(success) = serde_json::from_str::<api::schema::SuccessResponse>(&response) {
+ if let api::schema::ResponseResult::PaneRead { read } = success.result {
+ let pending = crate::server::alt_screen_read::PendingAltScreenRead::start(
+ spec.terminal_id,
+ success.id,
+ msg.respond_to,
+ response,
+ read,
+ spec.lines,
+ spec.unwrap,
+ spec.initial,
+ Instant::now(),
+ );
+ self.pending_alt_screen_reads.push(pending);
+ return changed;
+ }
+ }
+ }
let _ = msg.respond_to.send(response);
if let Some(revision_before) = pane_graphics_revision_before {
@@ -4709,6 +4991,8 @@ mod tests {
server_config_diagnostic: None,
server_config_diagnostic_without_keybindings: None,
terminal_attach_owners: HashMap::new(),
+ pending_alt_screen_reads: Vec::new(),
+ deferred_alt_screen_reads: Vec::new(),
next_activity_stamp: 1,
effective_size: (MIN_COLS, MIN_ROWS),
shutting_down: false,
@@ -5406,6 +5690,64 @@ next_tab = ""
}
#[test]
+ fn explicit_agent_history_read_requires_idle_on_alternate_screen() {
+ with_terminal_session_test_server(
+ |server, terminal_id, _terminal_id_string, public_pane_id| {
+ let terminal = server
+ .app
+ .state
+ .terminals
+ .get_mut(&terminal_id)
+ .expect("terminal");
+ terminal.detected_agent = Some(crate::detect::Agent::Claude);
+ terminal.state = crate::detect::AgentState::Working;
+ server.app.terminal_runtimes.insert(
+ terminal_id,
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(
+ 80,
+ 24,
+ b"\x1b[?1049hworking",
+ ),
+ );
+ let request = api::schema::Request {
+ id: "read".into(),
+ method: api::schema::Method::AgentRead(api::schema::AgentReadParams {
+ target: public_pane_id.clone(),
+ source: api::schema::ReadSource::Recent,
+ lines: Some(200),
+ format: api::schema::ReadFormat::Text,
+ strip_ansi: true,
+ }),
+ };
+
+ assert_eq!(
+ server.agent_read_not_idle_error(&request),
+ Some(api::schema::ErrorBody {
+ code: "agent_not_idle".into(),
+ message: format!(
+ "cannot read 200 lines while {public_pane_id} is working: its alternate-screen history can only be captured by scrolling while idle. Wait and retry, or use --source visible"
+ ),
+ })
+ );
+
+ let mut default_request = request.clone();
+ let api::schema::Method::AgentRead(params) = &mut default_request.method else {
+ unreachable!();
+ };
+ params.lines = None;
+ assert_eq!(server.agent_read_not_idle_error(&default_request), None);
+
+ let mut visible_request = request;
+ let api::schema::Method::AgentRead(params) = &mut visible_request.method else {
+ unreachable!();
+ };
+ params.source = api::schema::ReadSource::Visible;
+ assert_eq!(server.agent_read_not_idle_error(&visible_request), None);
+ },
+ );
+ }
+
+ #[test]
fn terminal_observe_allows_multiple_clients_without_attach_ownership() {
with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| {
let initial_size = server
@@ -5498,6 +5840,58 @@ next_tab = ""
}
#[test]
+ fn terminal_control_rejects_attach_during_alt_screen_read() {
+ with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| {
+ let (respond_to, _response_rx) = std::sync::mpsc::channel();
+ server.pending_alt_screen_reads.push(
+ crate::server::alt_screen_read::PendingAltScreenRead::start(
+ terminal_id,
+ "read".into(),
+ respond_to,
+ "fallback".into(),
+ api::schema::PaneReadResult {
+ pane_id: "w1:p1".into(),
+ workspace_id: "w1".into(),
+ tab_id: "w1:t1".into(),
+ source: api::schema::ReadSource::Recent,
+ format: api::schema::ReadFormat::Text,
+ text: String::new(),
+ revision: 0,
+ truncated: false,
+ },
+ 120,
+ false,
+ crate::terminal::ScreenSnapshot {
+ cols: 80,
+ rows: Vec::new(),
+ },
+ Instant::now(),
+ ),
+ );
+ let control_rx = connect_pending_terminal_client_with_control_rx(server, 7);
+
+ assert!(
+ !server.handle_server_event(ServerEvent::ClientControlTerminal {
+ client_id: 7,
+ target: terminal_id_string.clone(),
+ takeover: false,
+ })
+ );
+ assert!(!server.clients.contains_key(&7));
+ assert!(!server
+ .terminal_attach_owners
+ .contains_key(&terminal_id_string));
+ let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message"));
+ assert_eq!(
+ reason,
+ Some(format!(
+ "terminal attach failed: terminal {terminal_id_string} has a read in progress; retry"
+ ))
+ );
+ });
+ }
+
+ #[test]
fn terminal_control_rejects_second_controller_without_takeover() {
with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| {
connect_pending_terminal_client(server, 7);
diff --git a/src/server/mod.rs b/src/server/mod.rs
index 58b552e2..8566b4bd 100644
--- a/src/server/mod.rs
+++ b/src/server/mod.rs
@@ -1,3 +1,4 @@
+mod alt_screen_read;
pub mod autodetect;
#[cfg(unix)]
pub(crate) mod client_accept;
diff --git a/src/terminal/history_read.rs b/src/terminal/history_read.rs
new file mode 100644
index 00000000..decce3aa
--- /dev/null
+++ b/src/terminal/history_read.rs
@@ -0,0 +1,307 @@
+use crate::ghostty::{CellWide, ScreenTextRow};
+use crate::pane::TerminalReadSnapshot;
+
+const MIN_ALIGNMENT_RATIO_PERCENT: usize = 30;
+const SIMILAR_VIEWPORT_RATIO_PERCENT: usize = 70;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct ScreenSnapshot {
+ pub(crate) cols: u16,
+ pub(crate) rows: Vec<ScreenTextRow>,
+}
+
+impl ScreenSnapshot {
+ pub(crate) fn similar_text(&self, other: &Self) -> bool {
+ if self.cols != other.cols || self.rows.len() != other.rows.len() {
+ return false;
+ }
+ let left = row_identities(&self.rows);
+ let right = row_identities(&other.rows);
+ let comparable = left
+ .iter()
+ .zip(&right)
+ .filter(|(left, right)| !left.is_empty() || !right.is_empty())
+ .count();
+ if comparable == 0 {
+ return true;
+ }
+ let matches = left
+ .iter()
+ .zip(&right)
+ .filter(|(left, right)| left == right && (!left.is_empty() || !right.is_empty()))
+ .count();
+ matches.saturating_mul(100) >= comparable.saturating_mul(SIMILAR_VIEWPORT_RATIO_PERCENT)
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum UpwardMerge {
+ Advanced { rows: usize },
+ Unchanged,
+ Unaligned,
+}
+
+pub(crate) fn merge_scrolled_up(
+ history: &mut Vec<ScreenTextRow>,
+ previous: &ScreenSnapshot,
+ next: &ScreenSnapshot,
+) -> UpwardMerge {
+ if previous.cols != next.cols || previous.rows.len() != next.rows.len() {
+ return UpwardMerge::Unaligned;
+ }
+ let previous_text = row_identities(&previous.rows);
+ let next_text = row_identities(&next.rows);
+ if previous_text == next_text {
+ return UpwardMerge::Unchanged;
+ }
+ let Some(shift) = best_upward_shift(&previous_text, &next_text) else {
+ return UpwardMerge::Unaligned;
+ };
+ let Some(boundary) = (0..previous_text.len().saturating_sub(shift)).find_map(|index| {
+ let next_index = index + shift;
+ (!previous_text[index].is_empty() && previous_text[index] == next_text[next_index])
+ .then_some(next_index)
+ }) else {
+ return UpwardMerge::Unaligned;
+ };
+ let added: Vec<_> = next.rows[..boundary]
+ .iter()
+ .enumerate()
+ .filter(|(index, _)| {
+ next_text[*index].is_empty() || previous_text.get(*index) != Some(&next_text[*index])
+ })
+ .map(|(_, row)| row.clone())
+ .collect();
+ if added.is_empty() {
+ return UpwardMerge::Unaligned;
+ }
+ let rows = added.len();
+ history.splice(0..0, added);
+ UpwardMerge::Advanced { rows }
+}
+
+pub(crate) fn snapshot_text(
+ rows: &[ScreenTextRow],
+ lines: usize,
+ unwrap: bool,
+ truncated: bool,
+) -> TerminalReadSnapshot {
+ let start = rows.len().saturating_sub(lines);
+ let rows = &rows[start..];
+ let text = if unwrap {
+ unwrapped_text(rows)
+ } else {
+ wrapped_text(rows)
+ };
+ TerminalReadSnapshot { text, truncated }
+}
+
+fn best_upward_shift(previous: &[String], next: &[String]) -> Option<usize> {
+ let mut best = None;
+ for shift in 1..previous.len() {
+ let overlap = previous.len() - shift;
+ let mut comparable = 0usize;
+ let mut matches = 0usize;
+ for index in 0..overlap {
+ let before = &previous[index];
+ let after = &next[index + shift];
+ if before.is_empty() || after.is_empty() {
+ continue;
+ }
+ comparable += 1;
+ if before == after {
+ matches += 1;
+ }
+ }
+ if comparable == 0
+ || matches.saturating_mul(100) < comparable.saturating_mul(MIN_ALIGNMENT_RATIO_PERCENT)
+ {
+ continue;
+ }
+ if best.is_none_or(|(_, best_matches, best_comparable)| {
+ matches > best_matches || (matches == best_matches && comparable > best_comparable)
+ }) {
+ best = Some((shift, matches, comparable));
+ }
+ }
+ best.map(|(shift, _, _)| shift)
+}
+
+fn row_identities(rows: &[ScreenTextRow]) -> Vec<String> {
+ rows.iter()
+ .map(|row| row_text(row).trim_end().to_string())
+ .collect()
+}
+
+fn wrapped_text(rows: &[ScreenTextRow]) -> String {
+ let mut lines: Vec<_> = rows
+ .iter()
+ .map(|row| row_text(row).trim_end().to_string())
+ .collect();
+ while lines.last().is_some_and(|line| line.trim().is_empty()) {
+ lines.pop();
+ }
+ lines_to_text(lines)
+}
+
+fn unwrapped_text(rows: &[ScreenTextRow]) -> String {
+ let mut lines = Vec::new();
+ let mut current = String::new();
+ for row in rows {
+ let text = row_text(row);
+ if row.soft_wrapped {
+ current.push_str(text.trim_end());
+ } else {
+ current.push_str(text.trim_end());
+ lines.push(std::mem::take(&mut current));
+ }
+ }
+ if !current.is_empty() {
+ lines.push(current);
+ }
+ while lines.last().is_some_and(|line| line.trim().is_empty()) {
+ lines.pop();
+ }
+ lines_to_text(lines)
+}
+
+fn lines_to_text(lines: Vec<String>) -> String {
+ let text = lines.join("\n");
+ if text.is_empty() {
+ text
+ } else {
+ format!("{text}\n")
+ }
+}
+
+fn row_text(row: &ScreenTextRow) -> String {
+ let mut text = String::new();
+ for cell in &row.cells {
+ if cell.wide == CellWide::SpacerTail {
+ continue;
+ }
+ if cell.graphemes.is_empty()
+ || cell.graphemes.first().copied() == Some(crate::ghostty::KITTY_UNICODE_PLACEHOLDER)
+ {
+ text.push(' ');
+ } else {
+ text.extend(cell.graphemes.iter().map(|codepoint| {
+ char::from_u32(*codepoint).unwrap_or(char::REPLACEMENT_CHARACTER)
+ }));
+ }
+ }
+ text
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::ghostty::{ScreenTextCell, ScreenTextRow};
+
+ fn row(text: &str) -> ScreenTextRow {
+ ScreenTextRow {
+ cells: text
+ .chars()
+ .map(|ch| ScreenTextCell {
+ wide: CellWide::Narrow,
+ graphemes: vec![ch as u32],
+ })
+ .collect(),
+ soft_wrapped: false,
+ wrap_continuation: false,
+ }
+ }
+
+ fn snapshot(lines: &[&str]) -> ScreenSnapshot {
+ ScreenSnapshot {
+ cols: 20,
+ rows: lines.iter().map(|line| row(line)).collect(),
+ }
+ }
+
+ #[test]
+ fn viewport_similarity_tolerates_small_dynamic_regions() {
+ let initial = snapshot(&["line 1", "line 2", "worked for 2s", "prompt"]);
+ let status_changed = snapshot(&["line 1", "line 2", "worked for 3s", "prompt"]);
+ let scrolled = snapshot(&["older", "line 1", "line 2", "prompt"]);
+
+ assert!(initial.similar_text(&status_changed));
+ assert!(!initial.similar_text(&scrolled));
+ }
+
+ #[test]
+ fn controlled_upward_scroll_prepends_only_new_rows() {
+ let previous = snapshot(&["line 3", "line 4", "line 5", "status"]);
+ let next = snapshot(&["line 1", "line 2", "line 3", "line 4"]);
+ let mut history = previous.rows.clone();
+
+ assert_eq!(
+ merge_scrolled_up(&mut history, &previous, &next),
+ UpwardMerge::Advanced { rows: 2 }
+ );
+ assert_eq!(
+ row_identities(&history),
+ ["line 1", "line 2", "line 3", "line 4", "line 5", "status"]
+ );
+ }
+
+ #[test]
+ fn fixed_header_is_not_repeated_or_counted_as_scrolled_history() {
+ let previous = snapshot(&["sticky", "line 4", "line 5", "line 6", "line 7"]);
+ let next = snapshot(&["sticky", "line 2", "line 3", "line 4", "line 5"]);
+ let mut history = previous.rows.clone();
+
+ assert_eq!(
+ merge_scrolled_up(&mut history, &previous, &next),
+ UpwardMerge::Advanced { rows: 2 }
+ );
+ assert_eq!(
+ row_identities(&history),
+ ["line 2", "line 3", "sticky", "line 4", "line 5", "line 6", "line 7"]
+ );
+ }
+
+ #[test]
+ fn unchanged_and_unaligned_frames_do_not_change_history() {
+ let previous = snapshot(&["line 1", "line 2", "line 3"]);
+ let mut history = previous.rows.clone();
+
+ assert_eq!(
+ merge_scrolled_up(&mut history, &previous, &previous),
+ UpwardMerge::Unchanged
+ );
+ assert_eq!(
+ merge_scrolled_up(
+ &mut history,
+ &previous,
+ &snapshot(&["other a", "other b", "other c"]),
+ ),
+ UpwardMerge::Unaligned
+ );
+ assert_eq!(history, previous.rows);
+ }
+
+ #[test]
+ fn snapshot_text_limits_rendered_rows_before_unwrapping() {
+ let mut first = row("hello ");
+ first.soft_wrapped = true;
+ let mut second = row("world");
+ second.wrap_continuation = true;
+ let rows = vec![row("older"), first, second];
+
+ assert_eq!(
+ snapshot_text(&rows, 2, false, true),
+ TerminalReadSnapshot {
+ text: "hello\nworld\n".into(),
+ truncated: true,
+ }
+ );
+ assert_eq!(
+ snapshot_text(&rows, 2, true, true),
+ TerminalReadSnapshot {
+ text: "helloworld\n".into(),
+ truncated: true,
+ }
+ );
+ }
+}
diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs
index a67b7bae..129634c6 100644
--- a/src/terminal/mod.rs
+++ b/src/terminal/mod.rs
@@ -1,9 +1,11 @@
+mod history_read;
mod id;
mod runtime;
mod runtime_registry;
pub mod state;
mod title;
+pub(crate) use history_read::{merge_scrolled_up, snapshot_text, ScreenSnapshot, UpwardMerge};
pub use id::TerminalId;
pub use runtime::TerminalRuntime;
pub(crate) use runtime_registry::TerminalRuntimeRegistry;
diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs
index d4b66f06..38b36942 100644
--- a/src/terminal/runtime.rs
+++ b/src/terminal/runtime.rs
@@ -433,6 +433,16 @@ impl TerminalRuntime {
self.0.wheel_routing()
}
+ pub(crate) fn screen_text_snapshot(
+ &self,
+ ) -> Option<(
+ crate::ghostty::ActiveScreen,
+ crate::terminal::ScreenSnapshot,
+ )> {
+ let (screen, cols, rows) = self.0.screen_text_snapshot()?;
+ Some((screen, crate::terminal::ScreenSnapshot { cols, rows }))
+ }
+
pub fn encode_mouse_button(
&self,
kind: crossterm::event::MouseEventKind,