mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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 &&
|
||||
|
||||
+10
@@ -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()
|
||||
}
|
||||
|
||||
+45
@@ -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')
|
||||
})
|
||||
})
|
||||
+12
-1
@@ -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()
|
||||
}
|
||||
|
||||
+50
-2
@@ -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),
|
||||
|
||||
+76
-11
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user