Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions crates/client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<construct_protocol::ScreenSnapshotResult> {
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(
Expand Down
1 change: 1 addition & 0 deletions crates/daemon/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
177 changes: 150 additions & 27 deletions crates/daemon/assets/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11589,6 +11589,9 @@ <h2 id="serviceViewTitle"></h2>
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;
Expand Down Expand Up @@ -11679,19 +11682,42 @@ <h2 id="serviceViewTitle"></h2>
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();
Expand Down Expand Up @@ -11972,6 +11998,13 @@ <h2 id="serviceViewTitle"></h2>
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,
Expand Down Expand Up @@ -12145,6 +12178,36 @@ <h2 id="serviceViewTitle"></h2>
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);
Expand Down Expand Up @@ -12189,17 +12252,26 @@ <h2 id="serviceViewTitle"></h2>
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;
Expand All @@ -12216,17 +12288,34 @@ <h2 id="serviceViewTitle"></h2>
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));
Expand Down Expand Up @@ -13923,10 +14012,24 @@ <h2 id="serviceViewTitle"></h2>
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 (_) {
Expand Down Expand Up @@ -14112,9 +14215,29 @@ <h2 id="serviceViewTitle"></h2>
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 (_) {
Expand Down
11 changes: 11 additions & 0 deletions crates/daemon/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading