Merge pull request #54585 from NousResearch/bb/desktop-terminal-history

feat(desktop): persist & restore terminal tabs + scrollback across relaunch
This commit is contained in:
brooklyn! 2026-06-28 23:38:16 -05:00 committed by GitHub
commit fb0644fbc2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 373 additions and 9 deletions

View file

@ -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",

View file

@ -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)
})

View file

@ -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()
})
})

View file

@ -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<readonly TerminalEntry[]>([])
export const $activeTerminalId = atom<string | null>(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<string, unknown>
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<string, unknown>
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<readonly TerminalEntry[]>(
restored.terminals.map(term => ({ ...term, kind: 'user' as const }))
)
export const $activeTerminalId = atom<string | null>(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()

View file

@ -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<Terminal | null>(null)
const webglRef = useRef<WebglAddon | null>(null)
const sessionIdRef = useRef<string | null>(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)
}
})

View file

@ -56,6 +56,7 @@ export function TerminalWorkspace({ onAddSelectionToChat }: TerminalWorkspacePro
id={term.id}
key={term.id}
onAddSelectionToChat={onAddSelectionToChat}
reviveBuffer={term.reviveBuffer}
/>
)
)}

7
package-lock.json generated
View file

@ -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",