diff --git a/Cargo.lock b/Cargo.lock index 70c7a227..121544cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -826,6 +826,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "vt100", "which", "wstunnel", "zstd", diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 5432ea07..86da3efe 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -952,6 +952,25 @@ impl Client { ) .await } + /// Fetch a server-rendered reconstruction of the session's terminal + /// (scrollback rows + visible-screen repaint as escape sequences) — + /// attach in O(screen) instead of replaying raw PTY history. Errors + /// when the daemon has no PTY geometry for the session; callers fall + /// back to [`Self::pty_replay`]. + pub async fn screen_snapshot( + &self, + id: &str, + strip_alt_screen: bool, + ) -> Result { + self.request( + ipc_method::SESSION_SCREEN_SNAPSHOT, + &construct_protocol::ScreenSnapshotParams { + session_id: id.to_string(), + strip_alt_screen, + }, + ) + .await + } pub async fn interrupt(&self, id: &str) -> Result<()> { let _: serde_json::Value = self .request( diff --git a/crates/daemon/Cargo.toml b/crates/daemon/Cargo.toml index 6739d349..96b21617 100644 --- a/crates/daemon/Cargo.toml +++ b/crates/daemon/Cargo.toml @@ -33,6 +33,7 @@ which.workspace = true futures.workspace = true async-trait.workspace = true base64.workspace = true +vt100.workspace = true # Console PTY (a browser-rendered instance of our own TUI client). portable-pty.workspace = true tokio-tungstenite.workspace = true diff --git a/crates/daemon/assets/index.html b/crates/daemon/assets/index.html index e8624024..5020d81e 100644 --- a/crates/daemon/assets/index.html +++ b/crates/daemon/assets/index.html @@ -11589,6 +11589,9 @@

handle.ptyReplayEndOffset = 0; handle.ptyReplayTotalBytes = 0; handle.ptyReplayLoadingOlder = false; + handle.snapshotHydrated = false; + handle.snapshotEndOffset = 0; + handle.snapshotTruncated = false; handle.structuredTranscript = false; handle.transcriptReplayEvents = []; handle.transcriptHistoryBeforeSeq = 0; @@ -11679,19 +11682,42 @@

await replayStructuredTranscript(handle); renderEditorStateForSession(id); } else { - const r = await rpc("session.pty_replay", { - session_id: id, - max_bytes: PTY_REPLAY_PAGE_BYTES, - }); - if (r && r.size) { - // Daemon's last-known size wins as the initial geometry; we - // refit below in case the phone screen is narrower. - state.term.resize(r.size.cols, r.size.rows); - state.ptySizeById.set(id, { cols: r.size.cols, rows: r.size.rows }); + // Prefer a rendered screen snapshot (spec 0188): the daemon feeds + // the pty.log tail through its own native emulator and returns + // just scrollback rows + a visible-screen repaint as escape + // sequences, so attach cost is O(screen + scrollback) instead of + // replaying raw history through xterm. Fall back to the raw + // pty_replay path on older daemons or when the daemon has no PTY + // geometry to render at. + let snap = null; + try { + snap = await rpc("session.screen_snapshot", { + session_id: id, + // Mirror writeTermBytes: this client strips alt-screen + // switches from everything it feeds xterm, so ask for a + // render that applied the same filter to the history. + strip_alt_screen: true, + }); + } catch (_) { + // Older daemon or unrenderable session — use raw replay below. + } + if (snap && snap.size) { + await hydrateFromScreenSnapshot(handle, id, snap); + } else { + const r = await rpc("session.pty_replay", { + session_id: id, + max_bytes: PTY_REPLAY_PAGE_BYTES, + }); + if (r && r.size) { + // Daemon's last-known size wins as the initial geometry; we + // refit below in case the phone screen is narrower. + state.term.resize(r.size.cols, r.size.rows); + state.ptySizeById.set(id, { cols: r.size.cols, rows: r.size.rows }); + } + rememberPtyReplayChunk(handle, r); + updateTerminalHistoryButton(handle); + await replayPtyChunks(handle); } - rememberPtyReplayChunk(handle, r); - updateTerminalHistoryButton(handle); - await replayPtyChunks(handle); hydratedFromPtyReplay = true; } handle.term.scrollToBottom(); @@ -11972,6 +11998,13 @@

ptyReplayEndOffset: 0, ptyReplayTotalBytes: 0, ptyReplayLoadingOlder: false, + // True while the terminal's contents came from session.screen_snapshot + // (a rendered screen, not raw bytes). xterm cannot prepend, so the + // first "load older" leaves snapshot mode: it re-fetches the raw span + // the snapshot covered plus one older page and rebuilds classically. + snapshotHydrated: false, + snapshotEndOffset: 0, + snapshotTruncated: false, structuredTranscript: false, transcriptReplayEvents: [], transcriptHistoryBeforeSeq: 0, @@ -12145,6 +12178,36 @@

return writeTermData(term, filterTerminalAltScreenBytes(bytes)); } +// Hydrate a terminal from a session.screen_snapshot result: one write of a +// server-rendered escape stream (scrollback rows + screen repaint) into a +// freshly reset xterm sized to the snapshot's PTY geometry. Seeds the same +// offset bookkeeping the raw-replay path uses so "load older" knows where +// the rendered span ended. +async function hydrateFromScreenSnapshot(handle, id, snap) { + // The snapshot was rendered at exactly this geometry; resizing after the + // write would rewrap a screen the daemon has already laid out. + handle.term.resize(snap.size.cols, snap.size.rows); + state.ptySizeById.set(id, { cols: snap.size.cols, rows: snap.size.rows }); + const bytes = decodeBase64ToBytes(snap.data || ""); + ensureTerminalScrollback(handle, Number(snap.scrollback_rows || 0)); + handle.replayingPty = true; + try { + handle.term.reset(); + handle.ptyAltScreenFilterCarry = new Uint8Array(0); + await writeTermBytes(handle.term, bytes); + } finally { + handle.replayingPty = false; + } + handle.snapshotHydrated = true; + handle.snapshotEndOffset = Number(snap.end_offset || 0); + handle.snapshotTruncated = !!snap.scrollback_truncated; + handle.ptyReplayChunks = []; + handle.ptyReplayStartOffset = Number(snap.start_offset || 0); + handle.ptyReplayEndOffset = Number(snap.end_offset || 0); + handle.ptyReplayTotalBytes = Number(snap.total_bytes || 0); + updateTerminalHistoryButton(handle); +} + async function replayPtyChunks(handle) { if (!handle || !handle.term) return; const loadedBytes = handle.ptyReplayChunks.reduce((sum, chunk) => sum + chunk.bytes.length, 0); @@ -12189,17 +12252,26 @@

const canLoad = !!(handle && handle.loaded && ( handle.structuredTranscript ? handle.transcriptHistoryBeforeSeq > 1 - : handle.ptyReplayStartOffset > 0 + : hasOlderPtyHistory(handle) )); terminalHistoryBtn.hidden = !canLoad; terminalHistoryBtn.disabled = !!(canLoad && handle.ptyReplayLoadingOlder); } +// Whether raw pty.log history older than what the terminal shows exists. A +// snapshot-hydrated terminal can also owe rows dropped by the snapshot's +// scrollback budget even when its rendered span started at offset 0. +function hasOlderPtyHistory(handle) { + if (!handle) return false; + if (handle.ptyReplayStartOffset > 0) return true; + return !!(handle.snapshotHydrated && handle.snapshotTruncated); +} + function shouldLoadOlderPtyReplay(handle, { force = false } = {}) { if (!handle || !handle.loaded || handle.ptyReplayLoadingOlder) return false; if (handle.structuredTranscript) { if (handle.transcriptHistoryBeforeSeq <= 1) return false; - } else if (handle.ptyReplayStartOffset <= 0) return false; + } else if (!hasOlderPtyHistory(handle)) return false; if (force) return true; const active = handle.term && handle.term.buffer && handle.term.buffer.active; if (!active) return false; @@ -12216,17 +12288,34 @@

updateTerminalHistoryButton(handle); const priorViewportY = handle.term.buffer.active.viewportY; const priorBaseY = handle.term.buffer.active.baseY; - const maxBytes = PTY_REPLAY_PAGE_BYTES; + // Leaving snapshot mode: the terminal holds a rendered screen, not raw + // bytes, and xterm cannot prepend. Re-fetch the raw span the snapshot + // rendered plus one older page so the chunk replay below rebuilds the + // whole terminal from real history; later pages then extend classically. + const fromSnapshot = !handle.structuredTranscript && handle.snapshotHydrated; + const request = fromSnapshot + ? { + session_id: id, + before_offset: handle.snapshotEndOffset, + max_bytes: + Math.max(0, handle.snapshotEndOffset - handle.ptyReplayStartOffset) + + PTY_REPLAY_PAGE_BYTES, + } + : { + session_id: id, + before_offset: handle.ptyReplayStartOffset, + max_bytes: PTY_REPLAY_PAGE_BYTES, + }; setTerminalLoadingMessage(ptyReplayProgressText(handle)); state.ptyBuffer = []; state.ptyBuffering = true; try { - const r = await rpc("session.pty_replay", { - session_id: id, - before_offset: handle.ptyReplayStartOffset, - max_bytes: maxBytes, - }); + const r = await rpc("session.pty_replay", request); if (state.currentId !== id) return; + if (fromSnapshot) { + handle.snapshotHydrated = false; + handle.snapshotTruncated = false; + } rememberPtyReplayChunk(handle, r); updateTerminalHistoryButton(handle); setTerminalLoadingMessage(ptyReplayProgressText(handle)); @@ -13923,10 +14012,24 @@

handle.hydrating = true; handle.replayingPty = true; try { - const r = await rpc("session.pty_replay", { session_id: handle.sessionId }); - if (r && r.size) handle.term.resize(r.size.cols, r.size.rows); - if (r && r.data) { - await writeTermBytes(handle.term, decodeBase64ToBytes(r.data)); + // A mirror only needs the current screen; prefer the rendered snapshot + // over shipping and replaying the raw history (older daemons fall back). + let snap = null; + try { + snap = await rpc("session.screen_snapshot", { + session_id: handle.sessionId, + strip_alt_screen: true, + }); + } catch (_) {} + if (snap && snap.size) { + handle.term.resize(snap.size.cols, snap.size.rows); + await writeTermBytes(handle.term, decodeBase64ToBytes(snap.data || "")); + } else { + const r = await rpc("session.pty_replay", { session_id: handle.sessionId }); + if (r && r.size) handle.term.resize(r.size.cols, r.size.rows); + if (r && r.data) { + await writeTermBytes(handle.term, decodeBase64ToBytes(r.data)); + } } handle.loaded = true; } catch (_) { @@ -14112,9 +14215,29 @@

if (!handle || handle.loaded || handle.mirrorHydrating) return; handle.mirrorHydrating = true; try { - const r = await rpc("session.pty_replay", { session_id: id }); - if (r && r.data) { - await writeTermBytes(handle.term, decodeBase64ToBytes(r.data)); + // Prefer the rendered snapshot: same content as a full raw replay at a + // fraction of the bytes. The mirror keeps its own local geometry + // either way (spec 0121 — a mirror never claims PTY size). + let snap = null; + try { + snap = await rpc("session.screen_snapshot", { + session_id: id, + strip_alt_screen: true, + }); + } catch (_) {} + if (snap && snap.size) { + await writeTermBytes(handle.term, decodeBase64ToBytes(snap.data || "")); + handle.snapshotHydrated = true; + handle.snapshotEndOffset = Number(snap.end_offset || 0); + handle.snapshotTruncated = !!snap.scrollback_truncated; + handle.ptyReplayStartOffset = Number(snap.start_offset || 0); + handle.ptyReplayEndOffset = Number(snap.end_offset || 0); + handle.ptyReplayTotalBytes = Number(snap.total_bytes || 0); + } else { + const r = await rpc("session.pty_replay", { session_id: id }); + if (r && r.data) { + await writeTermBytes(handle.term, decodeBase64ToBytes(r.data)); + } } handle.loaded = true; } catch (_) { diff --git a/crates/daemon/src/server.rs b/crates/daemon/src/server.rs index 0fb32bea..a9a2244c 100644 --- a/crates/daemon/src/server.rs +++ b/crates/daemon/src/server.rs @@ -12,6 +12,7 @@ use construct_protocol::{ PlaybookExecuteParams, PlaybookGetParams, PlaybookUpdateActor, PlaybookUpdateParams, PlaybookVerbExecuteParams, ProjectCreateParams, ProjectCreateResult, ProjectDeleteParams, ProjectMoveParams, ProjectRenameParams, ProjectSetCollapsedParams, PtyReplayParams, Request, + ScreenSnapshotParams, Response, RouterListRoutesParams, SearchParams, SessionAttachClipboardParams, SessionIdParams, SessionInputParams, SessionMoveParams, SessionPtyInputParams, SessionPtyResizeParams, SessionSetApprovalModeParams, SessionSetFocusedParams, SessionSetGroupParams, @@ -1669,6 +1670,16 @@ pub(crate) async fn dispatch( Err(e) => Response::err(req.id.clone(), ErrorObject::internal(e.to_string())), } }); + dispatch_entry!(ipc_method::SESSION_SCREEN_SNAPSHOT, { + let p = params!(req, ScreenSnapshotParams); + match manager + .screen_snapshot(&p.session_id, p.strip_alt_screen) + .await + { + Ok(r) => ok!(req, &r), + Err(e) => Response::err(req.id.clone(), ErrorObject::internal(e.to_string())), + } + }); dispatch_entry!(ipc_method::SESSION_SUGGEST, { let p = params!(req, SessionSuggestParams); match manager.request_suggestions(&p.session_id, p.keywords).await { diff --git a/crates/daemon/src/session.rs b/crates/daemon/src/session.rs index 257fad1b..c55dc07b 100644 --- a/crates/daemon/src/session.rs +++ b/crates/daemon/src/session.rs @@ -11686,6 +11686,90 @@ mod tests { ); } + #[tokio::test] + async fn screen_snapshot_reconstructs_screen_from_disk_history() { + use base64::Engine; + use tempfile::tempdir; + + let tmp = tempdir().expect("tempdir"); + let storage = + Arc::new(crate::storage::Storage::new(tmp.path().join("data")).expect("storage")); + let storage_handle = storage.clone(); + let config = Arc::new(crate::config::Config::default()); + let (mgr, _remote_rx, _restart_rx) = + SessionManager::new(storage, config, tmp.path().join("run")) + .await + .expect("session manager"); + + let id = "ssnap"; + let entry = synthetic_entry(id, construct_protocol::SessionKind::User, 0); + mgr.sessions.write().await.insert(id.into(), entry.clone()); + entry.pty.lock().await.size = Some(PtySize { cols: 40, rows: 5 }); + + let mut bytes = Vec::new(); + for i in 0..50 { + bytes.extend(format!("history line {i}\r\n").into_bytes()); + } + bytes.extend_from_slice(b"prompt> "); + storage_handle + .append_pty_bytes(id, &bytes) + .expect("append pty bytes"); + + let result = mgr + .screen_snapshot(id, false) + .await + .expect("screen_snapshot"); + assert_eq!(result.size, PtySize { cols: 40, rows: 5 }); + assert_eq!(result.start_offset, 0); + assert_eq!(result.end_offset, bytes.len() as u64); + assert_eq!(result.total_bytes, bytes.len() as u64); + assert_eq!(result.scrollback_rows, 46, "51 rendered rows on a 5-row screen"); + assert!(!result.scrollback_truncated); + + let decoded = base64::engine::general_purpose::STANDARD + .decode(&result.data) + .expect("base64 decode"); + let mut parser = vt100::Parser::new(5, 40, 100); + parser.process(&decoded); + let contents = parser.screen().contents(); + assert!( + contents.contains("history line 49") && contents.contains("prompt> "), + "snapshot must repaint the current screen, got: {contents:?}" + ); + assert_eq!(parser.screen().cursor_position(), (4, 8)); + parser.screen_mut().set_scrollback(usize::MAX); + assert_eq!(parser.screen().scrollback(), 46); + assert!( + parser.screen().contents().starts_with("history line 0"), + "oldest retained row must lead the rebuilt scrollback" + ); + } + + #[tokio::test] + async fn screen_snapshot_requires_known_pty_size() { + use tempfile::tempdir; + + // Without the child's real geometry a server-side render would + // wrap wrongly; the RPC must fail so clients fall back to raw + // pty_replay instead of showing a mis-wrapped screen. + let tmp = tempdir().expect("tempdir"); + let storage = + Arc::new(crate::storage::Storage::new(tmp.path().join("data")).expect("storage")); + let config = Arc::new(crate::config::Config::default()); + let (mgr, _remote_rx, _restart_rx) = + SessionManager::new(storage, config, tmp.path().join("run")) + .await + .expect("session manager"); + + let id = "ssnapnosize"; + mgr.sessions.write().await.insert( + id.into(), + synthetic_entry(id, construct_protocol::SessionKind::User, 0), + ); + + assert!(mgr.screen_snapshot(id, false).await.is_err()); + } + #[tokio::test] async fn delete_cascades_to_subagents() { use tempfile::tempdir; diff --git a/crates/daemon/src/session/pty.rs b/crates/daemon/src/session/pty.rs index e1076e85..1175e0f3 100644 --- a/crates/daemon/src/session/pty.rs +++ b/crates/daemon/src/session/pty.rs @@ -393,6 +393,47 @@ impl SessionManager { }) } + /// Render the session's terminal server-side and return it as a compact + /// escape-sequence stream (spec 0188): a bounded `pty.log` tail is fed + /// through a native vt100 parser at the session's PTY size, then the + /// parser's scrollback and visible screen are serialized. A client + /// writes the result into a freshly reset terminal of the same size and + /// is caught up in O(screen + scrollback) bytes instead of replaying + /// the raw history through its own emulator. + pub async fn screen_snapshot( + &self, + id: &str, + strip_alt_screen: bool, + ) -> Result { + use base64::Engine; + let entry = self + .get_entry(id) + .await + .ok_or_else(|| anyhow!("session not found: {}", id))?; + let size = entry.pty.lock().await.size.ok_or_else(|| { + // Without the child's real geometry the render would wrap + // wrongly; callers fall back to `session.pty_replay`. + anyhow!("session {} has no known pty size", id) + })?; + let (bytes, start_offset, end_offset, total_bytes) = self + .storage + .read_pty_range_before(id, SCREEN_SNAPSHOT_REPLAY_BYTES, None) + .unwrap_or_else(|e| { + tracing::warn!(session = %id, error = ?e, "pty_log range read failed"); + (Vec::new(), 0, 0, 0) + }); + let rendered = render_screen_snapshot(&bytes, size, strip_alt_screen); + Ok(construct_protocol::ScreenSnapshotResult { + data: base64::engine::general_purpose::STANDARD.encode(rendered.data), + scrollback_rows: rendered.scrollback_rows as u64, + scrollback_truncated: rendered.scrollback_truncated, + start_offset, + end_offset, + total_bytes, + size, + }) + } + /// Deliver a prompt as a bracketed paste (`ESC[200~` … `ESC[201~`) when /// submitting to external PTY-backed agents. pub(super) async fn playbook_submit_typed_prompt(&self, id: &str, prompt: &str) -> Result<()> { @@ -524,6 +565,116 @@ async fn deliver_pty_input(entry: &Arc, bytes: &[u8]) -> Result<() Ok(()) } +/// Bytes of `pty.log` tail parsed to build a screen snapshot. Only feeds +/// the server-side parser — none of it goes over the wire — so it just +/// needs to comfortably cover the visible screen plus the scrollback row +/// budget below. Kept well under `PTY_REPLAY_CAP`: a client that pages +/// into older history re-fetches this span as raw bytes, so the span +/// bounds that fallback's cost too. +const SCREEN_SNAPSHOT_REPLAY_BYTES: usize = 1024 * 1024; +/// Scrollback rows the snapshot parser retains and serializes. Bounds the +/// transient parser memory and the snapshot payload; rows beyond it are +/// reported as truncated and remain reachable through `session.pty_replay`. +const SCREEN_SNAPSHOT_SCROLLBACK_ROWS: usize = 4000; + +pub(crate) struct RenderedScreenSnapshot { + pub data: Vec, + pub scrollback_rows: usize, + pub scrollback_truncated: bool, +} + +/// Build the escape-sequence stream for [`SessionManager::screen_snapshot`] +/// from a raw PTY byte tail. The stream assumes a freshly reset terminal of +/// `size`: it flows the parser's scrollback rows first, feeds line feeds +/// until they have all scrolled into the client's scrollback buffer (the +/// screen repaint clears the visible area without saving it), then repaints +/// the visible screen and restores cursor, attributes, scroll region, and +/// input modes. +pub(crate) fn render_screen_snapshot( + bytes: &[u8], + size: construct_protocol::PtySize, + strip_alt_screen: bool, +) -> RenderedScreenSnapshot { + let rows = size.rows.max(1); + let cols = size.cols.max(1); + let stripped; + let src: &[u8] = if strip_alt_screen { + stripped = strip_alt_screen_sequences(bytes); + &stripped + } else { + bytes + }; + let mut parser = vt100::Parser::new(rows, cols, SCREEN_SNAPSHOT_SCROLLBACK_ROWS); + parser.process(src); + let screen = parser.screen(); + let mut data = Vec::new(); + let scrollback_rows = screen.scrollback_contents_formatted(&mut data); + if scrollback_rows > 0 { + // `rows - 1` line feeds push every prepended row off the visible + // screen whether there were fewer of them than the screen height + // (some feeds just walk the cursor down to the bottom row first) + // or more (the cursor is already on the bottom row and every feed + // scrolls). No blank line ever reaches the top before the feeds + // stop, so exactly the prepended rows land in scrollback. + data.extend(std::iter::repeat(b'\n').take(usize::from(rows) - 1)); + } + data.extend(screen.contents_formatted()); + // `contents_formatted` restores contents, cursor, and attributes but + // not the scroll region or origin mode, and live deltas following the + // snapshot may depend on both. DECSTBM and DECOM home the cursor, so + // re-restore its position afterwards (region-relative under DECOM). + let (top, bottom) = screen.scroll_region(); + let origin = screen.origin_mode(); + if (top, bottom) != (0, rows - 1) || origin { + data.extend(format!("\x1b[{};{}r", top + 1, bottom + 1).into_bytes()); + if origin { + data.extend_from_slice(b"\x1b[?6h"); + } + let (cur_row, cur_col) = screen.cursor_position(); + let cur_row = if origin { + cur_row.saturating_sub(top) + } else { + cur_row + }; + data.extend(format!("\x1b[{};{}H", cur_row + 1, cur_col + 1).into_bytes()); + } + data.extend(screen.input_mode_formatted()); + RenderedScreenSnapshot { + data, + scrollback_rows, + scrollback_truncated: scrollback_rows >= SCREEN_SNAPSHOT_SCROLLBACK_ROWS, + } +} + +/// Remove alternate-screen enter/exit sequences (`ESC[?1049h/l`, +/// `ESC[?1047h/l`, `ESC[?47h/l`) from a PTY byte stream — the same filter +/// the web UI applies to every byte it writes into xterm.js. +pub(crate) fn strip_alt_screen_sequences(bytes: &[u8]) -> Vec { + const SEQS: [&[u8]; 6] = [ + b"\x1b[?1049h", + b"\x1b[?1049l", + b"\x1b[?1047h", + b"\x1b[?1047l", + b"\x1b[?47h", + b"\x1b[?47l", + ]; + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + 'outer: while i < bytes.len() { + if bytes[i] == 0x1b { + for seq in SEQS { + if bytes[i..].starts_with(seq) { + i += seq.len(); + continue 'outer; + } + } + } + out.push(bytes[i]); + i += 1; + } + out +} + #[cfg(test)] pub(super) fn pty_caps() -> construct_protocol::Capabilities { construct_protocol::Capabilities { @@ -532,3 +683,126 @@ pub(super) fn pty_caps() -> construct_protocol::Capabilities { ..Default::default() } } + +#[cfg(test)] +mod screen_snapshot_tests { + use super::*; + use construct_protocol::PtySize; + + /// Feed a byte stream into a fresh parser the way a client terminal + /// of the same geometry would consume it. + fn parse(data: &[u8], size: PtySize) -> vt100::Parser { + let mut p = vt100::Parser::new(size.rows, size.cols, SCREEN_SNAPSHOT_SCROLLBACK_ROWS); + p.process(data); + p + } + + /// Plain-text contents of the view scrolled all the way back, plus the + /// scrollback depth — the two things a snapshot must reproduce beyond + /// the visible screen. + fn top_view(parser: &mut vt100::Parser) -> (usize, String) { + parser.screen_mut().set_scrollback(usize::MAX); + let depth = parser.screen().scrollback(); + let contents = parser.screen().contents(); + parser.screen_mut().set_scrollback(0); + (depth, contents) + } + + #[test] + fn snapshot_round_trips_screen_scrollback_and_colors() { + let size = PtySize { cols: 20, rows: 4 }; + let mut input = Vec::new(); + for i in 0..10 { + input.extend(format!("\x1b[3{}mline{i:02}\x1b[m", i % 8).into_bytes()); + if i < 9 { + input.extend_from_slice(b"\r\n"); + } + } + let mut orig = parse(&input, size); + let rendered = render_screen_snapshot(&input, size, false); + let mut rep = parse(&rendered.data, size); + + assert_eq!(rendered.scrollback_rows, 6, "10 lines on a 4-row screen"); + assert!(!rendered.scrollback_truncated); + assert_eq!(rep.screen().contents(), orig.screen().contents()); + // Formatted comparison covers colors/attributes, not just text. + assert_eq!( + rep.screen().contents_formatted(), + orig.screen().contents_formatted() + ); + assert_eq!( + rep.screen().cursor_position(), + orig.screen().cursor_position() + ); + assert_eq!(top_view(&mut rep), top_view(&mut orig)); + } + + #[test] + fn snapshot_preserves_soft_wrapped_scrollback_rows() { + let size = PtySize { cols: 10, rows: 3 }; + // A 25-char line soft-wraps across three rows, then enough hard + // lines push it entirely into scrollback. + let mut input = b"ABCDEFGHIJ0123456789abcde\r\n".to_vec(); + for i in 0..4 { + input.extend(format!("tail{i}\r\n").into_bytes()); + } + input.extend_from_slice(b"end"); + let mut orig = parse(&input, size); + let rendered = render_screen_snapshot(&input, size, false); + let mut rep = parse(&rendered.data, size); + + assert_eq!(rep.screen().contents(), orig.screen().contents()); + assert_eq!(top_view(&mut rep), top_view(&mut orig)); + } + + #[test] + fn snapshot_strip_alt_screen_paints_alt_content_on_primary() { + let size = PtySize { cols: 20, rows: 4 }; + let mut input = b"before\r\n".to_vec(); + input.extend_from_slice(b"\x1b[?1049h\x1b[2J\x1b[HALTUI"); + let rendered = render_screen_snapshot(&input, size, true); + let rep = parse(&rendered.data, size); + + assert!(rep.screen().contents().contains("ALTUI")); + assert!(!rep.screen().alternate_screen()); + assert!( + !rendered + .data + .windows(b"\x1b[?1049h".len()) + .any(|w| w == b"\x1b[?1049h"), + "stripped snapshot must not smuggle alt-screen switches back in" + ); + } + + #[test] + fn snapshot_restores_scroll_region_and_cursor() { + let size = PtySize { cols: 20, rows: 6 }; + let input = b"hello\x1b[2;5r\x1b[3;4H".to_vec(); + let rendered = render_screen_snapshot(&input, size, false); + let rep = parse(&rendered.data, size); + + assert_eq!(rep.screen().scroll_region(), (1, 4)); + assert_eq!(rep.screen().cursor_position(), (2, 3)); + } + + #[test] + fn snapshot_reports_scrollback_truncation() { + let size = PtySize { cols: 20, rows: 4 }; + let mut input = Vec::new(); + for i in 0..(SCREEN_SNAPSHOT_SCROLLBACK_ROWS + 100) { + input.extend(format!("row {i}\r\n").into_bytes()); + } + let rendered = render_screen_snapshot(&input, size, false); + assert_eq!(rendered.scrollback_rows, SCREEN_SNAPSHOT_SCROLLBACK_ROWS); + assert!(rendered.scrollback_truncated); + } + + #[test] + fn strip_alt_screen_sequences_removes_only_switches() { + let input = b"a\x1b[?1049hb\x1b[31mc\x1b[?47ld\x1b[?1047h".to_vec(); + assert_eq!( + strip_alt_screen_sequences(&input), + b"ab\x1b[31mcd".to_vec() + ); + } +} diff --git a/crates/e2e/tests/web_smoke.rs b/crates/e2e/tests/web_smoke.rs index 89f76ac4..4bd61bb8 100644 --- a/crates/e2e/tests/web_smoke.rs +++ b/crates/e2e/tests/web_smoke.rs @@ -384,6 +384,144 @@ async fn web_client_loads_and_websocket_connects() { "historical terminal queries must not generate live PTY input: {fast_open:?}" ); + // When the daemon offers session.screen_snapshot, terminal open hydrates + // from the rendered screen without fetching raw history at all; the first + // "load older" leaves snapshot mode by re-fetching the rendered span plus + // one page of raw bytes. + let snapshot_open: serde_json::Value = page + .evaluate( + r#" + (async () => { + const saved = { + sessions: state.sessions, + currentId: state.currentId, + mode: state.mode, + ws: state.ws, + terminalById: state.terminalById, + term: state.term, + fitAddon: state.fitAddon, + }; + const calls = []; + const snapText = 'old scrollback A\r\nold scrollback B\r\n' + '\n'.repeat(23) + '\x1b[H\x1b[Jsnapshot screen line'; + const tick = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + try { + state.sessions = [{ + id: 's-snap-pty', + cwd: '/tmp', + harness: 'shell', + has_pty: true, + kind: 'user', + }]; + state.currentId = 's-snap-pty'; + state.terminalById = new Map(); + state.ws = { + readyState: 1, + send(raw) { + const msg = JSON.parse(raw); + calls.push(msg); + const pending = state.pending.get(msg.id); + state.pending.delete(msg.id); + let result = null; + if (msg.method === 'session.screen_snapshot') { + result = { + data: btoa(snapText), + scrollback_rows: 2, + scrollback_truncated: false, + start_offset: 1048576, + end_offset: 1179648, + total_bytes: 1179648, + size: { cols: 80, rows: 24 }, + }; + } else if (msg.method === 'session.pty_replay') { + result = { + data: btoa('raw history\r\n'), + start_offset: 0, + end_offset: msg.params.before_offset || 0, + total_bytes: 1179648, + size: { cols: 80, rows: 24 }, + }; + } else if (msg.method === 'session.pty_resize' || msg.method === 'session.pty_input') { + result = {}; + } + queueMicrotask(() => pending.resolve(result)); + }, + }; + await enterTerminalMode('s-snap-pty', { forceReload: true }); + const handle = terminalHandleForSession('s-snap-pty'); + const snapshotCalls = calls.filter((c) => c.method === 'session.screen_snapshot'); + const replayCallsDuringOpen = calls.filter((c) => c.method === 'session.pty_replay').length; + const buffer = handle.term.buffer.active; + let text = ''; + for (let i = 0; i < buffer.length; i++) { + text += buffer.getLine(i).translateToString(true) + '\n'; + } + const historyVisibleAfterOpen = !terminalHistoryBtn.hidden; + const snapshotHydratedAfterOpen = !!handle.snapshotHydrated; + await maybeLoadOlderPtyReplay('s-snap-pty', { force: true }); + const olderCalls = calls.filter((c) => c.method === 'session.pty_replay'); + return { + snapshotCalls: snapshotCalls.map((c) => c.params), + replayCallsDuringOpen, + text, + cols: handle.term.cols, + rows: handle.term.rows, + historyVisibleAfterOpen, + snapshotHydratedAfterOpen, + olderCalls: olderCalls.map((c) => c.params), + snapshotHydratedAfterOlder: !!handle.snapshotHydrated, + }; + } finally { + state.sessions = saved.sessions; + state.currentId = saved.currentId; + state.mode = saved.mode; + state.ws = saved.ws; + state.terminalById = saved.terminalById; + state.term = saved.term; + state.fitAddon = saved.fitAddon; + terminalHistoryBtn.hidden = true; + hideTerminalLoading(); + } + })() + "#, + ) + .await + .expect("evaluate terminal snapshot-open") + .into_value() + .expect("json value"); + assert_eq!( + snapshot_open["snapshotCalls"][0]["strip_alt_screen"], true, + "web client must request an alt-screen-stripped render: {snapshot_open:?}" + ); + assert_eq!( + snapshot_open["replayCallsDuringOpen"], 0, + "snapshot hydration must not fetch raw history: {snapshot_open:?}" + ); + let text = snapshot_open["text"].as_str().unwrap_or(""); + assert!( + text.contains("snapshot screen line") && text.contains("old scrollback A"), + "snapshot bytes must paint screen and scrollback: {snapshot_open:?}" + ); + assert_eq!(snapshot_open["cols"], 80, "{snapshot_open:?}"); + assert_eq!(snapshot_open["rows"], 24, "{snapshot_open:?}"); + assert_eq!( + snapshot_open["historyVisibleAfterOpen"], true, + "start_offset > 0 means older history exists: {snapshot_open:?}" + ); + assert_eq!(snapshot_open["snapshotHydratedAfterOpen"], true, "{snapshot_open:?}"); + assert_eq!( + snapshot_open["olderCalls"][0]["before_offset"], 1179648, + "leaving snapshot mode must re-fetch from the rendered span's end: {snapshot_open:?}" + ); + assert_eq!( + snapshot_open["olderCalls"][0]["max_bytes"], + (1179648 - 1048576) + 64 * 1024, + "first raw page must cover the rendered span plus one page: {snapshot_open:?}" + ); + assert_eq!( + snapshot_open["snapshotHydratedAfterOlder"], false, + "load-older must leave snapshot mode: {snapshot_open:?}" + ); + // Claude Code's full-screen mode enters the terminal alternate screen // (`ESC[?1049h`). xterm.js treats that buffer as having no scrollback, so // the web client strips those toggles before writing PTY bytes. The live diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 4ff9aa3c..117f40e1 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -1283,6 +1283,12 @@ pub mod ipc_method { pub const SESSION_PTY_INPUT: &str = "session.pty_input"; pub const SESSION_PTY_RESIZE: &str = "session.pty_resize"; pub const SESSION_PTY_REPLAY: &str = "session.pty_replay"; + /// Render the session's current terminal screen server-side and return + /// it as a compact escape-sequence stream (scrollback rows + visible + /// screen repaint) instead of raw `pty.log` history. Lets a client + /// attach in O(screen + retained scrollback) rather than replaying the + /// full byte history through its own emulator. + pub const SESSION_SCREEN_SNAPSHOT: &str = "session.screen_snapshot"; pub const SESSION_INTERRUPT: &str = "session.interrupt"; pub const SESSION_STOP: &str = "session.stop"; pub const SESSION_KILL: &str = "session.kill"; @@ -3234,6 +3240,52 @@ pub struct PtyReplayParams { pub before_offset: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScreenSnapshotParams { + pub session_id: String, + /// Strip alternate-screen enter/exit sequences from the PTY history + /// before rendering, mirroring clients (the web UI) that filter those + /// sequences out of everything they feed their terminal. The rendered + /// screen then matches what such a client would have shown after a + /// full replay: alternate-screen apps painted onto the primary screen, + /// with the pre-alt scrollback intact. + #[serde(default)] + pub strip_alt_screen: bool, +} + +/// Result of `session.screen_snapshot`: a rendered reconstruction of the +/// session's terminal, produced by replaying a bounded tail of `pty.log` +/// through a server-side vt100 parser at the session's PTY size. Written +/// into a freshly reset client terminal of the same size, `data` first +/// flows `scrollback_rows` rows of history into the client's scrollback, +/// then repaints the visible screen and restores cursor, attributes, +/// scroll region, and input modes. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScreenSnapshotResult { + /// Base64-encoded escape-sequence stream reproducing the terminal. + pub data: String, + /// Rows of scrollback included ahead of the visible-screen repaint. + pub scrollback_rows: u64, + /// True when the parser's scrollback budget overflowed: rows older + /// than the included ones existed in the rendered tail but were + /// dropped. Older history is still reachable via `session.pty_replay`. + #[serde(default)] + pub scrollback_truncated: bool, + /// Absolute `pty.log` offset where the rendered tail began. Non-zero + /// means older raw history exists beyond what the snapshot rendered. + #[serde(default)] + pub start_offset: u64, + /// Absolute `pty.log` offset where the rendered tail ended. + #[serde(default)] + pub end_offset: u64, + /// Total byte length of `pty.log` at render time. + #[serde(default)] + pub total_bytes: u64, + /// PTY size the snapshot was rendered at. Clients must size their + /// terminal to this before writing `data`. + pub size: PtySize, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionDetail { pub summary: SessionSummary, diff --git a/specs/0188-attach-renders-a-screen-not-a-replay.md b/specs/0188-attach-renders-a-screen-not-a-replay.md new file mode 100644 index 00000000..4878d5ec --- /dev/null +++ b/specs/0188-attach-renders-a-screen-not-a-replay.md @@ -0,0 +1,86 @@ +# 0188-attach-renders-a-screen-not-a-replay + +Status: accepted +Date: 2026-08-03 +Area: protocol +Scope: Clients that cannot hide a raw-history replay attach to a PTY session via a server-rendered screen snapshot instead. + +## Decision + +The daemon can render a PTY session's terminal server-side — by feeding a +bounded tail of the session's persisted PTY history through its own native +terminal emulator at the session's real geometry — and serve the result as +a compact escape-sequence stream: scrollback rows first, then a +visible-screen repaint, then cursor position, attributes, scroll region, +and input modes. A client writes that stream once into a freshly reset +terminal of the same size and is caught up. + +Clients whose terminal renders progressively while consuming bytes (the +web UI's xterm.js) attach through this snapshot rather than replaying raw +history. Raw history stays available through the byte-range replay RPC: +paging into history older than the snapshot re-fetches the span the +snapshot rendered plus an older page and rebuilds from raw bytes. + +The snapshot can be rendered with alternate-screen switch sequences +stripped from the history first, for clients that apply the same filter to +every byte they feed their terminal. The render must mirror the client's +filter so snapshot-then-live and replay-then-live converge on the same +screen. + +A snapshot is only rendered at the session's last known PTY geometry. +When the daemon does not know the child's size, the RPC fails and the +client falls back to raw replay — a snapshot rendered at a guessed width +would wrap every line wrongly, which is worse than a visible replay. + +## Reason + +Raw-history attach cost scales with how much the session has ever printed; +snapshot attach cost scales with the screen and retained scrollback. The +native TUI hides its replay because it parses bytes into an in-memory grid +and paints one frame; xterm.js parses and paints incrementally, so the +same replay is visible to the user and was papered over with loading +overlays and offscreen hydration. Rendering server-side uses the same +trick the TUI uses — parse invisibly, paint once — and additionally stops +shipping megabytes of history to remote/mobile clients that only need the +current screen. + +## Consequences + +- The scrollback a snapshot carries is bounded. The result must say when + rows were dropped so the client can still offer older history, and raw + byte-range replay must remain a supported attach path (fallback for old + daemons, unknown geometry, and history paging). +- A snapshot is a reconstruction, not the child's authoritative screen — + the same trust level as a client-side replay of the same bytes. The + existing post-attach force-redraw (the resize bump that makes the child + repaint) remains the authority and must stay in place. +- The serialized stream must stay valid to feed a stock terminal emulator + at the stated size with no client-side interpretation: plain escape + sequences, scrollback flowed so the receiving terminal retains it, + soft-wrapped rows left to the receiving terminal's autowrap so reflow + and selection keep working. +- The server-side emulator's fidelity bounds snapshot fidelity: state the + upstream dump omits (scroll region, origin mode) must be restored + explicitly, and future terminal features consumed by harness TUIs may + need the same treatment. + +## Non-Goals + +- Replacing the byte-oriented replay RPC. History paging, previews at + arbitrary sizes, and clients with invisible replay (the native TUI) keep + using raw bytes. +- Guaranteeing pixel-perfect equivalence with a full replay. The snapshot + trades unbounded-history fidelity for bounded attach cost; the child's + own repaint is the corrector. + +## Examples + +- Opening a long-running session in the web UI writes one small stream + into xterm: the screen appears fully formed, with recent scrollback + above it, and no loading progression is visible. +- Scrolling to the top and requesting older history converts the terminal + to raw-replay mode: the client re-fetches the byte span the snapshot + covered plus one older page and rebuilds, after which further paging + extends normally. +- A session whose child never reported a size attaches the old way: raw + replay, loading overlay, offscreen hydration. diff --git a/vendor/vt100/src/grid.rs b/vendor/vt100/src/grid.rs index 9ea23727..5e7619e5 100644 --- a/vendor/vt100/src/grid.rs +++ b/vendor/vt100/src/grid.rs @@ -214,6 +214,73 @@ impl Grid { } } + // agentd fork addition: write the scrollback buffer (rows that have + // scrolled off the top of the visible screen), oldest first, as a + // byte stream that reproduces those rows — colors and attributes + // included — when written to a reset terminal of the same width. + // Rows are emitted flow-style: hard row breaks as CRLF, soft-wrapped + // rows left to the terminal's own autowrap so reflow and selection + // keep working in the receiving terminal. Unlike + // `write_contents_formatted` this never emits absolute cursor + // positioning, so the stream is valid at any scroll position; the + // intended use is seeding a client terminal's scrollback before + // repainting the visible screen on attach. The stream ends on a + // fresh line with default attributes. Returns the number of + // scrollback rows written. + pub fn write_scrollback_contents_formatted( + &self, + contents: &mut Vec, + ) -> usize { + let mut prev_attrs = crate::attrs::Attrs::default(); + let mut prev_pos = Pos::default(); + let mut wrapping = false; + // Synthetic row index, consistent with the positions passed to the + // row writer: 0 for a row starting after a hard break, previous + // index + 1 for a soft-wrap continuation. Row-to-row movement is + // then only ever expressed as the writer's wrap-continuation + // handling or our explicit CRLF, never absolute positioning. + let mut row_idx: u16 = 0; + for (i, row) in self.scrollback.iter().enumerate() { + if i > 0 { + if wrapping { + row_idx = row_idx.saturating_add(1); + } else { + crate::term::Crlf.write_buf(contents); + row_idx = 0; + prev_pos = Pos { row: 0, col: 0 }; + } + } + let (pos, attrs) = row.write_contents_formatted( + contents, + 0, + self.size.cols, + row_idx, + wrapping, + Some(prev_pos), + Some(prev_attrs), + ); + prev_pos = pos; + prev_attrs = attrs; + wrapping = row.wrapped(); + } + if !self.scrollback.is_empty() { + crate::term::Crlf.write_buf(contents); + crate::term::ClearAttrs.write_buf(contents); + } + self.scrollback.len() + } + + // agentd fork addition: expose the scroll region and origin mode so a + // consumer serializing terminal state can restore them (the upstream + // `contents_formatted` dump does not include either). + pub fn scroll_region(&self) -> (u16, u16) { + (self.scroll_top, self.scroll_bottom) + } + + pub fn origin_mode(&self) -> bool { + self.origin_mode + } + pub fn write_contents_formatted( &self, contents: &mut Vec, diff --git a/vendor/vt100/src/screen.rs b/vendor/vt100/src/screen.rs index 7dfec97d..3dd02371 100644 --- a/vendor/vt100/src/screen.rs +++ b/vendor/vt100/src/screen.rs @@ -216,6 +216,32 @@ impl Screen { } } + /// agentd fork addition: write the primary screen's scrollback buffer + /// (rows scrolled off the top of the visible screen, oldest first) as + /// a formatted byte stream — see + /// `Grid::write_scrollback_contents_formatted`. Always reads the + /// primary grid's scrollback: the alternate screen has none. + /// Returns the number of scrollback rows written. + pub fn scrollback_contents_formatted( + &self, + contents: &mut Vec, + ) -> usize { + self.grid.write_scrollback_contents_formatted(contents) + } + + /// agentd fork addition: the current scroll region as 0-based + /// inclusive (top, bottom) rows. `(0, rows - 1)` when unset. + #[must_use] + pub fn scroll_region(&self) -> (u16, u16) { + self.grid().scroll_region() + } + + /// agentd fork addition: whether origin mode (DECOM) is enabled. + #[must_use] + pub fn origin_mode(&self) -> bool { + self.grid().origin_mode() + } + /// Return escape codes sufficient to reproduce the entire contents of the /// current terminal state. This is a convenience wrapper around /// [`contents_formatted`](Self::contents_formatted) and