mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 08:01:06 +00:00
fix: read alternate-screen agent history
This commit is contained in:
@@ -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,
|
||||
)]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -557,6 +557,7 @@ fn pane_read(
|
||||
lines,
|
||||
format: crate::api::schema::ReadFormat::Text,
|
||||
strip_ansi,
|
||||
intent: crate::api::schema::ReadIntent::Passive,
|
||||
}),
|
||||
},
|
||||
api_tx,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -503,6 +503,7 @@ fn pane_read(args: &[String]) -> std::io::Result<i32> {
|
||||
lines,
|
||||
format,
|
||||
strip_ansi,
|
||||
intent: crate::api::schema::ReadIntent::Interactive,
|
||||
}),
|
||||
})?;
|
||||
|
||||
|
||||
+10
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(|_| ())
|
||||
}
|
||||
+395
-1
@@ -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(¶ms.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(¶ms.target).ok()?,
|
||||
params.source,
|
||||
params.lines,
|
||||
params.format,
|
||||
),
|
||||
Method::PaneRead(params) if params.intent == ReadIntent::Interactive => (
|
||||
self.app.resolve_terminal_target(¶ms.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(¶ms.target).ok(),
|
||||
params.source,
|
||||
params.lines,
|
||||
params.format,
|
||||
),
|
||||
api::schema::Method::PaneRead(params) => (
|
||||
self.app.resolve_terminal_target(¶ms.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,
|
||||
@@ -5405,6 +5689,64 @@ next_tab = ""
|
||||
control_rx
|
||||
}
|
||||
|
||||
#[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, _| {
|
||||
@@ -5497,6 +5839,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, _| {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod alt_screen_read;
|
||||
pub mod autodetect;
|
||||
#[cfg(unix)]
|
||||
pub(crate) mod client_accept;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user