Persist terminal scrollback outside session JSON

This commit is contained in:
Neil 2026-06-04 01:31:38 -07:00 committed by GitHub
parent 68b8070db5
commit b4188b4861
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 611 additions and 27 deletions

View File

@ -24,4 +24,12 @@ export function registerSessionHandlers(store: Store): void {
store.flush()
event.returnValue = true
})
ipcMain.on(
'session:read-terminal-scrollback-sync',
(event, args: { ref?: unknown } | undefined) => {
event.returnValue =
typeof args?.ref === 'string' ? store.readTerminalScrollbackSnapshot(args.ref) : null
}
)
}

View File

@ -24,6 +24,7 @@ import type {
WorkspaceSessionState
} from '../shared/types'
import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id'
import { TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT } from '../shared/terminal-scrollback-limits'
import { MAX_BROWSER_HISTORY_ENTRIES } from '../shared/workspace-session-browser-history'
import { ONBOARDING_FINAL_STEP, ONBOARDING_FLOW_VERSION } from '../shared/constants'
@ -3504,7 +3505,7 @@ describe('Store', () => {
).toBeUndefined()
})
it('strips local terminal scrollback buffers when setting workspace session', async () => {
it('stores remote terminal scrollback out of workspace session JSON', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'local-repo', connectionId: null }))
store.addRepo(makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' }))
@ -3516,9 +3517,12 @@ describe('Store', () => {
expect(session.terminalLayoutsByTabId['local-tab'].ptyIdsByLeafId).toEqual({
[TEST_LEAF_1]: 'local-pty'
})
expect(session.terminalLayoutsByTabId['remote-tab'].buffersByLeafId).toEqual({
[TEST_LEAF_2]: 'remote-scrollback'
expect(session.terminalLayoutsByTabId['remote-tab'].buffersByLeafId).toBeUndefined()
expect(session.terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId).toEqual({
[TEST_LEAF_2]: expect.stringMatching(/^v1-[0-9a-f]{32}$/)
})
const ref = session.terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId?.[TEST_LEAF_2]
expect(ref ? store.readTerminalScrollbackSnapshot(ref) : null).toBe('remote-scrollback')
})
it('caps oversized browser history when setting workspace session', async () => {
@ -3535,7 +3539,7 @@ describe('Store', () => {
expect(prunedBytes).toBeLessThan(oversizedBytes / 2)
})
it('keeps terminal scrollback buffers when the repo catalog is not hydrated yet', async () => {
it('stores maybe-remote terminal scrollback out of workspace session JSON', async () => {
const store = await createStore()
store.setWorkspaceSession({
@ -3563,9 +3567,65 @@ describe('Store', () => {
expect(
store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].buffersByLeafId
).toBeUndefined()
expect(
store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId
).toEqual({
[TEST_LEAF_2]: 'maybe-remote-scrollback'
[TEST_LEAF_2]: expect.stringMatching(/^v1-[0-9a-f]{32}$/)
})
const ref =
store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId?.[
TEST_LEAF_2
]
expect(ref ? store.readTerminalScrollbackSnapshot(ref) : null).toBe('maybe-remote-scrollback')
})
it('deletes terminal scrollback snapshot files when refs leave the workspace session', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' }))
const session = makeSessionWithTerminalBuffers()
store.setWorkspaceSession({
...session,
tabsByWorktree: { 'remote-repo::/remote': session.tabsByWorktree['remote-repo::/remote'] },
terminalLayoutsByTabId: { 'remote-tab': session.terminalLayoutsByTabId['remote-tab'] }
})
const ref =
store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId?.[
TEST_LEAF_2
]
expect(ref).toEqual(expect.stringMatching(/^v1-[0-9a-f]{32}$/))
if (!ref) {
throw new Error('expected scrollback snapshot ref')
}
expect(existsSync(join(testState.dir, 'terminal-scrollback', `${ref}.bin`))).toBe(true)
store.setWorkspaceSession({
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {}
})
expect(existsSync(join(testState.dir, 'terminal-scrollback', `${ref}.bin`))).toBe(false)
})
it('reads only the replay tail from oversized terminal scrollback snapshots', async () => {
const store = await createStore()
const ref = 'v1-00000000000000000000000000000000'
const snapshotDir = join(testState.dir, 'terminal-scrollback')
mkdirSync(snapshotDir, { recursive: true })
writeFileSync(
join(snapshotDir, `${ref}.bin`),
`stale-prefix-${'x'.repeat(TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT)}tail`,
'utf-8'
)
const buffer = store.readTerminalScrollbackSnapshot(ref)
expect(buffer).toHaveLength(TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT)
expect(buffer?.startsWith('stale-prefix')).toBe(false)
expect(buffer?.endsWith('tail')).toBe(true)
})
it('strips legacy local terminal scrollback buffers when loading workspace session', async () => {
@ -3585,9 +3645,12 @@ describe('Store', () => {
const store = await createStore()
const session = store.getWorkspaceSession()
expect(session.terminalLayoutsByTabId['local-tab'].buffersByLeafId).toBeUndefined()
expect(session.terminalLayoutsByTabId['remote-tab'].buffersByLeafId).toEqual({
[TEST_LEAF_2]: 'remote-scrollback'
expect(session.terminalLayoutsByTabId['remote-tab'].buffersByLeafId).toBeUndefined()
expect(session.terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId).toEqual({
[TEST_LEAF_2]: expect.stringMatching(/^v1-[0-9a-f]{32}$/)
})
const ref = session.terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId?.[TEST_LEAF_2]
expect(ref ? store.readTerminalScrollbackSnapshot(ref) : null).toBe('remote-scrollback')
})
it('caps oversized legacy browser history when loading workspace session', async () => {
@ -4646,7 +4709,12 @@ describe('Store', () => {
expect(layout.activeLeafId).toBe(TEST_LEAF_1)
expect(layout.expandedLeafId).toBeNull()
expect(layout.ptyIdsByLeafId).toEqual({ [TEST_LEAF_1]: 'daemon-pty' })
expect(layout.buffersByLeafId).toEqual({ [TEST_LEAF_1]: 'Current buffer' })
expect(layout.buffersByLeafId).toBeUndefined()
expect(layout.scrollbackRefsByLeafId).toEqual({
[TEST_LEAF_1]: expect.stringMatching(/^v1-[0-9a-f]{32}$/)
})
const ref = layout.scrollbackRefsByLeafId?.[TEST_LEAF_1]
expect(ref ? store.readTerminalScrollbackSnapshot(ref) : null).toBe('Current buffer')
expect(layout.titlesByLeafId).toEqual({ [TEST_LEAF_1]: 'Current' })
expect(session.tabsByWorktree.wt1[0].ptyId).toBe('daemon-pty')
})

View File

@ -123,6 +123,12 @@ import {
sourceControlAiSettingsFromLegacy
} from '../shared/source-control-ai'
import { normalizeDisabledTuiAgents } from '../shared/tui-agent-selection'
import {
collectTerminalScrollbackSnapshotRefs,
deleteTerminalScrollbackSnapshotSync,
migrateWorkspaceSessionTerminalScrollbackSnapshots,
readTerminalScrollbackSnapshotSync
} from './terminal-scrollback-snapshots'
function encrypt(plaintext: string): string {
if (!plaintext || !safeStorage.isEncryptionAvailable()) {
@ -1167,6 +1173,11 @@ function normalizeTerminalLayoutSnapshotForPersistence(
leafIdByInputLeafId,
duplicatedInputLeafIds
)
const scrollbackRefsByLeafId = remapLeafRecordForPersistence(
inputSnapshot.scrollbackRefsByLeafId,
leafIdByInputLeafId,
duplicatedInputLeafIds
)
const titlesByLeafId = remapLeafRecordForPersistence(
inputSnapshot.titlesByLeafId,
leafIdByInputLeafId,
@ -1175,6 +1186,7 @@ function normalizeTerminalLayoutSnapshotForPersistence(
const recordsChanged =
!leafRecordEquivalent(inputSnapshot.ptyIdsByLeafId, ptyIdsByLeafId) ||
!leafRecordEquivalent(inputSnapshot.buffersByLeafId, buffersByLeafId) ||
!leafRecordEquivalent(inputSnapshot.scrollbackRefsByLeafId, scrollbackRefsByLeafId) ||
!leafRecordEquivalent(inputSnapshot.titlesByLeafId, titlesByLeafId)
const metadataChanged =
activeLeafId !== inputSnapshot.activeLeafId || expandedLeafId !== inputSnapshot.expandedLeafId
@ -1184,6 +1196,7 @@ function normalizeTerminalLayoutSnapshotForPersistence(
const {
ptyIdsByLeafId: _oldPtyIdsByLeafId,
buffersByLeafId: _oldBuffersByLeafId,
scrollbackRefsByLeafId: _oldScrollbackRefsByLeafId,
titlesByLeafId: _oldTitlesByLeafId,
...snapshotWithoutLeafRecords
} = inputSnapshot
@ -1195,6 +1208,7 @@ function normalizeTerminalLayoutSnapshotForPersistence(
expandedLeafId,
...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {}),
...(buffersByLeafId ? { buffersByLeafId } : {}),
...(scrollbackRefsByLeafId ? { scrollbackRefsByLeafId } : {}),
...(titlesByLeafId ? { titlesByLeafId } : {})
},
changed: true,
@ -1526,6 +1540,21 @@ function cloneWorkspaceSessionState(session: WorkspaceSessionState): WorkspaceSe
return structuredClone(session)
}
function deleteRemovedTerminalScrollbackSnapshots(
prior: WorkspaceSessionState | undefined,
next: WorkspaceSessionState
): void {
if (!prior) {
return
}
const nextRefs = collectTerminalScrollbackSnapshotRefs(next)
for (const ref of collectTerminalScrollbackSnapshotRefs(prior)) {
if (!nextRefs.has(ref)) {
deleteTerminalScrollbackSnapshotSync(ref)
}
}
}
export class Store {
private state: PersistedState
private writeTimer: ReturnType<typeof setTimeout> | null = null
@ -2112,12 +2141,18 @@ export class Store {
result = getDefaultPersistedState(homedir())
}
const workspaceSession = pruneWorkspaceSessionBrowserHistory(
pruneLocalTerminalScrollbackBuffers(result.workspaceSession, result.repos)
)
const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots(workspaceSession)
if (migratedScrollback.changed) {
this.loadNeedsSave = true
}
result = {
...result,
repos: clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []),
workspaceSession: pruneWorkspaceSessionBrowserHistory(
pruneLocalTerminalScrollbackBuffers(result.workspaceSession, result.repos)
)
workspaceSession: migratedScrollback.session
}
return this.migrateTelemetry(result, fileExistedOnLoad)
@ -3211,6 +3246,10 @@ export class Store {
return this.state.workspaceSession ?? getDefaultWorkspaceSession()
}
readTerminalScrollbackSnapshot(ref: string): string | null {
return readTerminalScrollbackSnapshotSync(ref)
}
/** Resolve the worktree a terminal tab belongs to, from the session's
* tabworktree map. More reliable than agent-echoed hook fields. */
getWorktreeIdForTab(tabId: string): string | undefined {
@ -3336,6 +3375,11 @@ export class Store {
layout.buffersByLeafId,
liveLeafIds
)
const scrollbackRefsByLeafId = preserveMissingLeafRecordEntries(
priorLayout.scrollbackRefsByLeafId,
layout.scrollbackRefsByLeafId,
liveLeafIds
)
const titlesByLeafId = preserveMissingLeafRecordEntries(
priorLayout.titlesByLeafId,
layout.titlesByLeafId,
@ -3344,12 +3388,19 @@ export class Store {
if (buffersByLeafId) {
layout.buffersByLeafId = buffersByLeafId
}
if (scrollbackRefsByLeafId) {
layout.scrollbackRefsByLeafId = scrollbackRefsByLeafId
}
if (titlesByLeafId) {
layout.titlesByLeafId = titlesByLeafId
}
}
}
}
session = pruneLocalTerminalScrollbackBuffers(session, this.state.repos)
const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots(session)
session = migratedScrollback.session
deleteRemovedTerminalScrollbackSnapshots(prior, session)
this.state.workspaceSession = session
this.scheduleSave()
}

View File

@ -0,0 +1,187 @@
import { createHash } from 'crypto'
import {
closeSync,
mkdirSync,
openSync,
readSync,
renameSync,
rmSync,
statSync,
writeFileSync
} from 'fs'
import { join } from 'path'
import { app } from 'electron'
import type { WorkspaceSessionState } from '../shared/types'
import {
TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT,
TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT
} from '../shared/terminal-scrollback-limits'
const SNAPSHOT_DIR_NAME = 'terminal-scrollback'
const REF_PREFIX = 'v1'
function getSnapshotRoot(): string {
return join(app.getPath('userData'), SNAPSHOT_DIR_NAME)
}
export function makeTerminalScrollbackSnapshotRef(tabId: string, leafId: string): string {
const hash = createHash('sha256').update(`${tabId}\0${leafId}`).digest('hex').slice(0, 32)
return `${REF_PREFIX}-${hash}`
}
function snapshotPath(ref: string): string | null {
if (!/^v1-[0-9a-f]{32}$/.test(ref)) {
return null
}
return join(getSnapshotRoot(), `${ref}.bin`)
}
function trailingUtf8Bytes(value: string, maxBytes: number): Buffer {
const bytes = Buffer.from(value, 'utf-8')
if (bytes.length <= maxBytes) {
return bytes
}
let start = bytes.length - maxBytes
while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) {
start++
}
return bytes.subarray(start)
}
function readTrailingUtf8(path: string, maxBytes: number): string {
const size = statSync(path).size
const length = Math.min(size, maxBytes)
if (length <= 0) {
return ''
}
const bytes = Buffer.allocUnsafe(length)
const fd = openSync(path, 'r')
try {
readSync(fd, bytes, 0, length, size - length)
} finally {
closeSync(fd)
}
let start = 0
while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) {
start++
}
return bytes.subarray(start).toString('utf-8')
}
export function writeTerminalScrollbackSnapshotSync(args: {
tabId: string
leafId: string
buffer: string
}): string | null {
if (!args.buffer) {
return null
}
const ref = makeTerminalScrollbackSnapshotRef(args.tabId, args.leafId)
const path = snapshotPath(ref)
if (!path) {
return null
}
try {
mkdirSync(getSnapshotRoot(), { recursive: true, mode: 0o700 })
const tmpPath = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
const bytes = trailingUtf8Bytes(args.buffer, TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT)
let renamed = false
try {
writeFileSync(tmpPath, bytes, { mode: 0o600 })
renameSync(tmpPath, path)
renamed = true
} finally {
if (!renamed) {
rmSync(tmpPath, { force: true })
}
}
return ref
} catch (err) {
console.warn(
`[terminal-scrollback] Failed to write snapshot: ${err instanceof Error ? err.message : String(err)}`
)
return null
}
}
export function readTerminalScrollbackSnapshotSync(ref: string): string | null {
const path = snapshotPath(ref)
if (!path) {
return null
}
try {
return readTrailingUtf8(path, TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT)
} catch {
return null
}
}
export function deleteTerminalScrollbackSnapshotSync(ref: string): void {
const path = snapshotPath(ref)
if (!path) {
return
}
try {
rmSync(path, { force: true })
} catch {
// Best-effort cleanup; stale refs are harmless and bounded by per-file caps.
}
}
export function collectTerminalScrollbackSnapshotRefs(session: WorkspaceSessionState): Set<string> {
const refs = new Set<string>()
for (const layout of Object.values(session.terminalLayoutsByTabId ?? {})) {
for (const ref of Object.values(layout.scrollbackRefsByLeafId ?? {})) {
refs.add(ref)
}
}
return refs
}
export function migrateWorkspaceSessionTerminalScrollbackSnapshots(
session: WorkspaceSessionState
): { session: WorkspaceSessionState; changed: boolean } {
let terminalLayoutsByTabId: WorkspaceSessionState['terminalLayoutsByTabId'] | null = null
for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) {
const buffers = layout.buffersByLeafId
if (!buffers || Object.keys(buffers).length === 0) {
continue
}
const refs = { ...layout.scrollbackRefsByLeafId }
const remainingBuffers: Record<string, string> = {}
let layoutChanged = false
for (const [leafId, buffer] of Object.entries(buffers)) {
const ref = writeTerminalScrollbackSnapshotSync({ tabId, leafId, buffer })
if (ref) {
refs[leafId] = ref
layoutChanged = true
} else {
remainingBuffers[leafId] = buffer
if (refs[leafId]) {
delete refs[leafId]
layoutChanged = true
}
}
}
if (!layoutChanged) {
continue
}
terminalLayoutsByTabId ??= { ...session.terminalLayoutsByTabId }
const nextLayout = { ...layout }
if (Object.keys(refs).length > 0) {
nextLayout.scrollbackRefsByLeafId = refs
} else {
delete nextLayout.scrollbackRefsByLeafId
}
if (Object.keys(remainingBuffers).length > 0) {
nextLayout.buffersByLeafId = remainingBuffers
} else {
delete nextLayout.buffersByLeafId
}
terminalLayoutsByTabId[tabId] = nextLayout
}
if (!terminalLayoutsByTabId) {
return { session, changed: false }
}
return { session: { ...session, terminalLayoutsByTabId }, changed: true }
}

View File

@ -1710,6 +1710,7 @@ export type PreloadApi = {
get: () => Promise<WorkspaceSessionState>
set: (args: WorkspaceSessionState) => Promise<void>
patch: (args: WorkspaceSessionPatch) => Promise<void>
readTerminalScrollback: (args: { ref: string }) => string | null
setSync: (args: WorkspaceSessionState) => void
}
remoteWorkspace: {

View File

@ -2075,6 +2075,8 @@ const api = {
get: () => ipcRenderer.invoke('session:get'),
set: (args) => ipcRenderer.invoke('session:set', args),
patch: (args) => ipcRenderer.invoke('session:patch', args),
readTerminalScrollback: (args) =>
ipcRenderer.sendSync('session:read-terminal-scrollback-sync', args),
/** Synchronous session save for beforeunload — blocks until flushed to disk. */
setSync: (args) => {
ipcRenderer.sendSync('session:set-sync', args)

View File

@ -337,6 +337,7 @@ export default function TerminalPane({
const paneTitlesRef = useRef<Record<number, string>>({})
paneTitlesRef.current = paneTitles
const removedTitleLeafIdsRef = useRef<Set<string>>(new Set())
const clearedScrollbackLeafIdsRef = useRef<Set<string>>(new Set())
const [renamingPaneId, setRenamingPaneId] = useState<number | null>(null)
const [renameValue, setRenameValue] = useState('')
const renameInputRef = useRef<HTMLInputElement>(null)
@ -513,17 +514,29 @@ export default function TerminalPane({
const existing = useAppStore.getState().terminalLayoutsByTabId[tabId]
const currentPanes = manager.getPanes()
const currentLeafIds = new Set(currentPanes.map((p) => p.leafId))
const clearedScrollbackLeafIds = clearedScrollbackLeafIdsRef.current
const scrollbackPreserveLeafIds = new Set(
[...currentLeafIds].filter((leafId) => !clearedScrollbackLeafIds.has(leafId))
)
// Preserve existing buffersByLeafId so layout-only persists (resize, split,
// reorder) don't clobber previously captured scrollback. Drop entries for
// leaves that no longer exist.
const mergedBuffers = mergeCapturedLeafState({
prior: existing?.buffersByLeafId,
fresh: {},
currentLeafIds
currentLeafIds: scrollbackPreserveLeafIds
})
if (Object.keys(mergedBuffers).length > 0) {
layout.buffersByLeafId = mergedBuffers
}
const mergedScrollbackRefs = mergeCapturedLeafState({
prior: existing?.scrollbackRefsByLeafId,
fresh: {},
currentLeafIds: scrollbackPreserveLeafIds
})
if (Object.keys(mergedScrollbackRefs).length > 0) {
layout.scrollbackRefsByLeafId = mergedScrollbackRefs
}
// Why: between pane creation and the deferred rAF where PTYs actually
// attach, all transports have getPtyId() === null. The merge below
// preserves the *prior* snapshot's leaf→PTY mappings while still letting
@ -570,8 +583,20 @@ export default function TerminalPane({
layout.titlesByLeafId = titlesByLeafId
}
setTabLayout(tabId, layout)
for (const leafId of currentLeafIds) {
clearedScrollbackLeafIds.delete(leafId)
}
}, [tabId, setTabLayout])
const clearPaneScrollback = useCallback(
(pane: ManagedPane): void => {
clearedScrollbackLeafIdsRef.current.add(pane.leafId)
pane.terminal.clear()
persistLayoutSnapshot()
},
[persistLayoutSnapshot]
)
useEffect(() => {
if (!terminalTab) {
return
@ -1058,6 +1083,7 @@ export default function TerminalPane({
setSearchOpen,
onSearchSelectedText: handleSearchSelectedText,
onRequestClosePane: handleRequestClosePane,
onClearPaneScrollback: clearPaneScrollback,
searchOpenRef,
searchStateRef,
macOptionAsAltRef,
@ -1381,9 +1407,13 @@ export default function TerminalPane({
existingLayout: existing,
// Why: beforeunload skips local/floating bytes because session payloads
// immediately prune them; worktree sleep keeps them as defense-in-depth.
captureBuffers: shouldCaptureScrollbackBuffers
captureBuffers: shouldCaptureScrollbackBuffers,
clearedScrollbackLeafIds: clearedScrollbackLeafIdsRef.current
})
setTabLayout(tabId, layout)
for (const pane of panes) {
clearedScrollbackLeafIdsRef.current.delete(pane.leafId)
}
}
shutdownBufferCaptures.set(tabId, captureBuffers)
return () => {
@ -1582,6 +1612,7 @@ export default function TerminalPane({
fallbackCwd: cwd ?? '',
toggleExpandPane,
onRequestClosePane: handleRequestClosePane,
onClearPaneScrollback: clearPaneScrollback,
onSetTitle: handleStartRename,
onPasteError: setTerminalError,
onAgentSessionForkReady: setAgentSessionFork,

View File

@ -2,7 +2,7 @@
* precedence in one ordered handler so shell input, pane commands, search, and
* split actions do not race across separate window listeners. */
import { useEffect } from 'react'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
import type { PtyTransport } from './pty-transport'
import { resolveTerminalShortcutAction } from './terminal-shortcut-policy'
import type { MacOptionAsAlt } from './terminal-shortcut-policy'
@ -120,6 +120,7 @@ type KeyboardHandlersDeps = {
setSearchOpen: React.Dispatch<React.SetStateAction<boolean>>
onSearchSelectedText: (text: string) => void
onRequestClosePane: (paneId: number) => void
onClearPaneScrollback: (pane: ManagedPane) => void
searchOpenRef: React.RefObject<boolean>
searchStateRef: React.RefObject<SearchState>
macOptionAsAltRef: React.RefObject<MacOptionAsAlt>
@ -143,6 +144,7 @@ export function useTerminalKeyboardShortcuts({
setSearchOpen,
onSearchSelectedText,
onRequestClosePane,
onClearPaneScrollback,
searchOpenRef,
searchStateRef,
macOptionAsAltRef,
@ -289,7 +291,7 @@ export function useTerminalKeyboardShortcuts({
e.stopImmediatePropagation()
const pane = manager.getActivePane() ?? manager.getPanes()[0]
if (pane) {
pane.terminal.clear()
onClearPaneScrollback(pane)
}
return
}
@ -446,6 +448,7 @@ export function useTerminalKeyboardShortcuts({
setSearchOpen,
onSearchSelectedText,
onRequestClosePane,
onClearPaneScrollback,
searchOpenRef,
searchStateRef,
macOptionAsAltRef,

View File

@ -107,10 +107,12 @@ export function normalizeTerminalLayoutSnapshot(
: null
const ptyIdsByLeafId = remapLeafRecord(snapshot.ptyIdsByLeafId, rewrite)
const buffersByLeafId = remapLeafRecord(snapshot.buffersByLeafId, rewrite)
const scrollbackRefsByLeafId = remapLeafRecord(snapshot.scrollbackRefsByLeafId, rewrite)
const titlesByLeafId = remapLeafRecord(snapshot.titlesByLeafId, rewrite)
const {
ptyIdsByLeafId: _oldPtyIdsByLeafId,
buffersByLeafId: _oldBuffersByLeafId,
scrollbackRefsByLeafId: _oldScrollbackRefsByLeafId,
titlesByLeafId: _oldTitlesByLeafId,
...snapshotWithoutLeafRecords
} = snapshot
@ -122,6 +124,7 @@ export function normalizeTerminalLayoutSnapshot(
expandedLeafId,
...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {}),
...(buffersByLeafId ? { buffersByLeafId } : {}),
...(scrollbackRefsByLeafId ? { scrollbackRefsByLeafId } : {}),
...(titlesByLeafId ? { titlesByLeafId } : {})
},
changed: true

View File

@ -137,4 +137,42 @@ describe('captureTerminalShutdownLayout', () => {
expect(layout.ptyIdsByLeafId).toEqual({ [LEAF_ID]: 'pty-1' })
expect(layout.titlesByLeafId).toEqual({ [LEAF_ID]: 'local shell' })
})
it('does not preserve prior scrollback buffers or refs for a cleared leaf', async () => {
const { captureTerminalShutdownLayout } = await import('./terminal-shutdown-layout-capture')
const pane = {
id: 1,
leafId: LEAF_ID,
stablePaneId: LEAF_ID,
terminal: { options: { scrollback: 1_000 } },
serializeAddon: {
serialize: vi.fn(() => '')
}
}
const manager = {
getPanes: vi.fn(() => [pane]),
getActivePane: vi.fn(() => pane)
}
const layout = captureTerminalShutdownLayout({
manager: manager as never,
container: mockRootForPane(1),
expandedPaneId: null,
paneTransports: new Map([[1, { getPtyId: vi.fn(() => 'pty-1') }]]),
paneTitlesByPaneId: { 1: 'local shell' },
existingLayout: {
root: null,
activeLeafId: null,
expandedLeafId: null,
buffersByLeafId: { [LEAF_ID]: 'previous-scrollback' },
scrollbackRefsByLeafId: { [LEAF_ID]: 'v1-previous' }
},
clearedScrollbackLeafIds: new Set([LEAF_ID])
})
expect(layout.buffersByLeafId).toBeUndefined()
expect(layout.scrollbackRefsByLeafId).toBeUndefined()
expect(layout.ptyIdsByLeafId).toEqual({ [LEAF_ID]: 'pty-1' })
expect(layout.titlesByLeafId).toEqual({ [LEAF_ID]: 'local shell' })
})
})

View File

@ -4,8 +4,9 @@ import type { PtyTransport } from './pty-transport'
import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
import { serializeTerminalLayout } from './layout-serialization'
import { mergeCapturedLeafState } from './merge-captured-leaf-state'
import { TERMINAL_SCROLLBACK_SESSION_BUFFER_CHAR_LIMIT } from '../../../../shared/terminal-scrollback-limits'
const MAX_BUFFER_BYTES = 512 * 1024
const MAX_BUFFER_BYTES = TERMINAL_SCROLLBACK_SESSION_BUFFER_CHAR_LIMIT
type ShutdownPane = Pick<ManagedPane, 'id' | 'leafId' | 'terminal' | 'serializeAddon'>
@ -22,6 +23,20 @@ type CaptureTerminalShutdownLayoutArgs = {
paneTitlesByPaneId: Record<number, string>
existingLayout: TerminalLayoutSnapshot | undefined
captureBuffers?: boolean
clearedScrollbackLeafIds?: ReadonlySet<string>
}
function omitClearedLeafState(
record: Record<string, string> | undefined,
clearedLeafIds: ReadonlySet<string> | undefined
): Record<string, string> | undefined {
if (!record || !clearedLeafIds || clearedLeafIds.size === 0) {
return record
}
const next = Object.fromEntries(
Object.entries(record).filter(([leafId]) => !clearedLeafIds.has(leafId))
)
return Object.keys(next).length > 0 ? next : undefined
}
export function captureTerminalShutdownLayout({
@ -31,7 +46,8 @@ export function captureTerminalShutdownLayout({
paneTransports,
paneTitlesByPaneId,
existingLayout,
captureBuffers = true
captureBuffers = true,
clearedScrollbackLeafIds
}: CaptureTerminalShutdownLayoutArgs): TerminalLayoutSnapshot {
const panes = manager.getPanes()
const buffers: Record<string, string> = {}
@ -85,11 +101,16 @@ export function captureTerminalShutdownLayout({
const mergedBuffers = captureBuffers
? mergeCapturedLeafState({
prior: existingLayout?.buffersByLeafId,
prior: omitClearedLeafState(existingLayout?.buffersByLeafId, clearedScrollbackLeafIds),
fresh: buffers,
currentLeafIds
})
: {}
const mergedScrollbackRefs = mergeCapturedLeafState({
prior: omitClearedLeafState(existingLayout?.scrollbackRefsByLeafId, clearedScrollbackLeafIds),
fresh: {},
currentLeafIds
})
const mergedPtyIds = mergeCapturedLeafState({
prior: existingLayout?.ptyIdsByLeafId,
fresh: Object.fromEntries(ptyEntries),
@ -98,6 +119,9 @@ export function captureTerminalShutdownLayout({
if (Object.keys(mergedBuffers).length > 0) {
layout.buffersByLeafId = mergedBuffers
}
if (Object.keys(mergedScrollbackRefs).length > 0) {
layout.scrollbackRefsByLeafId = mergedScrollbackRefs
}
if (Object.keys(mergedPtyIds).length > 0) {
layout.ptyIdsByLeafId = mergedPtyIds
}

View File

@ -47,6 +47,7 @@ type UseTerminalPaneContextMenuDeps = {
fallbackCwd: string
toggleExpandPane: (paneId: number) => void
onRequestClosePane: (paneId: number) => void
onClearPaneScrollback: (pane: ManagedPane) => void
onSetTitle: (paneId: number) => void
onPasteError: (message: string) => void
onAgentSessionForkReady: (fork: PreparedAgentSessionFork) => void
@ -85,6 +86,7 @@ export function useTerminalPaneContextMenu({
fallbackCwd,
toggleExpandPane,
onRequestClosePane,
onClearPaneScrollback,
onSetTitle,
onPasteError,
onAgentSessionForkReady,
@ -248,7 +250,7 @@ export function useTerminalPaneContextMenu({
const onClearScreen = (): void => {
const pane = resolveMenuPane()
if (pane) {
pane.terminal.clear()
onClearPaneScrollback(pane)
}
}

View File

@ -205,6 +205,37 @@ function terminalSelectionExceedsPrimaryLimit(terminal: Terminal): boolean {
return cellEstimate > PRIMARY_SELECTION_MAX_LENGTH
}
function hydrateTerminalScrollbackRefs(layout: TerminalLayoutSnapshot): {
layout: TerminalLayoutSnapshot
hydrated: boolean
} {
const refs = layout.scrollbackRefsByLeafId
if (!refs || Object.keys(refs).length === 0) {
return { layout, hydrated: false }
}
const buffers = { ...layout.buffersByLeafId }
let hydrated = false
for (const [leafId, ref] of Object.entries(refs)) {
if (buffers[leafId] !== undefined) {
continue
}
try {
const buffer = window.api.session.readTerminalScrollback({ ref })
if (buffer) {
buffers[leafId] = buffer
hydrated = true
}
} catch {
// Best-effort restore; failed snapshot reads should not block terminal mount.
}
}
return hydrated
? { layout: { ...layout, buffersByLeafId: buffers }, hydrated }
: { layout, hydrated }
}
type SplitStartupPayload = { command: string; env?: Record<string, string> }
type SplitWithStartupDeps = {
@ -458,6 +489,11 @@ export function useTerminalPaneLifecycle({
initialLayoutRef.current = normalizedInitialLayout.snapshot
useAppStore.getState().setTabLayout(tabId, normalizedInitialLayout.snapshot)
}
const initialLayoutHadBuffers = Boolean(initialLayoutRef.current.buffersByLeafId)
const hydratedInitialScrollback = hydrateTerminalScrollbackRefs(initialLayoutRef.current)
if (hydratedInitialScrollback.hydrated) {
initialLayoutRef.current = hydratedInitialScrollback.layout
}
let shouldPersistLayout = false
const ptyDeps = {
tabId,
@ -989,12 +1025,18 @@ export function useTerminalPaneLifecycle({
}
const restoredPaneByLeafId = replayTerminalLayout(manager, initialLayoutRef.current, isActive)
restoreScrollbackBuffers(
manager,
initialLayoutRef.current.buffersByLeafId,
restoredPaneByLeafId,
replayingPanesRef
)
const restoredBuffers = initialLayoutRef.current.buffersByLeafId
restoreScrollbackBuffers(manager, restoredBuffers, restoredPaneByLeafId, replayingPanesRef)
if (restoredBuffers && initialLayoutRef.current.scrollbackRefsByLeafId) {
const layoutWithoutRestoredBuffers = { ...initialLayoutRef.current }
delete layoutWithoutRestoredBuffers.buffersByLeafId
initialLayoutRef.current = layoutWithoutRestoredBuffers
if (initialLayoutHadBuffers) {
// Why: raw replay bytes belong only to this mount. Drop legacy hydrated
// copies from Zustand so normal session writes stay ref-only.
useAppStore.getState().setTabLayout(tabId, layoutWithoutRestoredBuffers)
}
}
// Seed pane titles from the persisted snapshot using the same
// old-leafId → new-paneId mapping used for buffer restore.

View File

@ -159,6 +159,7 @@ describe('buildWorkspaceSessionPatch', () => {
activeLeafId: null,
expandedLeafId: null,
buffersByLeafId: { 'pane:1': 'serialized-local-scrollback' },
scrollbackRefsByLeafId: { 'pane:1': 'v1-local' },
ptyIdsByLeafId: { 'pane:1': 'pty-1' }
}
},
@ -177,6 +178,7 @@ describe('buildWorkspaceSessionPatch', () => {
)
expect('pendingActivationSpawn' in patch.tabsByWorktree![localWorktreeId][0]).toBe(false)
expect(patch.terminalLayoutsByTabId?.['tab-local'].buffersByLeafId).toBeUndefined()
expect(patch.terminalLayoutsByTabId?.['tab-local'].scrollbackRefsByLeafId).toBeUndefined()
})
it('keeps optional clearing keys in patches', () => {

View File

@ -196,6 +196,7 @@ describe('buildWorkspaceSessionPayload', () => {
activeLeafId: null,
expandedLeafId: null,
buffersByLeafId: { 'pane:1': 'serialized-local-scrollback' },
scrollbackRefsByLeafId: { 'pane:1': 'v1-local' },
ptyIdsByLeafId: { 'pane:1': 'pty-1' },
titlesByLeafId: { 'pane:1': 'build' }
}

View File

@ -3,6 +3,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
import { makePaneKey } from '../../../shared/stable-pane-id'
import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
import type { BrowserPage, BrowserWorkspace, Tab, TerminalTab } from '../../../shared/types'
import type { OpenFile } from '../store/slices/editor'
import {
@ -321,6 +322,60 @@ describe('applyWebSessionTabsSnapshot', () => {
expect(patch.activeTabIdByWorktree?.[WT]).toBe(mirroredId)
})
it('removes stale scrollback refs from mirrored terminal layouts', () => {
const mirroredId = toWebTerminalSurfaceTabId('host-tab-1')
const ptyId = 'remote:web-env-1@@terminal-1'
const existingTab: TerminalTab = {
id: mirroredId,
ptyId,
worktreeId: WT,
title: 'host shell',
defaultTitle: 'host shell',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: NOW
}
const patch = applyWebSessionTabsSnapshot(
makeState({
tabsByWorktree: { [WT]: [existingTab] },
ptyIdsByTabId: { [mirroredId]: [ptyId] },
terminalLayoutsByTabId: {
[mirroredId]: {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: ptyId },
scrollbackRefsByLeafId: { [LEAF_ID]: 'v1-stale-ref' }
}
}
}),
makeSnapshot([
{
type: 'terminal',
id: HOST_SURFACE_ID,
title: 'host shell',
parentTabId: 'host-tab-1',
leafId: LEAF_ID,
isActive: true,
status: 'ready',
terminal: 'terminal-1'
}
]),
ENV,
NOW
) as Partial<WebSessionTabsSyncState>
expect(patch.terminalLayoutsByTabId?.[mirroredId]).toMatchObject({
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: ptyId }
})
expect(patch.terminalLayoutsByTabId?.[mirroredId]?.scrollbackRefsByLeafId).toBeUndefined()
})
it('hydrates host split tab groups with mirrored terminal tab ids', () => {
const rightLeafId = SECOND_LEAF_ID
const patch = applyWebSessionTabsSnapshot(

View File

@ -1118,6 +1118,7 @@ function terminalLayoutEqual(
(a?.expandedLeafId ?? null) === b.expandedLeafId &&
sameStringRecord(a?.ptyIdsByLeafId, b.ptyIdsByLeafId) &&
sameStringRecord(a?.buffersByLeafId, b.buffersByLeafId) &&
sameStringRecord(a?.scrollbackRefsByLeafId, b.scrollbackRefsByLeafId) &&
sameStringRecord(a?.titlesByLeafId, b.titlesByLeafId)
)
}

View File

@ -485,6 +485,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
})
)
},
readTerminalScrollback: () => null,
setSync: (session) => {
writeJson(SESSION_STORAGE_KEY, sanitizeWebRuntimeWorkspaceSession(session))
}

View File

@ -0,0 +1,3 @@
export const TERMINAL_SCROLLBACK_SESSION_BUFFER_CHAR_LIMIT = 512 * 1024
export const TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT = 512 * 1024
export const TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT = 5 * 1024 * 1024

View File

@ -647,6 +647,8 @@ export type TerminalLayoutSnapshot = {
ptyIdsByLeafId?: Record<string, string>
/** Serialized terminal buffers per leaf for scrollback restoration on restart. */
buffersByLeafId?: Record<string, string>
/** Durable scrollback snapshot refs per leaf; raw bytes live outside session JSON. */
scrollbackRefsByLeafId?: Record<string, string>
/** User-assigned pane titles, keyed by stable layout leaf UUID.
* Persisted alongside buffers via the existing session:set flow. */
titlesByLeafId?: Record<string, string>

View File

@ -52,6 +52,7 @@ const terminalLayoutSnapshotSchema = z.object({
expandedLeafId: z.string().nullable(),
ptyIdsByLeafId: z.record(z.string(), z.string()).optional(),
buffersByLeafId: z.record(z.string(), z.string()).optional(),
scrollbackRefsByLeafId: z.record(z.string(), z.string()).optional(),
titlesByLeafId: z.record(z.string(), z.string()).optional()
})

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { FLOATING_TERMINAL_WORKTREE_ID } from './constants'
import type { WorkspaceSessionState } from './types'
import { TERMINAL_SCROLLBACK_SESSION_BUFFER_CHAR_LIMIT } from './terminal-scrollback-limits'
import {
pruneLocalTerminalScrollbackBuffers,
shouldPreserveTerminalScrollbackBuffers
@ -43,6 +44,7 @@ function makeSession(overrides: Partial<WorkspaceSessionState> = {}): WorkspaceS
activeLeafId: null,
expandedLeafId: null,
buffersByLeafId: { 'pane:1': 'local-scrollback' },
scrollbackRefsByLeafId: { 'pane:1': 'v1-local' },
ptyIdsByLeafId: { 'pane:1': 'local-pty' }
},
'remote-tab': {
@ -50,6 +52,7 @@ function makeSession(overrides: Partial<WorkspaceSessionState> = {}): WorkspaceS
activeLeafId: null,
expandedLeafId: null,
buffersByLeafId: { 'pane:1': 'remote-scrollback' },
scrollbackRefsByLeafId: { 'pane:1': 'v1-remote' },
ptyIdsByLeafId: { 'pane:1': 'remote-pty' }
}
},
@ -78,7 +81,7 @@ describe('pruneLocalTerminalScrollbackBuffers', () => {
).toBe(true)
})
it('drops local buffers while preserving SSH buffers and PTY bindings', () => {
it('drops local scrollback while preserving SSH scrollback and PTY bindings', () => {
const result = pruneLocalTerminalScrollbackBuffers(makeSession(), [
{ id: 'local-repo', connectionId: null },
{ id: 'remote-repo', connectionId: 'ssh-target-1' }
@ -93,6 +96,30 @@ describe('pruneLocalTerminalScrollbackBuffers', () => {
expect(result.terminalLayoutsByTabId['remote-tab'].buffersByLeafId).toEqual({
'pane:1': 'remote-scrollback'
})
expect(result.terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId).toEqual({
'pane:1': 'v1-remote'
})
})
it('caps preserved SSH buffers so session JSON cannot scale with raw scrollback', () => {
const hugeScrollback = `start-${'x'.repeat(TERMINAL_SCROLLBACK_SESSION_BUFFER_CHAR_LIMIT + 10)}`
const result = pruneLocalTerminalScrollbackBuffers(
makeSession({
terminalLayoutsByTabId: {
'remote-tab': {
root: null,
activeLeafId: null,
expandedLeafId: null,
buffersByLeafId: { 'pane:1': hugeScrollback }
}
}
}),
[{ id: 'remote-repo', connectionId: 'ssh-target-1' }]
)
const buffer = result.terminalLayoutsByTabId['remote-tab'].buffersByLeafId?.['pane:1']
expect(buffer).toHaveLength(TERMINAL_SCROLLBACK_SESSION_BUFFER_CHAR_LIMIT)
expect(buffer?.startsWith('start-')).toBe(false)
})
it('drops floating terminal buffers even though the synthetic worktree has no repo', () => {

View File

@ -1,6 +1,7 @@
import type { Repo, WorkspaceSessionState } from './types'
import { FLOATING_TERMINAL_WORKTREE_ID } from './constants'
import { getRepoIdFromWorktreeId } from './worktree-id'
import { TERMINAL_SCROLLBACK_SESSION_BUFFER_CHAR_LIMIT } from './terminal-scrollback-limits'
export type RepoConnection = Pick<Repo, 'id' | 'connectionId'>
@ -34,6 +35,30 @@ export function shouldPreserveTerminalScrollbackBuffers(
)
}
export function capTerminalScrollbackSessionBuffer(buffer: string): string {
if (buffer.length <= TERMINAL_SCROLLBACK_SESSION_BUFFER_CHAR_LIMIT) {
return buffer
}
return buffer.slice(-TERMINAL_SCROLLBACK_SESSION_BUFFER_CHAR_LIMIT)
}
function capTerminalScrollbackLeafBuffers(buffers: Record<string, string> | undefined): {
buffers: Record<string, string> | undefined
changed: boolean
} {
if (!buffers) {
return { buffers: undefined, changed: false }
}
let changed = false
const capped: Record<string, string> = {}
for (const [leafId, buffer] of Object.entries(buffers)) {
const next = capTerminalScrollbackSessionBuffer(buffer)
capped[leafId] = next
changed ||= next !== buffer
}
return { buffers: Object.keys(capped).length > 0 ? capped : undefined, changed }
}
export function pruneLocalTerminalScrollbackBuffers(
session: WorkspaceSessionState,
repos: readonly RepoConnection[]
@ -48,17 +73,23 @@ export function pruneLocalTerminalScrollbackBuffers(
let terminalLayoutsByTabId: WorkspaceSessionState['terminalLayoutsByTabId'] | null = null
for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId)) {
if (!layout.buffersByLeafId) {
if (!layout.buffersByLeafId && !layout.scrollbackRefsByLeafId) {
continue
}
const worktreeId = worktreeIdByTabId.get(tabId)
if (shouldPreserveTerminalScrollbackBuffersForRepoMap(worktreeId, connectionIdByRepoId)) {
const capped = capTerminalScrollbackLeafBuffers(layout.buffersByLeafId)
if (capped.changed) {
terminalLayoutsByTabId ??= { ...session.terminalLayoutsByTabId }
terminalLayoutsByTabId[tabId] = { ...layout, buffersByLeafId: capped.buffers }
}
continue
}
terminalLayoutsByTabId ??= { ...session.terminalLayoutsByTabId }
const layoutWithoutBuffers = { ...layout }
delete layoutWithoutBuffers.buffersByLeafId
delete layoutWithoutBuffers.scrollbackRefsByLeafId
terminalLayoutsByTabId[tabId] = layoutWithoutBuffers
}