mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
feat(desktop): persist & restore terminal tabs + scrollback across relaunch
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.
This commit is contained in:
parent
4488fe134b
commit
1c0fa12edb
7 changed files with 373 additions and 9 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
7
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue