diff --git a/src/renderer/src/components/orca-cloud/offline/CloudWakeScrollback.fixture.tsx b/src/renderer/src/components/orca-cloud/offline/CloudWakeScrollback.fixture.tsx new file mode 100644 index 00000000000..ee96358bd09 --- /dev/null +++ b/src/renderer/src/components/orca-cloud/offline/CloudWakeScrollback.fixture.tsx @@ -0,0 +1,133 @@ +// WAKE-SCROLLBACK: a real xterm after Sleep → Wake. Each fixture writes the pre-sleep output, runs +// the actual capture, the woken terminal's snapshot (it clears the pane) and the restore after it, +// so what shows is what the pane shows. +import React, { useEffect, useRef } from 'react' +import { Terminal } from '@xterm/xterm' +import '@xterm/xterm/css/xterm.css' +import { defineFixture } from '../../../component-fixtures/component-fixture' +import { + armWakeScrollback, + beginWakeScrollbackRestore, + captureWakeScrollback, + finishWakeScrollbackRestore +} from '@/lib/orca-cloud/cloud-wake-scrollback' + +const SLEPT_AT = new Date(2026, 10, 11, 14, 2).getTime() +const WOKE_AT = new Date(2026, 10, 11, 14, 31).getTime() +const SNAPSHOT_CLEAR = '\x1b[2J\x1b[3J\x1b[H' + +function write(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +type Sleep = { before: string; agentPane?: boolean; after: string; sleptAt?: number } + +function WokeTerminal({ id, sleeps }: { id: string; sleeps: Sleep[] }): React.JSX.Element { + const host = useRef(null) + useEffect(() => { + if (!host.current) { + return + } + const dark = document.documentElement.classList.contains('dark') + const terminal = new Terminal({ + cols: 72, + rows: 12, + fontSize: 12, + fontFamily: 'Menlo, monospace', + theme: dark + ? { background: '#0a0a0a', foreground: '#e5e5e5', cursor: '#e5e5e5' } + : { background: '#ffffff', foreground: '#1f2328', cursor: '#1f2328' } + }) + terminal.open(host.current) + void (async () => { + for (const [index, sleep] of sleeps.entries()) { + await write(terminal, sleep.before) + captureWakeScrollback(id, terminal, { + sleptAt: sleep.sleptAt ?? SLEPT_AT + index * 7_200_000, + agentPane: sleep.agentPane ?? false, + maxRows: 5000 + }) + armWakeScrollback([id]) + const restore = beginWakeScrollbackRestore(id, terminal, WOKE_AT + index * 7_200_000) + await write(terminal, `${SNAPSHOT_CLEAR}${sleep.after}`) + if (restore) { + await finishWakeScrollbackRestore(restore, terminal, (data) => write(terminal, data)) + } + } + terminal.scrollToBottom() + })() + return () => terminal.dispose() + }, [id, sleeps]) + return ( +
+
+
+ ) +} + +const PROMPT = '\x1b[32mubuntu@sbx\x1b[0m:\x1b[34m~/hello-world\x1b[0m$ ' +const testRun = [ + `${PROMPT}pnpm test`, + '', + ' \x1b[32m✓\x1b[0m src/util.test.ts \x1b[2m(4 tests)\x1b[0m', + ' \x1b[32m✓\x1b[0m src/logging.test.ts \x1b[2m(2 tests)\x1b[0m', + ' \x1b[31m✗\x1b[0m src/server.test.ts \x1b[2m(1 failed)\x1b[0m', + '', + ' \x1b[1mTest Files\x1b[0m \x1b[31m1 failed\x1b[0m | \x1b[32m2 passed\x1b[0m', + `${PROMPT}sleep 900`, + '' +].join('\r\n') + +/** A shell pane: its output (in its own colours) above the divider, the woken prompt below. */ +export const shellPaneRestored = defineFixture({ + width: 720, + render: () => +}) + +/** Two sleeps: one divider each, in order. */ +export const twoSleeps = defineFixture({ + width: 720, + render: () => ( + + ) +}) + +/** An agent pane: only the scrollback above Claude's screen; never its frozen input box. */ +export const agentPaneScrollbackOnly = defineFixture({ + width: 720, + render: () => ( + ''), + '╭──────────────────────────────────────────╮', + '│ > Try "create a util logging.py that..." │', + '╰──────────────────────────────────────────╯', + ' ? for shortcuts' + ].join('\r\n'), + agentPane: true, + // Fixture stand-in for the relaunched Claude's own screen (the app writes no such text). + after: [ + '╭───────────────────────────────╮', + '│ ✻ Welcome back to Claude Code │', + '╰───────────────────────────────╯', + '> ' + ].join('\r\n') + } + ]} + /> + ) +}) diff --git a/src/renderer/src/components/terminal-pane/layout/terminal-pane-layout-restore.ts b/src/renderer/src/components/terminal-pane/layout/terminal-pane-layout-restore.ts index 09518d91f8d..e7bf0c9df4b 100644 --- a/src/renderer/src/components/terminal-pane/layout/terminal-pane-layout-restore.ts +++ b/src/renderer/src/components/terminal-pane/layout/terminal-pane-layout-restore.ts @@ -1,5 +1,6 @@ import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { useAppStore } from '@/store/index' +import { wakeScrollbackPreview } from '@/lib/orca-cloud/cloud-wake-scrollback' import { applyExpandedLayoutTo } from './expand/expanded-pane-layout' import { replayTerminalLayout } from './terminal-layout-replay' import { restoreScrollbackBuffers } from '../snapshots/saved-terminal-output-replay' @@ -48,6 +49,15 @@ export function restoreTerminalPaneLayout(args: { refs.restoredViewportBlankingPanesRef, liveAltScreenLeafIds ) + // WAKE-SCROLLBACK: waking remounts a cloud pane with a fresh, empty xterm; paint its kept pre-sleep + // output now (client-side, text + SGR) so it never shows blank before the woken snapshot's restore. + for (const pane of manager.getPanes()) { + // A pane whose saved output was just replayed already shows it; a second copy would double it. + const preview = restoredBuffers?.[pane.leafId] ? null : wakeScrollbackPreview(pane.leafId) + if (preview !== null) { + pane.terminal.write(preview) + } + } const hasScrollbackRefs = Boolean(initialLayoutRef.current.scrollbackRefsByLeafId) if ( restoredBuffers && diff --git a/src/renderer/src/components/terminal-pane/pty-connection/output/fresh-spawn-follow-reset.ts b/src/renderer/src/components/terminal-pane/pty-connection/output/fresh-spawn-follow-reset.ts index 41213984226..f780daf7c3f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/output/fresh-spawn-follow-reset.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/output/fresh-spawn-follow-reset.ts @@ -1,4 +1,8 @@ import { replayIntoTerminal, replayIntoTerminalAsync } from '../../snapshots/replay-guard' +import { + HOLD_RENDER_UNTIL_RESTORED, + hasPendingWakeScrollback +} from '@/lib/orca-cloud/cloud-wake-scrollback' import { terminalOutputPrefersRenderRefresh } from '@/lib/pane-manager/terminal-complex-script' import { buildPostReplayLiveAgentReattachReset, @@ -186,6 +190,12 @@ export function bindFreshSpawnFollowReset(session: ConnectPanePtySession): void if (!options.forceBlankRestoredViewport && !hadRestoredViewport) { return } + // WAKE-SCROLLBACK: a woken cloud pane keeps showing its pre-sleep output (render held) until its + // restore rewrites the whole pane; blanking here is the blank frame before it. + if (hasPendingWakeScrollback(session.pane.leafId)) { + session.writeReplayData(HOLD_RENDER_UNTIL_RESTORED) + return + } // Move restored rows into scrollback before coordinate-based Windows ConPTY prompt output. session.writeFreshShellViewportBlanking() } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/output/fresh-spawn-wake-scrollback.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/output/fresh-spawn-wake-scrollback.test.ts new file mode 100644 index 00000000000..9990ca25e3a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/output/fresh-spawn-wake-scrollback.test.ts @@ -0,0 +1,45 @@ +import { Terminal } from '@xterm/headless' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + captureWakeScrollback, + HOLD_RENDER_UNTIL_RESTORED, + resetWakeScrollbackForTests +} from '@/lib/orca-cloud/cloud-wake-scrollback' +import type { ConnectPanePtySession } from '../connect-pane-pty-session' +import { bindFreshSpawnFollowReset } from './fresh-spawn-follow-reset' + +afterEach(() => resetWakeScrollbackForTests()) + +function session(leafId: string): { session: ConnectPanePtySession; writes: string[] } { + const writes: string[] = [] + const stub = { + pane: { id: 1, leafId, terminal: { rows: 4 } }, + deps: { restoredViewportBlankingPanesRef: { current: new Set() } } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a stub with only the fields the binder's viewport-prepare path reads; writeReplayData is replaced below. + const typed = stub as unknown as ConnectPanePtySession + bindFreshSpawnFollowReset(typed) + typed.writeReplayData = vi.fn((data: string) => { + writes.push(data) + }) + return { session: typed, writes } +} + +describe('fresh-spawn viewport blanking on a woken cloud pane (WAKE-SCROLLBACK)', () => { + it('a pane with pre-sleep output to put back is held, not blanked (no blank frame before the restore)', async () => { + const terminal = new Terminal({ cols: 40, rows: 4, allowProposedApi: true }) + await new Promise((resolve) => terminal.write('$ seq 1 3\r\n1\r\n2\r\n3\r\n$ ', resolve)) + captureWakeScrollback('leaf-1', terminal, { sleptAt: 1, agentPane: false, maxRows: 5000 }) + const { session: s, writes } = session('leaf-1') + s.prepareFreshShellViewportForSpawn({ forceBlankRestoredViewport: true }) + expect(writes).toEqual([HOLD_RENDER_UNTIL_RESTORED]) + }) + + it('any other pane is blanked as before', () => { + const { session: s, writes } = session('leaf-2') + s.prepareFreshShellViewportForSpawn({ forceBlankRestoredViewport: true }) + expect(writes).toHaveLength(1) + expect(writes[0]).not.toBe(HOLD_RENDER_UNTIL_RESTORED) + expect(writes[0]).toContain('\x1b[H') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/output/replaced-process-input-modes.ts b/src/renderer/src/components/terminal-pane/pty-connection/output/replaced-process-input-modes.ts index c3cb5c88c9a..b1daae3cbe9 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/output/replaced-process-input-modes.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/output/replaced-process-input-modes.ts @@ -1,5 +1,9 @@ import { PROCESS_BOUNDARY_GROUND } from '../../../../../../shared/terminal/protocol/input-modes/terminal-mode-reset-profiles' import type { ConnectPanePtySession } from '../connect-pane-pty-session' +import { + HOLD_RENDER_UNTIL_RESTORED, + hasPendingWakeScrollback +} from '@/lib/orca-cloud/cloud-wake-scrollback' /** A replaced process must not inherit the old one's input modes (#35559). */ export function bindReplacedProcessInputModes(session: ConnectPanePtySession): void { @@ -8,7 +12,14 @@ export function bindReplacedProcessInputModes(session: ConnectPanePtySession): v session.groundInputModesForNewProcess = (): void => { session.inputModeOwnerIncarnationId = null - session.writeInputModeGround(PROCESS_BOUNDARY_GROUND) + // WAKE-SCROLLBACK: the ground releases DEC 2026; a woken cloud pane with pre-sleep output to put + // back re-holds rendering here, at the new terminal's bind, so no clear before its restore is + // ever painted (the restore releases it). + session.writeInputModeGround( + hasPendingWakeScrollback(session.pane.leafId) + ? `${PROCESS_BOUNDARY_GROUND}${HOLD_RENDER_UNTIL_RESTORED}` + : PROCESS_BOUNDARY_GROUND + ) // The new process never subscribed to mode 2031; a reconnect catch-up would land in its input. session.clearPaneMode2031State() } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/output/replay-data-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/output/replay-data-drain.ts index 60d4e7fb19c..00427d046a1 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/output/replay-data-drain.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/output/replay-data-drain.ts @@ -1,4 +1,5 @@ import { RELEASE_SYNCHRONIZED_OUTPUT } from '../../../../../../shared/terminal/protocol/input-modes/terminal-mode-reset-profiles' +import { syncTerminalScrollIntentFromViewport } from '@/lib/pane-manager/terminal-scroll-intent' import { waitForTerminalOutputParsed } from '@/lib/pane-manager/pane-terminal-output-scheduler' import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-fit' import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides' @@ -19,6 +20,13 @@ import { } from '../reattach/cursor-agent-reattach-screen' import type { ConnectPanePtySession } from '../connect-pane-pty-session' +import { + beginWakeScrollbackRestore, + finishWakeScrollbackRestore, + HOLD_RENDER_UNTIL_RESTORED, + noteBeforeCloudReplay, + type WakeScrollbackRestore +} from '@/lib/orca-cloud/cloud-wake-scrollback' export function bindReplayDataDrain(session: ConnectPanePtySession): void { session.sendFocusedReattachFocusInAfterReplay = ( @@ -110,7 +118,28 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { // Relay replay may overlap painted output; eager replay decides earlier so metadata-only frames preserve saved scrollback. // Clear before source-grid resize to avoid reflowing history that replay immediately discards. let replayPayload = data + let preSleep: WakeScrollbackRestore | null = null if (clearBeforeReplay) { + // WAKE-SCROLLBACK: a resubscribe keeps only the host's 1000 rows; keep the fuller copy first + // in case a sleep nobody clicked here follows (a teammate, idle auto-sleep). + const keptBeforeReplay = noteBeforeCloudReplay( + session.pane.leafId, + session.pane.terminal, + payload.ptyId + ) + if (keptBeforeReplay) { + await keptBeforeReplay + if (!isCurrentPayload()) { + continue + } + } + // A woken cloud pane's pre-sleep output, put back once this snapshot painted. + preSleep = beginWakeScrollbackRestore( + session.pane.leafId, + session.pane.terminal, + Date.now(), + payload.ptyId + ) if (carriesNormalBuffer) { // Wait for queued alt-screen transitions before choosing replay’s target buffer. await waitForTerminalOutputParsed(session.pane.terminal) @@ -123,10 +152,14 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { carriesHistory === true ) replayPayload = image.payload - await session.writeReplayDataAsync(image.preamble) + await session.writeReplayDataAsync( + preSleep ? `${image.preamble}${HOLD_RENDER_UNTIL_RESTORED}` : image.preamble + ) } else { // Reconnect can sever DEC 2026 mid-frame; clear-screen does not release its latch, leaving replay invisible until timeout. - await session.writeReplayDataAsync(`${RELEASE_SYNCHRONIZED_OUTPUT}\x1b[2J\x1b[3J\x1b[H`) + await session.writeReplayDataAsync( + `${RELEASE_SYNCHRONIZED_OUTPUT}\x1b[2J\x1b[3J\x1b[H${preSleep ? HOLD_RENDER_UNTIL_RESTORED : ''}` + ) } if (!isCurrentPayload()) { continue @@ -161,6 +194,21 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { if (!isCurrentPayload()) { continue } + if (preSleep) { + await finishWakeScrollbackRestore( + preSleep, + session.pane.terminal, + session.writeReplayDataAsync + ) + if (preSleep.scrolledTo !== null) { + // Record the person's place as a pin, so post-fit scroll-intent restores keep it there + // instead of following output to the bottom. + syncTerminalScrollIntentFromViewport(session.pane.terminal, { allowBufferShrink: true }) + } + if (!isCurrentPayload()) { + continue + } + } if (clearBeforeReplay || data.length > 0) { await session.writeReplayEpilogue( session.chooseReattachReplayReset(data, false, alternateScreen, terminalOwner, lastTitle), diff --git a/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/recovery/TerminalPaneRecoveryPortals.tsx b/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/recovery/TerminalPaneRecoveryPortals.tsx index 8fc1ec46fd0..8a73efc054d 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/recovery/TerminalPaneRecoveryPortals.tsx +++ b/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/recovery/TerminalPaneRecoveryPortals.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import type { PtyTransport } from '../../pty-transport-types' import { useWslHostUnavailableReason } from '../../../../../runtime/hosts/wsl/wsl-host-availability' @@ -23,8 +23,6 @@ import { CloudAsleepTerminalNotice } from '@/components/orca-cloud/offline/Cloud import { CloudWokeProcessesNotice } from '@/components/orca-cloud/offline/CloudWokeProcessesNotice' import { CloudWokeAgentNotice } from '@/components/orca-cloud/offline/CloudWokeAgentNotice' import { - captureWakeProcessHint, - cursorBelowLastText, dismissWakeProcessHint, endsAtShellPrompt, getWakeCaptureTime, @@ -39,6 +37,14 @@ import { paneShowsWslHostStoppedCard } from '@/runtime/hosts/wsl/wsl-host-stoppe import { withoutWslHostStoppedLines } from '../../../../../../../shared/wsl-host-stopped-message' import { makePaneKey } from '../../../../../../../shared/workspace-layout/stable-pane-id' import { cloudWokeActionFor, runCloudWokeAction } from '@/lib/orca-cloud/cloud-wake-hint-action' +import { captureCloudPaneBeforeWake } from '@/lib/orca-cloud/cloud-wake-pane-capture' +import { useIsWorktreeGoingToSleep } from '@/lib/worktree-sleep-view' +import { + armWakeScrollback, + forgetWakeScrollback, + noteWakeViewport +} from '@/lib/orca-cloud/cloud-wake-scrollback' +import { restartPaneExists } from '@/lib/orca-cloud/cloud-agent-restart-pane-watch' import { mapPaneTerminalErrors } from '../../../errors/terminal-error-accumulation' import type { TerminalPaneController } from '../../../controller/use-terminal-pane-controller' @@ -139,24 +145,83 @@ export function TerminalPaneRecoveryPortals({ // While asleep (or waking) the pane still holds the pre-sleep scrollback: note what was running, // so after the wake the pane can say it stopped and offer it back (JA-15). const isCloud = cloud !== null + const asleepSince = cloud?.asleepSince ?? null + // Sleep was asked for: look now, before the transport's resubscribes (while the VM suspends) + // replace the pane's buffer with the host's 1000-row snapshot (WAKE-SCROLLBACK). + const goingToSleep = useIsWorktreeGoingToSleep(controller.worktreeId) + // Set once the workspace was asleep this cycle: only then is a replay the woken terminal's. + const sleptRef = useRef(false) useEffect(() => { if (!isCloud) { return } - if (cloudAsleep || cloudWaking) { - const agentPanes = useAppStore.getState().agentStatusByPaneKey + // Never while waking: a remount may already hold restored copies; the capture is the sleep's. + if (goingToSleep || cloudAsleep) { for (const pane of managedPanes) { - // An agent TUI or full-screen app's last line is its own input, not a shell command. - captureWakeProcessHint(pane.leafId, readTerminalTail(pane.terminal), { - agentType: agentPanes[makePaneKey(tabId, pane.leafId)]?.agentType ?? null, - alternateScreen: pane.terminal.buffer.active.type === 'alternate', - cursorBelowText: cursorBelowLastText(pane.terminal) + // The Rerun hint and the pre-sleep output, before the new terminal attaches. + captureCloudPaneBeforeWake({ + tabId, + leafId: pane.leafId, + worktreeId: controller.worktreeId, + terminal: pane.terminal, + asleepSince }) } } else { resetWakeProcessCapture(managedPanes.map((pane) => pane.leafId)) } - }, [isCloud, cloudAsleep, cloudWaking, managedPanes, tabId]) + if (cloudAsleep) { + sleptRef.current = true + } else if (sleptRef.current && (cloudWaking || cloudAwake)) { + // Waking or live after a sleep: the next clearing replay is the woken terminal's. + armWakeScrollback(managedPanes.map((pane) => pane.leafId)) + if (cloudAwake) { + sleptRef.current = false + } + } + }, [ + isCloud, + goingToSleep, + cloudAsleep, + cloudWaking, + cloudAwake, + managedPanes, + tabId, + controller.worktreeId, + asleepSince + ]) + // WAKE-SCROLLBACK: a person scrolling the pane while it sleeps or wakes keeps that place after the + // restore, across the wake's remount. Wheel only: writes also scroll, and must not count. + useEffect(() => { + if (!isCloud || !(cloudAsleep || cloudWaking)) { + return + } + const listening = new AbortController() + for (const pane of managedPanes) { + pane.container.addEventListener( + 'wheel', + () => requestAnimationFrame(() => noteWakeViewport(pane.leafId, pane.terminal)), + { passive: true, signal: listening.signal } + ) + } + return () => listening.abort() + }, [isCloud, cloudAsleep, cloudWaking, managedPanes]) + // A pane closed, or its tab or workspace went away: drop its kept pre-sleep output. + const leafIdsRef = useRef<(typeof managedPanes)[number]['leafId'][]>([]) + useEffect(() => { + const now = managedPanes.map((pane) => pane.leafId) + forgetWakeScrollback(leafIdsRef.current.filter((id) => !now.includes(id))) + leafIdsRef.current = now + }, [managedPanes]) + useEffect( + () => () => { + const state = useAppStore.getState() + forgetWakeScrollback( + leafIdsRef.current.filter((id) => !restartPaneExists(state, makePaneKey(tabId, id))) + ) + }, + [tabId] + ) const isolated = controller.isolatedPaneKey !== null // Why: a pane waiting for SSH sign-in says so itself; "reconnecting" over it would be false. const awaitingSignInHere = managedPanes.some( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a893118aadb..4451455e021 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -27164,7 +27164,8 @@ "asleepSince": "Asleep since {{time}}", "rerun": "Rerun {{command}}", "rerunLabel": "Rerun", - "wokeProcessesStopped": "Woke up · processes stopped while asleep" + "wokeProcessesStopped": "Woke up · processes stopped while asleep", + "sleptRangeDivider": "Slept {{range}} · processes stopped" }, "archivedDetailLocalEdits_one": "It goes to sleep and stays browsable: PRs, last changes and files. {{count}} changed file on this computer will be removed.", "archivedDetailLocalEdits_other": "It goes to sleep and stays browsable: PRs, last changes and files. {{count}} changed files on this computer will be removed.", diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-pane-capture.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-pane-capture.ts new file mode 100644 index 00000000000..7298e8e5068 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-pane-capture.ts @@ -0,0 +1,56 @@ +// What a cloud terminal pane notes while its workspace is asleep (or waking), before the woken +// terminal attaches: the Rerun hint (JA-15 / WAKE-RERUN) and its pre-sleep output (WAKE-SCROLLBACK). +import type { Terminal } from '@xterm/xterm' +import { useAppStore } from '@/store' +import { makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' +import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime/remote-runtime-pty-id' +import { normalizeDesktopTerminalScrollbackRows } from '../../../../shared/terminal/replay/terminal-scrollback-policy' +import { + captureWakeProcessHint, + cursorBelowLastText, + readTerminalTail, + scrollbackPaneKind, + type WakePaneFacts +} from './cloud-wake-process-hints' +import { captureWakeScrollback } from './cloud-wake-scrollback' + +/** The plain-text read-only copy saved at sleep for the pane's pre-sleep terminal, if any. */ +async function readOnlyCopyLines( + workspaceId: string, + ptyId: string | null +): Promise { + const handle = ptyId ? parseRemoteRuntimePtyId(ptyId)?.handle : null + if (!handle) { + return null + } + const snapshot = await window.api.orcaCloudOffline.getSnapshot({ workspaceId }) + return snapshot?.terminals.find((terminal) => terminal.handle === handle)?.lines ?? null +} + +export function captureCloudPaneBeforeWake(args: { + tabId: string + leafId: string + worktreeId: string + terminal: Terminal + /** When the workspace went to sleep, or null when unknown. */ + asleepSince: number | null +}): void { + const { tabId, leafId, worktreeId, terminal } = args + const state = useAppStore.getState() + const lines = readTerminalTail(terminal) + const facts: WakePaneFacts = { + agentType: state.agentStatusByPaneKey[makePaneKey(tabId, leafId)]?.agentType ?? null, + alternateScreen: terminal.buffer.active.type === 'alternate', + cursorBelowText: cursorBelowLastText(terminal) + } + captureWakeProcessHint(leafId, lines, facts) + const agentPane = scrollbackPaneKind(lines, facts) === 'agent' + const ptyId = state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId] ?? null + captureWakeScrollback(leafId, terminal, { + sleptAt: args.asleepSince, + agentPane, + maxRows: normalizeDesktopTerminalScrollbackRows(state.settings?.terminalScrollbackRows), + readOnlyCopy: () => readOnlyCopyLines(worktreeId, ptyId), + ptyId + }) +} diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-process-hints.test.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-process-hints.test.ts index 18c8f21e8fe..b6490bbb3f8 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-wake-process-hints.test.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-process-hints.test.ts @@ -11,7 +11,8 @@ import { wakeHintForPane, readTerminalTail, resetWakeProcessCapture, - resetWakeProcessHintsForTests + resetWakeProcessHintsForTests, + scrollbackPaneKind } from './cloud-wake-process-hints' afterEach(() => resetWakeProcessHintsForTests()) @@ -238,3 +239,20 @@ describe('wake process hints', () => { expect(readTerminalTail(terminal, 2)).toEqual(['b', 'c']) }) }) + +describe('scrollbackPaneKind (WAKE-SCROLLBACK)', () => { + const claudeScreen = ['$ claude', '────────', '> Try "fix lint"', '────────', ' ? for shortcuts'] + + it('an agent still on screen, or a full-screen app, restores only its scrollback', () => { + expect(scrollbackPaneKind(claudeScreen, { agentType: 'claude' })).toBe('agent') + expect(scrollbackPaneKind(claudeScreen, { agentType: 'codex' })).toBe('agent') + expect(scrollbackPaneKind(['$ vim notes.md'], { alternateScreen: true })).toBe('agent') + }) + + it('a shell pane (no agent, or the agent quit back to its prompt) restores its visible rows too', () => { + expect(scrollbackPaneKind(['$ seq 1 3', '1', '2', '3', '$ '], {})).toBe('shell') + expect(scrollbackPaneKind(['$ claude', 'bye', 'ubuntu@sbx:~$ '], { agentType: 'claude' })).toBe( + 'shell' + ) + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-process-hints.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-process-hints.ts index bb774639d13..d544feb5c9e 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-wake-process-hints.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-process-hints.ts @@ -300,3 +300,19 @@ export function resetWakeProcessHintsForTests(): void { withHistory.clear() version = 0 } + +/** + * WAKE-SCROLLBACK: an agent pane (the same rule as the woke-up strip, #451: a Claude/Codex pane not + * back at its shell) or a full-screen app restores only the scrollback above its screen; a shell + * pane restores its visible rows too. + */ +export function scrollbackPaneKind( + lines: readonly string[], + facts: WakePaneFacts +): 'shell' | 'agent' { + if (facts.alternateScreen) { + return 'agent' + } + const hint = wakeHintForPane(lines, facts) + return hint !== null && 'restartAgent' in hint ? 'agent' : 'shell' +} diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback-render.test.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback-render.test.ts new file mode 100644 index 00000000000..72df31d3b0f --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback-render.test.ts @@ -0,0 +1,56 @@ +import { Terminal } from '@xterm/headless' +import { describe, expect, it } from 'vitest' +import { renderBufferRows, sanitizeRestoredText } from './cloud-wake-scrollback-render' + +function write(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +describe('sanitizeRestoredText (WAKE-SCROLLBACK)', () => { + it('keeps text and SGR, and drops every other escape', () => { + const dirty = [ + 'plain ', + '\x1b[1;31mred\x1b[0m ', + '\x1b]52;c;ZXZpbA==\x07', // OSC 52 clipboard write (BEL) + '\x1b]52;c;ZXZpbA==\x1b\\', // OSC 52 (ST) + '\x1b]0;window title\x07', // OSC 0 title + '\x1b[200~pasted\x1b[201~', // bracketed paste markers + '\x1b[5A\x1b[10;3H\x1b[2C\x1b[K\x1b[2J', // cursor moves and erases + '\x1b[?1049h\x1b[?2004h\x1b[?25l', // DECSET + '\x1b(0\x1b=\x1b7\x1bc', // charset, keypad, DECSC, RIS + '\x1bPq#0;2;0;0;0\x1b\\', // DCS (sixel) + '\x9b31m\x9d0;t\x07', // C1 CSI / OSC + 'end\x07\x08\r\n' + ].join('') + expect(sanitizeRestoredText(dirty)).toBe('plain \x1b[1;31mred\x1b[0m pastedend\r\n') + }) + + it('keeps 256-colour and truecolour SGR', () => { + expect(sanitizeRestoredText('\x1b[38;5;208mo\x1b[48;2;1;2;3mb\x1b[m')).toBe( + '\x1b[38;5;208mo\x1b[48;2;1;2;3mb\x1b[m' + ) + }) +}) + +describe('renderBufferRows (WAKE-SCROLLBACK)', () => { + it('renders text with its colours and attributes, joins wrapped rows, trims padding', async () => { + const terminal = new Terminal({ cols: 10, rows: 4, allowProposedApi: true }) + await write(terminal, '\x1b[1;31mbold red\x1b[0m\r\nabcdefghijKLM\r\nplain ') + const out = renderBufferRows(terminal.buffer.normal, 0, 4) + expect(out).toBe('\x1b[0;1;31mbold red\x1b[0m\r\nabcdefghijKLM\r\nplain') + expect(sanitizeRestoredText(out)).toBe(out) + }) + + it('round-trips through a fresh terminal with the same cells', async () => { + const source = new Terminal({ cols: 20, rows: 3, allowProposedApi: true }) + await write(source, 'a \x1b[38;5;208mo\x1b[48;2;1;2;3mx\x1b[0m \x1b[7mi\x1b[0m') + const target = new Terminal({ cols: 20, rows: 3, allowProposedApi: true }) + await write(target, renderBufferRows(source.buffer.normal, 0, 1)) + const cell = (terminal: Terminal, x: number) => terminal.buffer.normal.getLine(0)!.getCell(x)! + for (const x of [2, 3, 5]) { + expect(cell(target, x).getFgColor()).toBe(cell(source, x).getFgColor()) + expect(cell(target, x).getBgColor()).toBe(cell(source, x).getBgColor()) + expect(cell(target, x).isInverse()).toBe(cell(source, x).isInverse()) + } + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback-render.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback-render.ts new file mode 100644 index 00000000000..acc88f8d12e --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback-render.ts @@ -0,0 +1,254 @@ +// WAKE-SCROLLBACK: a pane's pre-sleep output as rendered text and attributes only. Built from the +// xterm buffer's cells (never its escape stream), so it carries text and SGR (`CSI … m`) and +// nothing else: no OSC (52 clipboard, 0 title), no modes, no cursor moves. `sanitizeRestoredText` +// enforces that again on whatever is written back, including the plain-text read-only copy. + +/** The parts of an xterm buffer cell the renderer reads. */ +export type RestoreCell = { + getChars(): string + getWidth(): number + isBold(): number + isDim(): number + isItalic(): number + isUnderline(): number + isBlink(): number + isInverse(): number + isInvisible(): number + isStrikethrough(): number + isFgDefault(): boolean + isFgPalette(): boolean + isFgRGB(): boolean + getFgColor(): number + isBgDefault(): boolean + isBgPalette(): boolean + isBgRGB(): boolean + getBgColor(): number +} + +export type RestoreLine = { + isWrapped: boolean + length: number + getCell(x: number): RestoreCell | undefined +} + +export type RestoreBuffer = { + length: number + getLine(y: number): RestoreLine | undefined +} + +function colorParams( + kind: 'fg' | 'bg', + isDefault: boolean, + isPalette: boolean, + isRgb: boolean, + color: number +): string[] { + if (isDefault) { + return [] + } + const base = kind === 'fg' ? 30 : 40 + if (isPalette) { + if (color < 8) { + return [String(base + color)] + } + if (color < 16) { + return [String(base + 60 + color - 8)] + } + return [String(base + 8), '5', String(color)] + } + if (isRgb) { + return [ + String(base + 8), + '2', + String((color >> 16) & 255), + String((color >> 8) & 255), + String(color & 255) + ] + } + return [] +} + +/** The SGR parameters for a cell's attributes (empty for the default pen). */ +export function cellSgrParams(cell: RestoreCell): string[] { + const params: string[] = [] + const flags: [number, string][] = [ + [cell.isBold(), '1'], + [cell.isDim(), '2'], + [cell.isItalic(), '3'], + [cell.isUnderline(), '4'], + [cell.isBlink(), '5'], + [cell.isInverse(), '7'], + [cell.isInvisible(), '8'], + [cell.isStrikethrough(), '9'] + ] + for (const [on, code] of flags) { + if (on) { + params.push(code) + } + } + params.push( + ...colorParams('fg', cell.isFgDefault(), cell.isFgPalette(), cell.isFgRGB(), cell.getFgColor()), + ...colorParams('bg', cell.isBgDefault(), cell.isBgPalette(), cell.isBgRGB(), cell.getBgColor()) + ) + return params +} + +function renderLine(line: RestoreLine): string { + let out = '' + let pen = '' + // Trailing default-pen blanks are padding, not output. + let keep = 0 + const cells: { chars: string; pen: string }[] = [] + for (let x = 0; x < line.length; x++) { + const cell = line.getCell(x) + if (!cell || cell.getWidth() === 0) { + continue + } + const cellPen = cellSgrParams(cell).join(';') + const chars = cell.getChars() || ' ' + cells.push({ chars, pen: cellPen }) + if (chars !== ' ' || cellPen !== '') { + keep = cells.length + } + } + for (const cell of cells.slice(0, keep)) { + if (cell.pen !== pen) { + out += cell.pen === '' ? '\x1b[0m' : `\x1b[0;${cell.pen}m` + pen = cell.pen + } + out += cell.chars + } + return pen === '' ? out : `${out}\x1b[0m` +} + +/** Rows `[from, to)` of a buffer as text + SGR; wrapped rows join their line, others end in CRLF. */ +export function renderBufferRows(buffer: RestoreBuffer, from: number, to: number): string { + let out = '' + for (let y = Math.max(0, from); y < Math.min(to, buffer.length); y++) { + const line = buffer.getLine(y) + if (!line) { + continue + } + if (y > from && !line.isWrapped) { + out += '\r\n' + } + out += renderLine(line) + } + return out +} + +/** `renderBufferRows` in 1000-row chunks, yielding between them (off the input path). */ +export async function renderBufferRowsAsync( + buffer: RestoreBuffer, + from: number, + to: number, + chunk = 1000 +): Promise { + let out = '' + for (let start = Math.max(0, from); start < to; start += chunk) { + const end = Math.min(to, start + chunk) + if (start > from && !buffer.getLine(start)?.isWrapped) { + out += '\r\n' + } + out += renderBufferRows(buffer, start, end) + if (end < to) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + } + return out +} + +const ESC = '\x1b' +const BEL = '\x07' + +/** A CSI body starting at `start` (after ESC [ or C1 CSI): its end index and whether it is SGR. */ +function readCsi(text: string, start: number): { end: number; sgr: boolean } { + let end = start + while (end < text.length && /[0-?]/.test(text[end]!)) { + end++ + } + while (end < text.length && /[ -/]/.test(text[end]!)) { + end++ + } + return { end, sgr: text[end] === 'm' && /^[0-9;:]*$/.test(text.slice(start, end)) } +} + +/** Index just past a string control (OSC/DCS/SOS/PM/APC) starting at `start`, or the end. */ +function skipStringControl(text: string, start: number): number { + for (let index = start; index < text.length; index++) { + if (text[index] === BEL) { + return index + 1 + } + if (text[index] === ESC && text[index + 1] === '\\') { + return index + 2 + } + } + return text.length +} + +/** + * Text and SGR only: every other escape (OSC 52 / OSC 0, DECSET, bracketed paste, cursor moves, + * erases, charset and keypad modes) and every C0/C1 control but tab, CR and LF is dropped. + */ +export function sanitizeRestoredText(text: string): string { + let out = '' + let index = 0 + while (index < text.length) { + const char = text[index]! + const code = char.charCodeAt(0) + if (char === ESC) { + const next = text[index + 1] + if (next === '[') { + const csi = readCsi(text, index + 2) + if (csi.sgr) { + out += text.slice(index, csi.end + 1) + } + index = csi.end + 1 + continue + } + if (next === ']' || next === 'P' || next === 'X' || next === '^' || next === '_') { + index = skipStringControl(text, index + 2) + continue + } + // ESC + intermediates + final (charset designations, keypad modes, RIS, DECSC/DECRC). + let end = index + 1 + while (end < text.length && /[ -/]/.test(text[end]!)) { + end++ + } + index = end + 1 + continue + } + if ((code < 0x20 && char !== '\t' && char !== '\r' && char !== '\n') || code === 0x7f) { + index++ + continue + } + if (code >= 0x80 && code <= 0x9f) { + // C1 controls, dropped with their sequences: CSI (0x9b), and OSC/DCS/SOS/PM/APC strings. + if (code === 0x9b) { + index = readCsi(text, index + 1).end + 1 + } else if ([0x90, 0x98, 0x9d, 0x9e, 0x9f].includes(code)) { + index = skipStringControl(text, index + 1) + } else { + index++ + } + continue + } + out += char + index++ + } + return out +} + +/** + * "2:02–2:31 PM" / "11:50 AM–12:10 PM" / "14:02–14:31": the locale's short times, no leading zero, + * a shared AM/PM said once; across days each side keeps its weekday ("Mon 11:50 PM–Tue 12:10 AM"). + */ +export function formatSleptRange(from: number, to: number, locale?: string): string { + const sameDay = new Date(from).toDateString() === new Date(to).toDateString() + const format = new Intl.DateTimeFormat(locale, { + hour: 'numeric', + minute: '2-digit', + ...(sameDay ? {} : { weekday: 'short' }) + }) + return format.formatRange(Math.min(from, to), to).replace(/\s*[–-]\s*/u, '–') +} diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts new file mode 100644 index 00000000000..0ddb2835d07 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts @@ -0,0 +1,364 @@ +import { Terminal } from '@xterm/headless' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + armWakeScrollback, + beginWakeScrollbackRestore, + captureWakeScrollback, + finishWakeScrollbackRestore, + forgetWakeScrollback, + hasPendingWakeScrollback, + noteBeforeCloudReplay, + noteWakeViewport, + wakeScrollbackPreview, + resetWakeScrollbackForTests +} from './cloud-wake-scrollback' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, params?: Record) => + fallback.replace(/\{\{(\w+)\}\}/g, (_slot: string, name: string) => params?.[name] ?? '') +})) + +// A fixed clock (Date only; xterm's own timers stay real): the divider reads 2:02–2:31 PM. +const NOW = new Date(2026, 10, 11, 14, 31).getTime() +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(NOW) +}) +afterEach(() => { + resetWakeScrollbackForTests() + vi.useRealTimers() +}) + +const SLEPT_AT = NOW - 29 * 60_000 +const DIVIDER = /^── Slept .+–.+ · processes stopped ──$/ + +function write(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +function pane(rows = 10): Terminal { + return new Terminal({ cols: 60, rows, scrollback: 5000, allowProposedApi: true }) +} + +function text(terminal: Terminal): string[] { + const buffer = terminal.buffer.normal + const out: string[] = [] + for (let y = 0; y < buffer.length; y++) { + out.push(buffer.getLine(y)!.translateToString(true).trimEnd()) + } + while (out.length > 0 && out.at(-1) === '') { + out.pop() + } + return out +} + +function cursorRowText(terminal: Terminal): string { + const active = terminal.buffer.active + return active + .getLine(active.baseY + active.cursorY)! + .translateToString(true) + .trimEnd() +} + +/** What the drain does with the woken terminal's snapshot: begin, the snapshot (it clears), finish. */ +async function wake(terminal: Terminal, leafId: string, newScreen: string): Promise { + const restore = beginWakeScrollbackRestore(leafId, terminal) + await write(terminal, `\x1b[2J\x1b[3J\x1b[H${newScreen}`) + if (!restore) { + return false + } + return finishWakeScrollbackRestore(restore, terminal, (data) => write(terminal, data)) +} + +function capture(terminal: Terminal, leafId: string, agentPane = false): void { + captureWakeScrollback(leafId, terminal, { sleptAt: SLEPT_AT, agentPane, maxRows: 5000 }) +} + +describe('WAKE-SCROLLBACK', () => { + it('a shell pane: `seq 1 2000` above the divider, the woken prompt right below, pinned to the bottom', async () => { + const terminal = pane() + const seq = Array.from({ length: 2000 }, (_, index) => String(index + 1)).join('\r\n') + await write(terminal, `ubuntu@sbx:~$ seq 1 2000\r\n${seq}\r\nubuntu@sbx:~$ sleep 900\r\n`) + capture(terminal, 'leaf-1') + armWakeScrollback(['leaf-1']) + expect(await wake(terminal, 'leaf-1', 'ubuntu@sbx:~$ ')).toBe(true) + const lines = text(terminal) + expect(lines[0]).toBe('ubuntu@sbx:~$ seq 1 2000') + expect(lines[1]).toBe('1') + expect(lines.at(-3)).toBe('ubuntu@sbx:~$ sleep 900') + expect(lines.at(-2)).toMatch(DIVIDER) + expect(lines.at(-1)).toBe('ubuntu@sbx:~$') + const active = terminal.buffer.active + expect(active.viewportY).toBe(active.baseY) + // The cursor is where the woken shell left it: after its prompt. + expect(cursorRowText(terminal)).toBe('ubuntu@sbx:~$') + expect(active.cursorX).toBe('ubuntu@sbx:~$ '.length) + }) + + it('a woken screen with rows below the cursor keeps them, and the cursor goes back to its row', async () => { + const terminal = pane(8) + await write(terminal, '$ ls\r\nREADME\r\n$ ') + capture(terminal, 'leaf-1') + armWakeScrollback(['leaf-1']) + await wake(terminal, 'leaf-1', '$ \x1b[3;1Hstatus line\x1b[1;3H') + const lines = text(terminal) + expect(lines.at(-4)).toMatch(DIVIDER) + expect(lines.slice(-3)).toEqual(['$', '', 'status line']) + expect(cursorRowText(terminal)).toBe('$') + expect(terminal.buffer.active.cursorX).toBe(2) + }) + + it('keeps colours (rendered attributes), never escapes like OSC 52 the pane received', async () => { + const terminal = pane(4) + await write(terminal, '\x1b]52;c;ZXZpbA==\x07\x1b[32mgreen ok\x1b[0m\r\n$ ') + capture(terminal, 'leaf-1') + armWakeScrollback(['leaf-1']) + const restore = beginWakeScrollbackRestore('leaf-1', terminal)! + expect(restore.block).not.toContain('\x1b]') + expect(restore.block).toContain('\x1b[0;32mgreen ok') + }) + + it('an agent pane keeps only the scrollback above its screen, never the frozen TUI frame', async () => { + const terminal = pane(5) + const earlier = Array.from({ length: 8 }, (_, index) => `earlier ${index + 1}`).join('\r\n') + await write(terminal, `${earlier}\r\n`) + await write(terminal, '╭────────╮\r\n│ > Try │\r\n╰────────╯\r\n ? for shortcuts') + capture(terminal, 'leaf-a', true) + armWakeScrollback(['leaf-a']) + const block = beginWakeScrollbackRestore('leaf-a', terminal)!.block + expect(block).toContain('earlier 1') + expect(block).not.toContain('> Try') + expect(block).not.toContain('for shortcuts') + }) + + it('repeated sleeps: one divider per sleep, in order', async () => { + const terminal = pane(6) + await write(terminal, '$ echo one\r\none\r\n$ ') + capture(terminal, 'leaf-1') + armWakeScrollback(['leaf-1']) + await wake(terminal, 'leaf-1', '$ ') + await write(terminal, 'echo two\r\ntwo\r\n$ ') + captureWakeScrollback('leaf-1', terminal, { + sleptAt: SLEPT_AT + 10 * 60_000, + agentPane: false, + maxRows: 5000 + }) + armWakeScrollback(['leaf-1']) + await wake(terminal, 'leaf-1', '$ ') + const lines = text(terminal) + const dividers = lines.flatMap((line, index) => (DIVIDER.test(line) ? [index] : [])) + expect(dividers).toHaveLength(2) + expect(lines.indexOf('one')).toBeLessThan(dividers[0]!) + expect(lines.indexOf('two')).toBeGreaterThan(dividers[0]!) + expect(lines.indexOf('two')).toBeLessThan(dividers[1]!) + }) + + it('a snapshot for the new (woken) terminal takes it at once, before React arms it', async () => { + const terminal = pane(4) + await write(terminal, '$ ls\r\nREADME\r\n$ ') + captureWakeScrollback('leaf-1', terminal, { + sleptAt: SLEPT_AT, + agentPane: false, + maxRows: 5000, + ptyId: 'remote:env::term-old' + }) + // A cold restore of the old terminal while still asleep: not taken. + expect(beginWakeScrollbackRestore('leaf-1', terminal, NOW, 'remote:env::term-old')).toBeNull() + // The woken terminal's snapshot: taken, even though nothing armed it yet. + expect( + beginWakeScrollbackRestore('leaf-1', terminal, NOW, 'remote:env::term-new')?.block + ).toContain('README') + }) + + it('a replay before the workspace leaves "asleep" (a cold restore) does not take it', async () => { + const terminal = pane(4) + await write(terminal, '$ ls\r\nREADME\r\n$ ') + capture(terminal, 'leaf-1') + expect(beginWakeScrollbackRestore('leaf-1', terminal)).toBeNull() + armWakeScrollback(['leaf-1']) + expect(beginWakeScrollbackRestore('leaf-1', terminal)?.block).toContain('README') + // Done: taken once. + expect(beginWakeScrollbackRestore('leaf-1', terminal)).toBeNull() + }) + + it('no live buffer (never mounted / restarted while asleep): the plain read-only copy, uncoloured', async () => { + const terminal = pane(6) + const readOnlyCopy = vi.fn(async () => ['$ npm test', 'ok 12 tests', '$ ']) + captureWakeScrollback('leaf-1', terminal, { + sleptAt: SLEPT_AT, + agentPane: false, + maxRows: 5000, + readOnlyCopy + }) + await vi.waitFor(() => expect(readOnlyCopy).toHaveBeenCalled()) + await Promise.resolve() + armWakeScrollback(['leaf-1']) + expect(await wake(terminal, 'leaf-1', '$ ')).toBe(true) + const lines = text(terminal) + expect(lines[0]).toBe('$ npm test') + expect(lines).toContain('ok 12 tests') + expect(lines.at(-2)).toMatch(DIVIDER) + }) + + it('neither a live buffer nor a read-only copy: restores nothing and says nothing', async () => { + const terminal = pane(4) + captureWakeScrollback('leaf-1', terminal, { + sleptAt: SLEPT_AT, + agentPane: false, + maxRows: 5000, + readOnlyCopy: async () => null + }) + await Promise.resolve() + armWakeScrollback(['leaf-1']) + expect(await wake(terminal, 'leaf-1', '$ ')).toBe(false) + expect(text(terminal)).toEqual(['$']) + }) + + // m4air run: by "asleep" a resubscribe had replaced 2000 rows with the host's 1000-row snapshot. + it('captures once per sleep (at going to sleep): a later look at a replaced buffer changes nothing', async () => { + const terminal = pane(10) + const seq = Array.from({ length: 2000 }, (_, index) => String(index + 1)).join('\r\n') + await write(terminal, `$ seq 1 2000\r\n${seq}\r\n$ sleep 900\r\n`) + captureWakeScrollback('leaf-1', terminal, { sleptAt: null, agentPane: false, maxRows: 5000 }) + // The transport resubscribes while the VM suspends: a cleared, host-limited image. + const last1000 = Array.from({ length: 1000 }, (_, index) => String(index + 1001)).join('\r\n') + await write(terminal, `\x1b[2J\x1b[3J\x1b[H${last1000}\r\n$ sleep 900\r\n`) + captureWakeScrollback('leaf-1', terminal, { + sleptAt: SLEPT_AT, + agentPane: false, + maxRows: 5000 + }) + armWakeScrollback(['leaf-1']) + expect(await wake(terminal, 'leaf-1', '$ ')).toBe(true) + const lines = text(terminal) + expect(lines[0]).toBe('$ seq 1 2000') + expect(lines[1]).toBe('1') + }) + + it("a past sleep's woken-but-untaken capture is replaced by the next sleep's", async () => { + const terminal = pane(6) + await write(terminal, '$ echo old\r\nold\r\n$ ') + capture(terminal, 'leaf-1') + armWakeScrollback(['leaf-1']) + await write(terminal, 'echo new\r\nnew\r\n$ ') + capture(terminal, 'leaf-1') + armWakeScrollback(['leaf-1']) + expect(beginWakeScrollbackRestore('leaf-1', terminal)?.block).toContain('new') + }) + + it('a pane remounted on wake (fresh, empty xterm) is painted with its kept output at once', async () => { + const before = pane(6) + await write(before, '\x1b[32m$ ls\x1b[0m\r\nREADME\r\n$ ') + capture(before, 'leaf-1') + const remounted = pane(6) + await write(remounted, wakeScrollbackPreview('leaf-1')!) + expect(text(remounted)).toEqual(['$ ls', 'README', '$']) + expect(wakeScrollbackPreview('leaf-none')).toBeNull() + }) + + it('reports a pending restore only when there is output to put back', async () => { + const empty = pane(4) + captureWakeScrollback('leaf-e', empty, { sleptAt: SLEPT_AT, agentPane: false, maxRows: 5000 }) + expect(hasPendingWakeScrollback('leaf-e')).toBe(false) + const full = pane(4) + await write(full, '$ ls\r\nREADME\r\n$ ') + capture(full, 'leaf-f') + expect(hasPendingWakeScrollback('leaf-f')).toBe(true) + }) + + // A sleep nobody clicked on this desktop: no early capture; the VM going down makes the transport + // resubscribe, and that replay keeps only the host's 1000 rows before the pane hears "asleep". + it("a sleep nobody clicked here: what the pane held before the host's 1000-row resubscribe comes back", async () => { + const terminal = pane(10) + const seq = Array.from({ length: 2000 }, (_, index) => String(index + 1)).join('\r\n') + await write(terminal, `$ seq 1 2000\r\n${seq}\r\n$ sleep 900\r\n`) + const cloud = (): boolean => true + // The drain, just before the resubscribe's clearing replay. + await noteBeforeCloudReplay('leaf-1', terminal, 'remote:env@@term-old', cloud) + const last1000 = Array.from({ length: 1000 }, (_, index) => String(index + 1001)).join('\r\n') + await write(terminal, `\x1b[2J\x1b[3J\x1b[H${last1000}\r\n$ sleep 900\r\n`) + // Only now does the pane hear the workspace is asleep. + captureWakeScrollback('leaf-1', terminal, { + sleptAt: SLEPT_AT, + agentPane: false, + maxRows: 5000, + ptyId: 'remote:env@@term-old' + }) + armWakeScrollback(['leaf-1']) + expect(await wake(terminal, 'leaf-1', '$ ')).toBe(true) + const lines = text(terminal) + expect(lines[0]).toBe('$ seq 1 2000') + expect(lines[1]).toBe('1') + expect(lines.at(-2)).toMatch(DIVIDER) + }) + + it('keeps the pre-replay copy cheap: only over the host replay size, and only for cloud panes', async () => { + const small = pane(10) + await write(small, '$ ls\r\nREADME\r\n$ ') + // Under the host's 1000 rows: the replay keeps it all, so nothing is copied (and no await added). + expect(noteBeforeCloudReplay('leaf-s', small, 'remote:env@@t', () => true)).toBeNull() + const big = pane(10) + await write(big, Array.from({ length: 1500 }, (_, index) => `row ${index}`).join('\r\n')) + expect(noteBeforeCloudReplay('leaf-b', big, 'remote:env@@t', () => false)).toBeNull() + expect(noteBeforeCloudReplay('leaf-b', big, 'remote:env@@t', () => true)).not.toBeNull() + }) + + it('times the pre-replay copy of a 10k-line pane (rendered in chunks, off the input path)', async () => { + const terminal = new Terminal({ + cols: 80, + rows: 24, + scrollback: 10_000, + allowProposedApi: true + }) + const lines = Array.from( + { length: 10_000 }, + (_, index) => `\x1b[3${index % 8}mline ${index}\x1b[0m` + ) + await write(terminal, lines.join('\r\n')) + vi.useRealTimers() + const started = performance.now() + await noteBeforeCloudReplay('leaf-10k', terminal, 'remote:env@@t', () => true) + const elapsed = performance.now() - started + console.info(`[WAKE-SCROLLBACK] pre-replay copy of a 10k-line pane: ${elapsed.toFixed(1)} ms`) + expect(elapsed).toBeLessThan(2000) + }) + + // m4air (c): waking remounts the pane at the bottom; the place the person scrolled to comes back. + it('a scroll made while waking survives the remount and is put back after the restore', async () => { + const before = pane(5) + await write(before, Array.from({ length: 50 }, (_, index) => `row ${index}`).join('\r\n')) + capture(before, 'leaf-1') + before.scrollToLine(12) + noteWakeViewport('leaf-1', before) + armWakeScrollback(['leaf-1']) + const remounted = pane(5) + expect(await wake(remounted, 'leaf-1', '$ ')).toBe(true) + const active = remounted.buffer.active + expect(active.viewportY).toBe(12) + expect(active.getLine(active.viewportY)!.translateToString(true).trimEnd()).toBe('row 12') + }) + + it('a person who had scrolled up keeps their place instead of jumping', async () => { + const terminal = pane(5) + await write(terminal, Array.from({ length: 50 }, (_, index) => `row ${index}`).join('\r\n')) + terminal.scrollToLine(10) + capture(terminal, 'leaf-1') + armWakeScrollback(['leaf-1']) + await wake(terminal, 'leaf-1', '$ ') + expect(terminal.buffer.active.viewportY).toBe(10) + }) + + it('forgets a closed pane, and caps the kept rows', async () => { + const terminal = pane(4) + await write(terminal, Array.from({ length: 30 }, (_, index) => `row ${index}`).join('\r\n')) + captureWakeScrollback('leaf-1', terminal, { sleptAt: SLEPT_AT, agentPane: false, maxRows: 5 }) + armWakeScrollback(['leaf-1']) + const block = beginWakeScrollbackRestore('leaf-1', terminal)!.block + expect(block).toContain('row 29') + expect(block).not.toContain('row 24\r') + captureWakeScrollback('leaf-2', terminal, { sleptAt: SLEPT_AT, agentPane: false, maxRows: 5 }) + forgetWakeScrollback(['leaf-2']) + armWakeScrollback(['leaf-2']) + expect(beginWakeScrollbackRestore('leaf-2', terminal)).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts new file mode 100644 index 00000000000..c97c0ff3eb9 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts @@ -0,0 +1,345 @@ +// WAKE-SCROLLBACK: a cloud terminal pane keeps its pre-sleep output across a wake. Sleep reboots the +// VM and the woken pane attaches to a new terminal whose snapshot replay erases scrollback; the +// output the pane showed before the sleep is written back above a "Slept 14:02–14:31 · processes +// stopped" divider, client-side only (nothing reaches the PTY), as rendered text + SGR. +import { translate } from '@/i18n/i18n' +import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime/remote-runtime-pty-id' +import { isOrcaCloudEnvironmentId } from './orca-cloud-environment-id' +import { + formatSleptRange, + renderBufferRows, + renderBufferRowsAsync, + sanitizeRestoredText, + type RestoreBuffer +} from './cloud-wake-scrollback-render' + +type PendingHistory = { + /** Which sleep this is from (its start); one capture per sleep. */ + sleptAt: number + /** Text + SGR (or plain text from the read-only copy); null until known, or when empty. */ + text: string | null + /** + * The workspace left "asleep": the next clearing replay is the woken terminal's. Until then a + * replay (a cold restore while still asleep) must not take it. + */ + armed: boolean + /** The pre-sleep terminal: a snapshot for a different one belongs to the woken terminal. */ + ptyId: string | null + /** The line the person had scrolled up to (kept across the wake's remount), or null at the bottom. */ + scrolledTo: number | null +} + +/** + * The pane just before a clearing replay of its own terminal (a resubscribe, e.g. while the VM + * suspends): that replay keeps only the host's 1000 rows. One per pane, replaced, never piled up. + */ +type PreReplaySnapshot = { ptyId: string; at: number; aboveScreen: string; screen: string } +const preReplay = new Map() +/** The host's subscribe/recovery replay keeps this many rows of history. */ +const HOST_REPLAY_ROWS = 1000 +/** A sleep that follows a resubscribe within this long uses what the pane held before it. */ +const PRE_REPLAY_MAX_AGE_MS = 10 * 60_000 + +// Keyed by the pane's durable leaf id; cleared once restored, or when the pane goes away. +const SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') +const pending = new Map() + +export type ScrollbackSource = { + rows: number + buffer: { + normal: RestoreBuffer & { baseY: number } + active?: { viewportY: number; baseY: number } + } +} + +function lastTextRow(buffer: RestoreBuffer): number { + for (let y = buffer.length - 1; y >= 0; y--) { + const line = buffer.getLine(y) + for (let x = 0; line && x < line.length; x++) { + const chars = line.getCell(x)?.getChars() ?? '' + if (chars !== '' && chars !== ' ') { + return y + } + } + } + return -1 +} + +/** + * While asleep (or waking), before the new terminal attaches: keep the pane's normal-buffer output. + * A shell pane keeps its visible rows too (its last output is usually on screen); an agent or + * full-screen pane keeps only the scrollback above its screen, so no frozen TUI frame comes back. + * With nothing in the pane (never mounted, app or renderer restarted while asleep), the plain-text + * read-only copy saved at sleep stands in, uncoloured. + */ +export function captureWakeScrollback( + leafId: string, + terminal: ScrollbackSource, + options: { + /** When the workspace went to sleep; null when unknown (the first capture's time stands in). */ + sleptAt: number | null + agentPane: boolean + maxRows: number + readOnlyCopy?: () => Promise + /** The pane's terminal before the sleep; a replay for any other one is the woken terminal's. */ + ptyId?: string | null + } +): void { + const existing = pending.get(leafId) + // One capture per sleep, the earliest: by "asleep" the transport may already have resubscribed + // and replaced the buffer with the host's 1000-row snapshot. A later look only refines the time. + // An armed (woken) but untaken capture is a past sleep's; a new sleep captures afresh. + if (existing && !existing.armed) { + if (options.sleptAt !== null) { + existing.sleptAt = options.sleptAt + } + return + } + const normal = terminal.buffer.normal + const end = options.agentPane ? normal.baseY : lastTextRow(normal) + 1 + let text = renderBufferRows(normal, Math.max(0, end - options.maxRows), end) + // A sleep nobody clicked here (a teammate, idle auto-sleep): the VM was already going down when + // the pane heard, and a resubscribe may have cut it to the host's 1000 rows. What it held just + // before that replay is the fuller copy. + const before = preReplay.get(leafId) + if (before && before.ptyId === options.ptyId && Date.now() - before.at < PRE_REPLAY_MAX_AGE_MS) { + const fuller = options.agentPane + ? before.aboveScreen + : [before.aboveScreen, before.screen].filter((part) => part !== '').join('\r\n') + if (fuller.split('\r\n').length > text.split('\r\n').length) { + text = fuller.split('\r\n').slice(-options.maxRows).join('\r\n') + } + } + preReplay.delete(leafId) + const active = terminal.buffer.active + const entry: PendingHistory = { + sleptAt: options.sleptAt ?? Date.now(), + armed: false, + ptyId: options.ptyId ?? null, + text: text.replace(SGR, '').trim() === '' ? null : text, + scrolledTo: active && active.viewportY < active.baseY ? active.viewportY : null + } + pending.set(leafId, entry) + if (entry.text !== null || !options.readOnlyCopy) { + return + } + void options + .readOnlyCopy() + .then((lines) => { + if (pending.get(leafId) !== entry || !lines) { + return + } + const kept = options.agentPane + ? lines.slice(0, Math.max(0, lines.length - terminal.rows)) + : lines + const plain = kept.slice(-options.maxRows) + entry.text = plain.join('').trim() === '' ? null : plain.join('\r\n') + }) + .catch(() => undefined) +} + +/** The divider, dim, between the restored output and the woken terminal. */ +export function wakeDividerLine(sleptAt: number, wokeAt: number): string { + const label = translate( + 'orcaCloudWorkspace.terminal.sleptRangeDivider', + 'Slept {{range}} · processes stopped', + { range: formatSleptRange(sleptAt, wokeAt) } + ) + return `\x1b[0;2m── ${label} ──\x1b[0m` +} + +/** The workspace is waking or live: these panes' next clearing replay restores their output. */ +export function armWakeScrollback(leafIds: Iterable): void { + for (const id of leafIds) { + const entry = pending.get(id) + if (entry) { + entry.armed = true + } + } +} + +/** + * What a freshly mounted pane shows until its woken snapshot's restore: the kept output alone + * (text + SGR only), or null when there is none. + */ +export function wakeScrollbackPreview(leafId: string): string | null { + const text = pending.get(leafId)?.text ?? null + return text === null ? null : `\x1b[0m${sanitizeRestoredText(text)}\x1b[0m` +} + +/** The pane has pre-sleep output waiting to be put back after its wake. */ +export function hasPendingWakeScrollback(leafId: string): boolean { + return (pending.get(leafId)?.text ?? null) !== null +} + +/** The pane closed or its workspace was deleted. */ +export function forgetWakeScrollback(leafIds: Iterable): void { + for (const id of leafIds) { + pending.delete(id) + preReplay.delete(id) + } +} + +/** + * Before a clearing replay on an Orca Cloud pane with no capture pending: keep what the pane holds, + * if it holds more than the host's replay keeps. Rendered in chunks, off the input path. + */ +export function noteBeforeCloudReplay( + leafId: string, + terminal: ScrollbackSource, + ptyId: string | null, + isCloudPane: (ptyId: string) => boolean = (id) => + isOrcaCloudEnvironmentId(parseRemoteRuntimePtyId(id)?.environmentId ?? '') +): Promise | null { + // Decided synchronously, cheapest facts first: a skipped pane adds no await to the replay path. + if (!ptyId || pending.has(leafId) || !isCloudPane(ptyId)) { + return null + } + const normal = terminal.buffer?.normal + if (!normal || normal.length - terminal.rows <= HOST_REPLAY_ROWS) { + return null + } + return (async () => { + const aboveScreen = await renderBufferRowsAsync(normal, 0, normal.baseY) + const screen = renderBufferRows(normal, normal.baseY, lastTextRow(normal) + 1) + preReplay.set(leafId, { ptyId, at: Date.now(), aboveScreen, screen }) + })() +} + +/** The person scrolled the waking pane: where to put them back after the restore. */ +export function noteWakeViewport( + leafId: string, + terminal: { buffer: { active: { viewportY: number; baseY: number } } } +): void { + const entry = pending.get(leafId) + if (entry) { + const { viewportY, baseY } = terminal.buffer.active + entry.scrolledTo = viewportY < baseY ? viewportY : null + } +} + +type WokenTerminal = { + rows: number + buffer: { + active: RestoreBuffer & { + type: string + viewportY: number + baseY: number + cursorX: number + cursorY: number + } + } + scrollToLine: (line: number) => void + /** Drops the scrollback (xterm's own API; an ED3 sent through the replay writer may not). */ + clear?: () => void +} + +/** A restore started on the woken terminal's snapshot, finished once that snapshot painted. */ +export type WakeScrollbackRestore = { + /** The restored output (text + SGR only) and its divider. */ + block: string + /** Where a person who had scrolled up was, or null when pinned to the bottom. */ + scrolledTo: number | null +} + +/** + * The woken terminal's snapshot is about to paint: takes (and forgets) the pane's armed restore. + * Null when there is none, or nothing to restore (then nothing is said either). + */ +export function beginWakeScrollbackRestore( + leafId: string, + terminal: WokenTerminal, + wokeAt = Date.now(), + /** The terminal this snapshot is for. */ + ptyId: string | null = null +): WakeScrollbackRestore | null { + const entry = pending.get(leafId) + // Armed by the workspace leaving "asleep", or (sooner, with no race against React) by the + // snapshot being for a new terminal: a cold restore while asleep is for the old one. + const newTerminal = entry?.ptyId != null && ptyId !== null && ptyId !== entry.ptyId + if (!entry || !(entry.armed || newTerminal)) { + return null + } + pending.delete(leafId) + if (entry.text === null) { + return null + } + const { viewportY, baseY } = terminal.buffer.active + return { + // A remount on wake starts at the bottom: the person's scroll is the one noted before it. + scrolledTo: viewportY < baseY ? viewportY : entry.scrolledTo, + block: `\x1b[0m${sanitizeRestoredText(entry.text)}\x1b[0m\r\n${wakeDividerLine(entry.sleptAt, wokeAt)}` + } +} + +function rowHasText(buffer: RestoreBuffer, y: number): boolean { + const line = buffer.getLine(y) + for (let x = 0; line && x < line.length; x++) { + const chars = line.getCell(x)?.getChars() ?? '' + if (chars !== '' && chars !== ' ') { + return true + } + } + return false +} + +/** + * Appended to the snapshot's own clear when a restore follows: holds rendering (DEC 2026) from that + * clear until the rewrite, so no frame shows a blank pane or the woken prompt without its history. + */ +export const HOLD_RENDER_UNTIL_RESTORED = '\x1b[?2026h' +const RELEASE_RENDER = '\x1b[?2026l' + +/** + * The client-side rewrite once the snapshot painted: the restored output, the divider, then the + * woken screen's rows, with the cursor put back where the snapshot left it (relative moves only). + * Null on the alternate screen (a full-screen app owns the pane; its normal buffer is not shown). + */ +export function composeWakeRestore(block: string, terminal: WokenTerminal): string | null { + const active = terminal.buffer.active + if (active.type !== 'normal') { + return null + } + let last = active.cursorY + for (let row = terminal.rows - 1; row > active.cursorY; row--) { + if (rowHasText(active, active.baseY + row)) { + last = row + break + } + } + const screen = renderBufferRows(active, active.baseY, active.baseY + last + 1) + const up = last - active.cursorY + return `\x1b[2J\x1b[3J\x1b[H${block}\r\n${screen}\x1b[0m${up > 0 ? `\x1b[${up}A` : ''}\x1b[${active.cursorX + 1}G` +} + +/** + * Writes the rewrite through `write` (the replay writer: client-side, under the replay guard; + * nothing reaches the PTY). Stays pinned to the bottom; a person who had scrolled up keeps their place. + */ +export async function finishWakeScrollbackRestore( + restore: WakeScrollbackRestore, + terminal: WokenTerminal, + write: (data: string) => Promise +): Promise { + const data = composeWakeRestore(restore.block, terminal) + if (data !== null) { + // The woken screen is read; drop everything above it (a remount's preview, the old image) so + // the rewrite below is the pane's only copy of its history. + terminal.clear?.() + } + // Always release the render hold, restored or not. + await write(`${data ?? ''}${RELEASE_RENDER}`) + if (data === null) { + return false + } + if (restore.scrolledTo !== null) { + terminal.scrollToLine(restore.scrolledTo) + } + return true +} + +/** Tests only. */ +export function resetWakeScrollbackForTests(): void { + pending.clear() + preReplay.clear() +}