From 1c0fa12edb1c33b8c72ff860b6a0276262394cd7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 28 Jun 2026 22:12:29 -0500 Subject: [PATCH] feat(desktop): persist & restore terminal tabs + scrollback across relaunch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User terminal tabs and their recent scrollback now survive an app restart (VS Code parity). Tabs, active selection, cwd, and a serialized scrollback snapshot are written to localStorage on every change; on launch the tabs reopen with their history replayed above a fresh shell. Processes are NOT revived — a new shell starts one line below the restored block. - Capture: SerializeAddon snapshots the buffer on a 750ms leading-edge throttle, so a `cmd; quit` lands on disk before teardown; the snapshot is trimmed of its trailing idle prompt (no "double prompt" on restore) and capped (200 scrollback lines / 48k chars) to stay under the storage budget. - Teardown guard: app quit/reload kills the PTYs from the main process, firing onExit in the renderer, but React skips effect cleanups on teardown so the per-instance `disposed` flag never flips. A pagehide/beforeunload flag stops onExit from calling closeTerminal() and wiping the persisted tabs right before relaunch restores them. A real `exit`/Ctrl-D still closes. - Agent mirror tabs stay runtime-only — only user tabs persist. --- apps/desktop/package.json | 1 + .../app/right-sidebar/terminal/instance.tsx | 4 +- .../right-sidebar/terminal/terminals.test.ts | 90 +++++++++++ .../app/right-sidebar/terminal/terminals.ts | 129 ++++++++++++++- .../terminal/use-terminal-session.ts | 150 +++++++++++++++++- .../app/right-sidebar/terminal/workspace.tsx | 1 + package-lock.json | 7 + 7 files changed, 373 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/src/app/right-sidebar/terminal/terminals.test.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b4e3328402c..4bf4eaade96 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -73,6 +73,7 @@ "@tanstack/react-virtual": "^3.13.24", "@vscode/codicons": "^0.0.45", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-serialize": "^0.14.0", "@xterm/addon-unicode11": "^0.9.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/addon-webgl": "^0.19.0", diff --git a/apps/desktop/src/app/right-sidebar/terminal/instance.tsx b/apps/desktop/src/app/right-sidebar/terminal/instance.tsx index 0529eaf2b1a..12bfbe9db08 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/instance.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/instance.tsx @@ -19,11 +19,12 @@ interface TerminalInstanceProps { cwd: string active: boolean onAddSelectionToChat: (text: string, label?: string) => void + reviveBuffer?: string } /** One persistent xterm+PTY. Every open tab stays mounted (so its shell and * scrollback survive tab switches); only the active one is shown. */ -export function TerminalInstance({ id, active, cwd, onAddSelectionToChat }: TerminalInstanceProps) { +export function TerminalInstance({ id, active, cwd, onAddSelectionToChat, reviveBuffer }: TerminalInstanceProps) { const { t } = useI18n() const { addSelectionToChat, hostRef, selection, selectionStyle, status } = useTerminalSession({ @@ -31,6 +32,7 @@ export function TerminalInstance({ id, active, cwd, onAddSelectionToChat }: Term cwd, active, onAddSelectionToChat, + reviveBuffer, onShell: shell => reportTerminalShell(id, shell) }) diff --git a/apps/desktop/src/app/right-sidebar/terminal/terminals.test.ts b/apps/desktop/src/app/right-sidebar/terminal/terminals.test.ts new file mode 100644 index 00000000000..b04e1e12710 --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/terminals.test.ts @@ -0,0 +1,90 @@ +import { atom } from 'nanostores' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const STORAGE_KEY = 'hermes.desktop.terminals.v1' + +async function loadTerminalStore() { + vi.doMock('@/store/session', () => ({ + $currentCwd: atom('/workspace') + })) + + return import('./terminals') +} + +describe('terminal store persistence', () => { + beforeEach(() => { + window.localStorage.clear() + vi.resetModules() + }) + + it('restores user tabs, active tab, and history on module load', async () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + activeTerminalId: 'term-two', + terminals: [ + { auto: false, cwd: '/repo/one', id: 'term-one', reviveBuffer: 'last output', title: 'zsh' }, + { auto: true, cwd: '/repo/two', id: 'term-two', title: 'Terminal' } + ] + }) + ) + + const { $activeTerminalId, $terminals } = await loadTerminalStore() + + expect($activeTerminalId.get()).toBe('term-two') + expect($terminals.get()).toEqual([ + { auto: false, cwd: '/repo/one', id: 'term-one', kind: 'user', reviveBuffer: 'last output', title: 'zsh' }, + { auto: true, cwd: '/repo/two', id: 'term-two', kind: 'user', title: 'Terminal' } + ]) + }) + + it('persists user tabs and history synchronously, skipping agent mirrors', async () => { + const { createTerminal, ensureAgentTerminal, renameTerminal, selectTerminal, updateTerminalReviveBuffer } = + await loadTerminalStore() + + const userId = createTerminal('/repo') + renameTerminal(userId, 'server') + updateTerminalReviveBuffer(userId, 'recent scrollback') + ensureAgentTerminal('proc-1', 'background task') + selectTerminal(userId) + + // No flush/tick: persistence is synchronous, so the snapshot is already on + // disk (this is what makes app-quit restore reliable). + expect(JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '{}')).toEqual({ + activeTerminalId: userId, + terminals: [{ auto: false, cwd: '/repo', id: userId, reviveBuffer: 'recent scrollback', title: 'server' }] + }) + }) + + it('never attaches a revive buffer to an agent tab', async () => { + const { $terminals, ensureAgentTerminal, updateTerminalReviveBuffer } = await loadTerminalStore() + + const agentId = ensureAgentTerminal('proc-1', 'background task')! + updateTerminalReviveBuffer(agentId, 'should be ignored') + + expect($terminals.get().find(term => term.id === agentId)?.reviveBuffer).toBeUndefined() + expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull() + }) + + it('tail-trims an oversized revive buffer to stay under the storage budget', async () => { + const { $terminals, createTerminal, updateTerminalReviveBuffer } = await loadTerminalStore() + + const userId = createTerminal('/repo') + const huge = 'x'.repeat(60_000) + updateTerminalReviveBuffer(userId, huge) + + const stored = $terminals.get().find(term => term.id === userId)?.reviveBuffer ?? '' + expect(stored.length).toBe(48_000) + expect(stored).toBe(huge.slice(-48_000)) + }) + + it('clears remembered tabs when all terminals close', async () => { + const { closeAllTerminals, createTerminal } = await loadTerminalStore() + + createTerminal('/repo') + expect(window.localStorage.getItem(STORAGE_KEY)).not.toBeNull() + + closeAllTerminals() + expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull() + }) +}) diff --git a/apps/desktop/src/app/right-sidebar/terminal/terminals.ts b/apps/desktop/src/app/right-sidebar/terminal/terminals.ts index 1e7d36240da..2d716242ab3 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/terminals.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/terminals.ts @@ -1,5 +1,6 @@ import { atom, computed } from 'nanostores' +import { readKey, writeKey } from '@/lib/storage' import { $currentCwd } from '@/store/session' import { setTerminalTakeover } from '../store' @@ -18,14 +19,126 @@ export interface TerminalEntry { * (the project root if opened in one, else the backend's default). Switching * sessions never moves or recreates a terminal. */ cwd: string + /** Serialized xterm scrollback from the last session, replayed on relaunch so + * the tab reopens with its recent history (VS Code parity). Processes are NOT + * revived — a fresh shell starts beneath the restored buffer. Captured live + * for user tabs only; agent mirrors stay runtime-only. */ + reviveBuffer?: string /** `user` = interactive PTY shell. `agent` = read-only mirror of an agent * background process (`terminal(background=true)`), keyed by `procId`. */ kind: 'user' | 'agent' procId?: string } -export const $terminals = atom([]) -export const $activeTerminalId = atom(null) +interface PersistedTerminalEntry { + auto: boolean + cwd: string + id: string + reviveBuffer?: string + title: string +} + +interface PersistedTerminalState { + activeTerminalId: null | string + terminals: PersistedTerminalEntry[] +} + +const TERMINALS_STORAGE_KEY = 'hermes.desktop.terminals.v1' + +// Cap a single tab's replayed history so the persisted layout can't blow the +// localStorage quota. Roughly mirrors VS Code's persistentSessionScrollback +// default (100 lines) once the serialized escape codes are counted in. +const MAX_REVIVE_BUFFER_CHARS = 48_000 + +function sanitizePersistedTerminal(value: unknown): PersistedTerminalEntry | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + + const record = value as Record + const id = typeof record.id === 'string' ? record.id.trim() : '' + const title = typeof record.title === 'string' ? record.title.trim() : '' + const cwd = typeof record.cwd === 'string' ? record.cwd : '' + const reviveBuffer = typeof record.reviveBuffer === 'string' ? record.reviveBuffer : undefined + + if (!id) { + return null + } + + return { + auto: typeof record.auto === 'boolean' ? record.auto : true, + cwd, + id, + ...(reviveBuffer ? { reviveBuffer } : {}), + title: title || 'Terminal' + } +} + +function loadPersistedTerminals(): PersistedTerminalState { + const fallback: PersistedTerminalState = { activeTerminalId: null, terminals: [] } + const raw = readKey(TERMINALS_STORAGE_KEY) + + if (!raw) { + return fallback + } + + try { + const parsed = JSON.parse(raw) as unknown + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return fallback + } + + const record = parsed as Record + + const terminals = Array.isArray(record.terminals) + ? record.terminals.map(sanitizePersistedTerminal).filter((term): term is PersistedTerminalEntry => Boolean(term)) + : [] + + const active = + typeof record.activeTerminalId === 'string' && terminals.some(term => term.id === record.activeTerminalId) + ? record.activeTerminalId + : (terminals[0]?.id ?? null) + + return { activeTerminalId: active, terminals } + } catch { + return fallback + } +} + +// Persist synchronously on every change (the app-wide convention — see panes.ts +// / layout.ts). Capturing history this way means a snapshot is already on disk +// well before the renderer tears down, so app quit needs no unload hook. +function persistTerminals(list: readonly TerminalEntry[], activeTerminalId: null | string) { + const terminals = list + .filter(term => term.kind === 'user') + .map(term => ({ + auto: term.auto, + cwd: term.cwd, + id: term.id, + ...(term.reviveBuffer ? { reviveBuffer: term.reviveBuffer } : {}), + title: term.title + })) + + if (!terminals.length) { + writeKey(TERMINALS_STORAGE_KEY, null) + + return + } + + const active = terminals.some(term => term.id === activeTerminalId) ? activeTerminalId : (terminals[0]?.id ?? null) + writeKey(TERMINALS_STORAGE_KEY, JSON.stringify({ activeTerminalId: active, terminals })) +} + +const restored = loadPersistedTerminals() + +export const $terminals = atom( + restored.terminals.map(term => ({ ...term, kind: 'user' as const })) +) +export const $activeTerminalId = atom(restored.activeTerminalId) + +$terminals.subscribe(list => persistTerminals(list, $activeTerminalId.get())) +$activeTerminalId.subscribe(active => persistTerminals($terminals.get(), active)) export const $activeTerminal = computed( [$terminals, $activeTerminalId], @@ -184,6 +297,18 @@ export function closeOtherTerminals(id: string): void { } } +/** Record the latest serialized scrollback for a tab so it can be replayed on + * the next launch. Oversized buffers are tail-trimmed to stay under the storage + * budget; only user tabs ever carry one. */ +export function updateTerminalReviveBuffer(id: string, reviveBuffer: string): void { + const capped = + reviveBuffer.length > MAX_REVIVE_BUFFER_CHARS ? reviveBuffer.slice(-MAX_REVIVE_BUFFER_CHARS) : reviveBuffer + + $terminals.set( + $terminals.get().map(term => (term.id === id && term.kind === 'user' ? { ...term, reviveBuffer: capped } : term)) + ) +} + export function renameTerminal(id: string, title: string): void { const trimmed = title.trim() diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts index 9a7e690c7c8..931b8fec624 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts @@ -1,4 +1,5 @@ import { FitAddon } from '@xterm/addon-fit' +import { SerializeAddon } from '@xterm/addon-serialize' import { Unicode11Addon } from '@xterm/addon-unicode11' import { WebLinksAddon } from '@xterm/addon-web-links' import { WebglAddon } from '@xterm/addon-webgl' @@ -20,7 +21,34 @@ import { terminalSelectionLabel, terminalTheme } from './selection' -import { closeTerminal } from './terminals' +import { closeTerminal, updateTerminalReviveBuffer } from './terminals' + +// How many scrollback lines to serialize for relaunch restore. Mirrors VS Code's +// terminal.integrated.persistentSessionScrollback default; the store caps the +// resulting string so a long line-wrapped buffer can't blow the storage budget. +const PERSISTENT_SESSION_SCROLLBACK = 200 + +// Leading-edge throttle window for capturing history. The first output after an +// idle gap persists almost immediately (so `cmd; quit` is on disk before the +// renderer tears down), then at most once per window while output streams. +const SNAPSHOT_THROTTLE_MS = 750 + +// True once the page/app is tearing down (Cmd+Q, Alt+F4, window close, reload). +// App quit kills the PTYs from the main process, which fires onExit in the +// renderer — but React skips effect cleanups on teardown, so the per-instance +// `disposed` flag never flips. Without this guard those teardown exits would call +// closeTerminal() and wipe the persisted terminal list right before relaunch +// reads it. A real `exit`/Ctrl-D still closes the tab (flag stays false). +let appTearingDown = false + +if (typeof window !== 'undefined') { + const markTearingDown = () => { + appTearingDown = true + } + + window.addEventListener('pagehide', markTearingDown) + window.addEventListener('beforeunload', markTearingDown) +} type TerminalStatus = 'closed' | 'open' | 'starting' @@ -140,6 +168,38 @@ function stripInitialPromptGap(data: string) { return prefix } +// Trim the shell's trailing idle prompt from a serialized snapshot before it's +// persisted. Without it, the saved buffer ends in the old prompt, so the next +// launch replays it directly above the fresh shell's prompt ("double bar"). The +// prompt is the short block after the last blank line (starship's add_newline +// gap); only a short tail is dropped, so real command output is never trimmed and +// configs without that blank line simply keep the historical prompt (no loss). +function cleanReviveSnapshot(serialized: string): string { + const visible = (line: string) => stripEscapeSequences(line).replace(/[\s%]/g, '') + const lines = serialized.split(/\r?\n/) + + while (lines.length && visible(lines[lines.length - 1]) === '') { + lines.pop() + } + + let lastBlank = -1 + + for (let i = lines.length - 1; i >= 0; i -= 1) { + if (visible(lines[i]) === '') { + lastBlank = i + + break + } + } + + // A prompt is a short block; a long tail after the blank is real output, leave it. + if (lastBlank >= 0 && lines.length - 1 - lastBlank <= 3) { + lines.length = lastBlank + } + + return lines.join('\r\n') +} + interface UseTerminalSessionOptions { /** Renderer-side terminal id (the tab handle), used to key the agent reader. */ id: string @@ -147,6 +207,8 @@ interface UseTerminalSessionOptions { /** Only the active tab is visible, owns the agent reader, and runs injections. */ active: boolean onAddSelectionToChat: (text: string, label?: string) => void + /** Serialized scrollback from the previous session, replayed once on mount. */ + reviveBuffer?: string /** Reports the resolved shell name once the PTY is live (for the tab label). */ onShell?: (shell: string) => void } @@ -247,7 +309,14 @@ function quotePathForShell(path: string, shellName: string): string { return `'${path.replace(/'/g, "'\\''")}'` } -export function useTerminalSession({ id, cwd, active, onAddSelectionToChat, onShell }: UseTerminalSessionOptions) { +export function useTerminalSession({ + id, + cwd, + active, + onAddSelectionToChat, + reviveBuffer, + onShell +}: UseTerminalSessionOptions) { // Key off renderedMode (the painted surface type), not resolvedMode (the // clicked switch) — a skin can keep a light surface in "dark" mode, and we // must match the surface or the ANSI palette inverts against it. themeName @@ -264,6 +333,9 @@ export function useTerminalSession({ id, cwd, active, onAddSelectionToChat, onSh const termRef = useRef(null) const webglRef = useRef(null) const sessionIdRef = useRef(null) + // Snapshot the revive buffer once: live snapshots feed updateTerminalReviveBuffer + // and would otherwise re-arm replay on every store-driven re-render. + const initialReviveBufferRef = useRef(reviveBuffer) const shellNameRef = useRef('shell') const selectionLabelRef = useRef('') const selectionRef = useRef('') @@ -381,13 +453,72 @@ export function useTerminalSession({ id, cwd, active, onAddSelectionToChat, onSh }) const fit = new FitAddon() + const serialize = new SerializeAddon() termRef.current = term term.loadAddon(fit) + term.loadAddon(serialize) term.loadAddon(new Unicode11Addon()) term.loadAddon(new WebLinksAddon()) term.unicode.activeVersion = '11' + // Replay last session's scrollback before the fresh shell boots. The process + // is NOT revived — a new shell starts one line below the restored history. + // Stripping the boot gap still applies to the live shell output that follows, + // so the fresh prompt lands flush under the restored block. + const initialReviveBuffer = initialReviveBufferRef.current + + if (initialReviveBuffer) { + term.write(initialReviveBuffer) + term.write('\r\n') + } + + // Capture the buffer on a leading-edge throttle and persist synchronously via + // the store. No unload hook: by the time the user quits, a recent snapshot is + // already on disk (the prior beforeunload-based attempt lost the last output). + let snapshotTimer = 0 + let lastSnapshotAt = 0 + + const persistSnapshot = () => { + if (disposed) { + return + } + + lastSnapshotAt = Date.now() + + try { + const snapshot = serialize.serialize({ scrollback: PERSISTENT_SESSION_SCROLLBACK }) + updateTerminalReviveBuffer(id, cleanReviveSnapshot(snapshot)) + } catch { + // Best-effort restore: never let serialization break a live terminal. + } + } + + const scheduleSnapshot = () => { + if (snapshotTimer) { + return + } + + const elapsed = Date.now() - lastSnapshotAt + + if (elapsed >= SNAPSHOT_THROTTLE_MS) { + persistSnapshot() + + return + } + + snapshotTimer = window.setTimeout(() => { + snapshotTimer = 0 + persistSnapshot() + }, SNAPSHOT_THROTTLE_MS - elapsed) + } + + cleanup.push(() => { + if (snapshotTimer) { + window.clearTimeout(snapshotTimer) + } + }) + const onDragOver = (e: DragEvent) => { if (!e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) { return @@ -551,12 +682,19 @@ export function useTerminalSession({ id, cwd, active, onAddSelectionToChat, onSh setStatus('open') cleanup.push( - terminalApi.onData(session.id, armedWrite), + terminalApi.onData(session.id, data => { + armedWrite(data) + scheduleSnapshot() + }), terminalApi.onExit(session.id, () => { // Shell exited (`exit` / Ctrl-D / crash) — drop the tab like a real - // terminal. closeTerminal hides the pane when it's the last one; - // skip if we're already tearing down (cleanup disposes the PTY). - if (!disposed) { + // terminal. closeTerminal hides the pane when it's the last one. + // Skip if we're tearing down (cleanup disposes the PTY) OR the app + // is quitting/reloading: on quit the main process kills every PTY, + // firing this exit, but React skips the cleanup so `disposed` stays + // false — running closeTerminal here would wipe the persisted tabs + // right before relaunch restores them. + if (!disposed && !appTearingDown) { closeTerminal(id) } }) diff --git a/apps/desktop/src/app/right-sidebar/terminal/workspace.tsx b/apps/desktop/src/app/right-sidebar/terminal/workspace.tsx index 73af1f41bc7..b8b62f50999 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/workspace.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/workspace.tsx @@ -56,6 +56,7 @@ export function TerminalWorkspace({ onAddSelectionToChat }: TerminalWorkspacePro id={term.id} key={term.id} onAddSelectionToChat={onAddSelectionToChat} + reviveBuffer={term.reviveBuffer} /> ) )} diff --git a/package-lock.json b/package-lock.json index 2fe3537733e..97130e6cf10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -87,6 +87,7 @@ "@tanstack/react-virtual": "^3.13.24", "@vscode/codicons": "^0.0.45", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-serialize": "^0.14.0", "@xterm/addon-unicode11": "^0.9.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/addon-webgl": "^0.19.0", @@ -6930,6 +6931,12 @@ "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", "license": "MIT" }, + "node_modules/@xterm/addon-serialize": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz", + "integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==", + "license": "MIT" + }, "node_modules/@xterm/addon-unicode11": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",