From f2c0eaea582ebaf4163656c5dc8fa3d19051a2a4 Mon Sep 17 00:00:00 2001 From: Edwin Date: Mon, 3 Aug 2026 21:13:38 -0700 Subject: [PATCH] Attach webui terminals via server-rendered screen snapshots New session.screen_snapshot RPC: the daemon feeds a bounded pty.log tail through its own native vt100 parser at the session's PTY size and returns just scrollback rows + a visible-screen repaint as escape sequences, plus cursor, attributes, scroll region, and input modes. The web UI hydrates terminals (main view and both mirror paths) from that stream in one xterm.write instead of replaying raw history, so attach cost is O(screen + scrollback) and nothing visibly replays. Raw pty_replay stays for older daemons, sessions with unknown geometry, and history paging: the first "load older" re-fetches the rendered span plus one page and rebuilds classically. The vendored vt100 fork grows a scrollback serializer (flow-style, no absolute positioning, soft wraps preserved) and scroll-region/origin-mode accessors the upstream state dump lacks. Spec 0188 records the decision. --- Cargo.lock | 1 + crates/client/src/lib.rs | 19 ++ crates/daemon/Cargo.toml | 1 + crates/daemon/assets/index.html | 177 +++++++++-- crates/daemon/src/server.rs | 11 + crates/daemon/src/session.rs | 84 ++++++ crates/daemon/src/session/pty.rs | 274 ++++++++++++++++++ crates/e2e/tests/web_smoke.rs | 138 +++++++++ crates/protocol/src/lib.rs | 52 ++++ ...88-attach-renders-a-screen-not-a-replay.md | 86 ++++++ vendor/vt100/src/grid.rs | 67 +++++ vendor/vt100/src/screen.rs | 26 ++ 12 files changed, 909 insertions(+), 27 deletions(-) create mode 100644 specs/0188-attach-renders-a-screen-not-a-replay.md 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