Skip to content

Commit f613172

Browse files
frenchie4111claude
andcommitted
Replay persisted terminal scrollback on a cold app start
Scrollback was already being written to userData/terminal-history/<id>, but getHistory() only read the in-memory map, and that map is seeded from disk inside create(). On a cold start the renderer asks for history before it spawns, so it got '' and skipped the replay — create() then loaded the file into a buffer nobody ever displayed, which is why a renderer reload showed scrollback but relaunching the app did not. Both entry points now go through ensureHistoryBuffer, so the file is read on first touch (at most once per id) whichever call arrives first. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 20d67bc commit f613172

3 files changed

Lines changed: 71 additions & 19 deletions

File tree

src/main/pty-manager.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ vi.mock('./persistence', () => ({
3636

3737
import { PtyManager } from './pty-manager'
3838
import * as pty from 'node-pty'
39+
import { loadTerminalHistory } from './persistence'
3940

4041
describe('PtyManager.create — eager spawn contract for createShell (#203)', () => {
4142
beforeEach(() => {
@@ -67,3 +68,43 @@ describe('PtyManager.create — eager spawn contract for createShell (#203)', ()
6768
expect(pty.spawn).not.toHaveBeenCalled()
6869
})
6970
})
71+
72+
// On a cold app start the renderer asks for scrollback BEFORE it spawns, so
73+
// getHistory has to reach the persisted file itself — reading only the
74+
// in-memory map left restored tabs blank until something else seeded it.
75+
describe('PtyManager.getHistory — persisted scrollback across an app restart', () => {
76+
beforeEach(() => {
77+
vi.clearAllMocks()
78+
})
79+
80+
it('returns the persisted scrollback when nothing is in memory yet', () => {
81+
vi.mocked(loadTerminalHistory).mockReturnValueOnce('previous run output')
82+
const mgr = new PtyManager()
83+
expect(mgr.getHistory('shell-restored-1')).toBe('previous run output')
84+
})
85+
86+
it('returns empty string when no history was persisted', () => {
87+
const mgr = new PtyManager()
88+
expect(mgr.getHistory('shell-restored-2')).toBe('')
89+
})
90+
91+
it('reads the file once per id — the spawn that follows reuses the buffer', () => {
92+
vi.mocked(loadTerminalHistory).mockReturnValueOnce('previous run output')
93+
const mgr = new PtyManager()
94+
const id = 'shell-restored-3'
95+
mgr.getHistory(id)
96+
mgr.create(id, tmpdir(), '', ['-il'], undefined, true)
97+
expect(loadTerminalHistory).toHaveBeenCalledTimes(1)
98+
expect(mgr.getHistory(id)).toBe('previous run output')
99+
})
100+
101+
it('drops both the buffer and the file on forgetHistory so a closed tab does not resurrect', () => {
102+
vi.mocked(loadTerminalHistory).mockReturnValue('previous run output')
103+
const mgr = new PtyManager()
104+
const id = 'shell-restored-4'
105+
expect(mgr.getHistory(id)).toBe('previous run output')
106+
vi.mocked(loadTerminalHistory).mockReturnValue(null)
107+
mgr.forgetHistory(id)
108+
expect(mgr.getHistory(id)).toBe('')
109+
})
110+
})

src/main/pty-manager.ts

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,8 @@ export class PtyManager {
8585
private store: Store | null = null
8686
private sendSignal: ((channel: string, ...args: unknown[]) => void) | null = null
8787
// Per-terminal raw-byte scrollback owned by main. Populated from disk on
88-
// create() (if a history file exists), appended to from the PTY onData
89-
// stream, and returned on getHistory(id). Persistence to
88+
// first touch by either getHistory(id) or create() (whichever comes first),
89+
// appended to from the PTY onData stream. Persistence to
9090
// userData/terminal-history/<id> happens on a throttled cadence and on
9191
// before-quit via flushAllHistory().
9292
private history = new Map<string, HistoryBuffer>()
@@ -209,20 +209,15 @@ export class PtyManager {
209209
}
210210

211211
// Seed the history buffer from disk if a file exists. Renderer calls
212-
// getHistory(id) right after createTerminal and writes the bytes into a
213-
// fresh xterm instance before wiring up live data.
214-
let buf = this.history.get(id)
215-
if (!buf) {
216-
buf = new HistoryBuffer()
217-
const existing = loadTerminalHistory(id)
218-
if (existing) buf.seed(existing)
219-
this.history.set(id, buf)
220-
}
212+
// getHistory(id) right before createTerminal and writes the bytes into a
213+
// fresh xterm instance before wiring up live data, so this usually finds
214+
// the buffer already loaded and skips the read.
215+
const buf = this.ensureHistoryBuffer(id)
221216

222217
ptyProcess.onData((data: string) => {
223218
// Tee into the history ring buffer before forwarding, so a reload
224219
// right after output arrives still sees it.
225-
buf!.append(data)
220+
buf.append(data)
226221
this.historyDirty.add(id)
227222
this.ensureHistoryFlushTimer()
228223
this.perfMonitor?.recordTerminalBytes(id, data.length)
@@ -258,9 +253,25 @@ export class PtyManager {
258253
}
259254
}
260255

261-
/** Raw PTY scrollback for `id`, or empty string if none. */
256+
/** Raw PTY scrollback for `id`, or empty string if none. Falls back to the
257+
* persisted file when nothing is in memory yet: on a cold app start the
258+
* renderer asks for history BEFORE it spawns, so reading only the in-memory
259+
* map returned '' and the saved scrollback stayed invisible until a renderer
260+
* reload happened to hit the buffer create() had since seeded. */
262261
getHistory(id: string): string {
263-
return this.history.get(id)?.toString() || ''
262+
return this.ensureHistoryBuffer(id).toString()
263+
}
264+
265+
/** The buffer for `id`, seeded from disk on first touch. Both getHistory and
266+
* create go through here so the file is read at most once per id. */
267+
private ensureHistoryBuffer(id: string): HistoryBuffer {
268+
const existing = this.history.get(id)
269+
if (existing) return existing
270+
const buf = new HistoryBuffer()
271+
const persisted = loadTerminalHistory(id)
272+
if (persisted) buf.seed(persisted)
273+
this.history.set(id, buf)
274+
return buf
264275
}
265276

266277
/** Drop the in-memory buffer + delete the persisted file. Called on tab

src/renderer/components/XTerminal.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -699,11 +699,11 @@ export function XTerminal({ terminalId, cwd, type, agentKind, visible, sessionNa
699699
spawnPty()
700700
return
701701
}
702-
// A non-empty history means main already has a live PTY for this id
703-
// — the agent isn't "starting," we're attaching to a running one.
704-
// Clear the loading overlay so the restored scrollback is visible
705-
// without waiting for new bytes (which may never come if the agent
706-
// is idle at its prompt).
702+
// A non-empty history means this tab has run before — either main
703+
// still holds a live PTY for it (we're attaching to a running one) or
704+
// the scrollback came off disk from a previous app run. Clear the
705+
// loading overlay so it's visible without waiting for new bytes
706+
// (which may never come if the agent is idle at its prompt).
707707
setLoading(false)
708708
// Replay raw scrollback. Wait for xterm to finish parsing before
709709
// attaching onData, otherwise any response sequences xterm generates

0 commit comments

Comments
 (0)