Merge pull request #455 from nwparker/cloud/wake-scrollback

feat(cloud): WAKE-SCROLLBACK keep a woken terminal's pre-sleep output above a 'Slept … · processes stopped' divider
This commit is contained in:
Neil
2026-11-11 10:32:59 -08:00
committed by GitHub
15 changed files with 1448 additions and 16 deletions
@@ -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<void> {
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<HTMLDivElement>(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 (
<div className="overflow-hidden rounded-md border border-border bg-background p-2">
<div ref={host} />
</div>
)
}
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: () => <WokeTerminal id="fixture-shell" sleeps={[{ before: testRun, after: PROMPT }]} />
})
/** Two sleeps: one divider each, in order. */
export const twoSleeps = defineFixture({
width: 720,
render: () => (
<WokeTerminal
id="fixture-two"
sleeps={[
{ before: `${PROMPT}echo first\r\nfirst\r\n${PROMPT}`, after: PROMPT },
{ before: `echo second\r\nsecond\r\n${PROMPT}`, after: PROMPT }
]}
/>
)
})
/** An agent pane: only the scrollback above Claude's screen; never its frozen input box. */
export const agentPaneScrollbackOnly = defineFixture({
width: 720,
render: () => (
<WokeTerminal
id="fixture-agent"
sleeps={[
{
before: [
`${PROMPT}git log --oneline -3`,
'\x1b[33m4f1c2ab\x1b[0m fix: retry the upload',
'\x1b[33m9d0e771\x1b[0m feat: logging util',
'\x1b[33m2b6a0c4\x1b[0m chore: bump deps',
`${PROMPT}claude`,
...Array.from({ length: 8 }, () => ''),
'╭──────────────────────────────────────────╮',
'│ > 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')
}
]}
/>
)
})
@@ -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 &&
@@ -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()
}
@@ -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<number>() } }
}
// 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<void>((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')
})
})
@@ -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()
}
@@ -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),
@@ -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(
+2 -1
View File
@@ -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.",
@@ -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<readonly string[] | null> {
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
})
}
@@ -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'
)
})
})
@@ -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'
}
@@ -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<void> {
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())
}
})
})
@@ -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<string> {
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<void>((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, '–')
}
@@ -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<string, string>) =>
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<void> {
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<boolean> {
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()
})
})
@@ -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<string, PreReplaySnapshot>()
/** 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<string, PendingHistory>()
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<readonly string[] | null>
/** 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<string>): 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<string>): 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<void> | 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<void>
): Promise<boolean> {
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()
}