diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts index 06c85c002..b72cc28a8 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts @@ -36,6 +36,8 @@ beforeAll(() => { import { buildFontFamily, + POST_REPLAY_MODE_RESET, + restoreScrollbackBuffers, serializePaneTree, serializeTerminalLayout, replayTerminalLayout, @@ -422,6 +424,38 @@ describe('replayTerminalLayout', () => { }) }) +describe('restoreScrollbackBuffers', () => { + it('marks panes with restored scrollback for fresh-shell viewport blanking', () => { + const writes: string[] = [] + const pane = { + id: 1, + terminal: { + write: vi.fn((data: string, callback?: () => void) => { + writes.push(data) + callback?.() + }) + } + } + const manager = { + getPanes: vi.fn(() => [pane]) + } + const replayingPanesRef = { current: new Map() } + const restoredViewportBlankingPanesRef = { current: new Set() } + + restoreScrollbackBuffers( + manager as unknown as Parameters[0], + { [LEAF_1]: 'restored output' }, + new Map([[LEAF_1, 1]]), + replayingPanesRef, + restoredViewportBlankingPanesRef + ) + + expect(writes).toEqual(['restored output', '\r\n', POST_REPLAY_MODE_RESET]) + expect(restoredViewportBlankingPanesRef.current.has(1)).toBe(true) + expect(replayingPanesRef.current.size).toBe(0) + }) +}) + // --------------------------------------------------------------------------- // collectLeafIdsInReplayCreationOrder // --------------------------------------------------------------------------- diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts index b2ab8d1e2..78f774ca4 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts @@ -6,6 +6,7 @@ import type { import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard' +import type { RestoredViewportBlankingPanesRef } from './terminal-restored-viewport' import { getLeftmostLeafId, normalizeTerminalLayoutSnapshot, @@ -211,7 +212,8 @@ export function restoreScrollbackBuffers( manager: PaneManager, savedBuffers: Record | undefined, restoredPaneByLeafId: Map, - replayingPanesRef: ReplayingPanesRef + replayingPanesRef: ReplayingPanesRef, + restoredViewportBlankingPanesRef?: RestoredViewportBlankingPanesRef ): void { if (!savedBuffers) { return @@ -249,6 +251,9 @@ export function restoreScrollbackBuffers( // The shell underneath is fresh and has no TUI consuming these modes. // See POST_REPLAY_MODE_RESET comment. replayIntoTerminal(pane, replayingPanesRef, POST_REPLAY_MODE_RESET) + // Why: connection resolution happens after layout replay; only the + // fresh-shell paths should move these visible rows into scrollback. + restoredViewportBlankingPanesRef?.current.add(pane.id) } } catch { // If restore fails, continue with blank terminal. diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 090ef9590..01c98e845 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -1,5 +1,6 @@ import type { PtyTransport } from './pty-transport' import type { ReplayingPanesRef } from './replay-guard' +import type { RestoredViewportBlankingPanesRef } from './terminal-restored-viewport' import type { AgentCompletionStatusSnapshot } from './agent-completion-coordinator-types' import type { EventProps } from '../../../../shared/telemetry-events' import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol' @@ -38,6 +39,7 @@ export type PtyConnectionDeps = { paneMode2031Ref: React.RefObject> paneLastThemeModeRef: React.RefObject> replayingPanesRef: ReplayingPanesRef + restoredViewportBlankingPanesRef?: RestoredViewportBlankingPanesRef isActiveRef: React.RefObject isVisibleRef: React.RefObject onPtyExitRef: React.RefObject<(ptyId: string) => void> diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 6a5cf94a5..dadcf8ac0 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -9,6 +9,7 @@ import { RESET_KITTY_KEYBOARD_PROTOCOL, RESET_TERMINAL_CURSOR_STYLE } from './layout-serialization' +import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport' import { TERMINAL_PASTE_DIRECT_MAX_BYTES } from './terminal-paste-coordinator' import type * as UseNotificationDispatchModule from './use-notification-dispatch' import { getEagerPtyBufferHandle } from './pty-dispatcher' @@ -66,6 +67,31 @@ async function renderHeadlessBuffer(writes: string[], cols = 80, rows = 8): Prom } } +async function renderHeadlessTerminalState( + writes: string[], + cols = 80, + rows = 8 +): Promise<{ allLines: string[]; visibleLines: string[]; baseY: number }> { + const term = new Terminal({ cols, rows, allowProposedApi: true }) + try { + for (const write of writes) { + await writeHeadlessTerminal(term, write) + } + const allLines: string[] = [] + const buffer = term.buffer.active + for (let lineIndex = 0; lineIndex < buffer.length; lineIndex++) { + allLines.push(buffer.getLine(lineIndex)?.translateToString(true) ?? '') + } + const visibleLines: string[] = [] + for (let row = 0; row < term.rows; row++) { + visibleLines.push(buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? '') + } + return { allLines, visibleLines, baseY: buffer.baseY } + } finally { + term.dispose() + } +} + const toastInfo = vi.fn() const LEAF_1 = '11111111-1111-4111-8111-111111111111' as const const LEAF_2 = '22222222-2222-4222-8222-222222222222' as const @@ -5207,6 +5233,114 @@ describe('connectPanePty', () => { expect(window.api.pty.ackColdRestore).toHaveBeenCalledWith('tab-pty') }) + it('blanks restored scrollback before fresh shell output', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('fresh-pty') + const written: string[] = [] + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + callbacks.onData?.('PS >') + return 'fresh-pty' + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: {} + } + } + } as StoreState + + const pane = createPane(1) + pane.terminal.rows = 4 + pane.terminal.cols = 20 + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + written.push(data) + callback?.() + }) + const manager = createManager(1) + const deps = createDeps({ + restoredViewportBlankingPanesRef: { current: new Set([1]) } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + const blankViewport = buildFreshShellViewportBlankingSequence(4) + expect(written).toContain(blankViewport) + expect(written.indexOf(blankViewport)).toBeLessThan(written.indexOf('PS >')) + + const rendered = await renderHeadlessTerminalState( + ['old TUI row with a long tail\r\nold TUI row two', blankViewport, 'PS >'], + 20, + 4 + ) + expect(rendered.baseY).toBeGreaterThan(0) + expect(rendered.allLines.some((line) => line.includes('old TUI row'))).toBe(true) + expect(rendered.visibleLines).toEqual(['PS >', '', '', '']) + }) + + it('cold-restores scrollback then blanks the viewport without erasing scrollback', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('fresh-pty') + const written: string[] = [] + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: 'fresh-pty', + coldRestore: { scrollback: 'cold TUI row one\r\ncold TUI row two', cwd: '/tmp/wt-1' } + } + } + return 'fresh-pty' + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] + } + } as StoreState + + const pane = createPane(1) + pane.terminal.rows = 4 + pane.terminal.cols = 20 + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + written.push(data) + callback?.() + }) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + const blankViewport = buildFreshShellViewportBlankingSequence(4) + expect(written).not.toContain('\x1b[2J\x1b[3J\x1b[H') + expect(written).toEqual( + expect.arrayContaining([ + 'cold TUI row one\r\ncold TUI row two', + POST_REPLAY_MODE_RESET, + blankViewport + ]) + ) + expect(written.indexOf('cold TUI row one\r\ncold TUI row two')).toBeLessThan( + written.indexOf(blankViewport) + ) + + const rendered = await renderHeadlessTerminalState([...written, 'PS >'], 20, 4) + expect(rendered.baseY).toBeGreaterThan(0) + expect(rendered.allLines.some((line) => line.includes('cold TUI row'))).toBe(true) + expect(rendered.visibleLines).toEqual(['PS >', '', '', '']) + }) + it('resumes the provider agent session when daemon reattach cold-restores a fresh shell', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('fresh-pty') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 66e7e5f66..d9e65bd34 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -57,6 +57,7 @@ import { RESET_KITTY_KEYBOARD_PROTOCOL, RESET_TERMINAL_CURSOR_STYLE } from './layout-serialization' +import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport' import { createShellReadyMarkerScanState, scanForShellReadyMarker } from './shell-ready-marker-scan' import { shouldUseShellReadyStartupDelivery } from '../../../../shared/codex-startup-delivery' import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing' @@ -364,6 +365,10 @@ type PendingStartupCommand = { env?: Record } +type FreshSpawnOptions = { + forceBlankRestoredViewport?: boolean +} + type ColdRestoreAgentResumeStartup = PendingStartupCommand & { agent: ResumableTuiAgent launchConfig: NonNullable>['launchConfig'] @@ -3483,10 +3488,11 @@ export function connectPanePty( } : undefined const startFreshColdRestoreAgentResume = ( - startup: ColdRestoreAgentResumeStartup | null = buildColdRestoreAgentResumeStartup() + startup: ColdRestoreAgentResumeStartup | null = buildColdRestoreAgentResumeStartup(), + options: FreshSpawnOptions = {} ): void => { applyColdRestoreAgentResumeStartup(startup) - startFreshSpawn(startup) + startFreshSpawn(startup, options) } // Why: the hibernation wake fires from noteVisibilityResume in the outer // connection scope, long after this deferred-connect closure has run. @@ -3563,9 +3569,13 @@ export function connectPanePty( }, 50) } - const startFreshSpawn = (startupOverride?: PendingStartupCommand | null): void => { + const startFreshSpawn = ( + startupOverride?: PendingStartupCommand | null, + options: FreshSpawnOptions = {} + ): void => { clearPaneMode2031State() clearHiddenOutputRestoreState() + prepareFreshShellViewportForSpawn(options) if (connectionId && startupOverride?.command) { // Why: SSH providers use `command` only as spawn metadata; the renderer // must still submit the resume command to the fresh remote shell. @@ -3832,6 +3842,24 @@ export function connectPanePty( : POST_REPLAY_REATTACH_RESET } + const consumeRestoredViewportBlankingMarker = (): boolean => { + return deps.restoredViewportBlankingPanesRef?.current.delete(pane.id) ?? false + } + + const writeFreshShellViewportBlanking = (): void => { + writeReplayData(buildFreshShellViewportBlankingSequence(pane.terminal.rows)) + } + + const prepareFreshShellViewportForSpawn = (options: FreshSpawnOptions): void => { + const hadRestoredViewport = consumeRestoredViewportBlankingMarker() + if (!options.forceBlankRestoredViewport && !hadRestoredViewport) { + return + } + // Why: fresh Windows ConPTY output paints at screen coordinates, so + // restored rows must leave the viewport before the first prompt redraw. + writeFreshShellViewportBlanking() + } + const sendFocusedReattachFocusInAfterReplay = (): void => { const scheduledGeneration = reattachReplayPayloadSignalGeneration void waitForTerminalOutputParsed(pane.terminal).then(() => { @@ -5159,7 +5187,9 @@ export function connectPanePty( if (staleSessionId) { deps.clearTabPtyId(deps.tabId, staleSessionId) } - startFreshColdRestoreAgentResume(coldRestoreStartup) + startFreshColdRestoreAgentResume(coldRestoreStartup, { + forceBlankRestoredViewport: true + }) return } registerEffectiveLaunchConfig(connectResult?.launchConfig, { @@ -5178,7 +5208,9 @@ export function connectPanePty( // Why: SSH sleep/reconnect can invalidate the relay-held PTY while // leaving the tab mounted. Replace the dead lease in-place instead of // stranding the pane behind a stale expired-session overlay. - startFreshColdRestoreAgentResume(coldRestoreStartup) + startFreshColdRestoreAgentResume(coldRestoreStartup, { + forceBlankRestoredViewport: true + }) return } setPanePtyFitBinding(ptyId) @@ -5235,14 +5267,10 @@ export function connectPanePty( } } } else if (connectResult?.coldRestore) { - // restoreScrollbackBuffers() already wrote the saved xterm buffer - // before this rAF ran. The cold-restore scrollback overlaps with - // that content; clear first. // replayIntoTerminal: the recorded scrollback is raw PTY output that // may contain query sequences the previous agent CLI emitted; // writing them through xterm.write would trigger auto-replies that // land in the new shell's stdin. See replay-guard.ts. - writeReplayData('\x1b[2J\x1b[3J\x1b[H') writeReplayData(connectResult.coldRestore.scrollback) const preparedStartup = coldRestoreStartup ?? buildColdRestoreAgentResumeStartup() const didPrepareResume = applyColdRestoreAgentResumeStartup(preparedStartup) @@ -5257,6 +5285,8 @@ export function connectPanePty( // crashed TUI (e.g. Claude's \e[?1004h) left in the scrollback, so // reset them to match the fresh shell's expectations. writeReplayData(POST_REPLAY_MODE_RESET) + consumeRestoredViewportBlankingMarker() + writeFreshShellViewportBlanking() if (!isRemoteRuntimePtyId(ptyId)) { window.api.pty.ackColdRestore(ptyId) } @@ -5502,7 +5532,9 @@ export function connectPanePty( } deps.clearExitedPanePtyLayoutBinding(pane.id, pendingSessionId) deps.clearTabPtyId(deps.tabId, pendingSessionId) - startFreshColdRestoreAgentResume(coldRestoreStartup) + startFreshColdRestoreAgentResume(coldRestoreStartup, { + forceBlankRestoredViewport: true + }) return } handleReattachResult(result, pendingSessionId, coldRestoreStartup) @@ -5525,10 +5557,14 @@ export function connectPanePty( if (isSshSessionExpiredError(err)) { deps.clearExitedPanePtyLayoutBinding(pane.id, pendingSessionId) deps.clearTabPtyId(deps.tabId, pendingSessionId) - startFreshColdRestoreAgentResume(coldRestoreStartup) + startFreshColdRestoreAgentResume(coldRestoreStartup, { + forceBlankRestoredViewport: true + }) return } - startFreshColdRestoreAgentResume(coldRestoreStartup) + startFreshColdRestoreAgentResume(coldRestoreStartup, { + forceBlankRestoredViewport: true + }) }) } else { startFreshColdRestoreAgentResume() @@ -5671,7 +5707,9 @@ export function connectPanePty( } deps.clearExitedPanePtyLayoutBinding(pane.id, deferredReattachSessionId) deps.clearTabPtyId(deps.tabId, deferredReattachSessionId) - startFreshColdRestoreAgentResume(coldRestoreStartup) + startFreshColdRestoreAgentResume(coldRestoreStartup, { + forceBlankRestoredViewport: true + }) return } handleReattachResult(result, deferredReattachSessionId, coldRestoreStartup) @@ -5699,11 +5737,15 @@ export function connectPanePty( deps.clearExitedPanePtyLayoutBinding(pane.id, deferredReattachSessionId) deps.clearTabPtyId(deps.tabId, deferredReattachSessionId) if (connectionId && isSshSessionExpiredError(err)) { - startFreshColdRestoreAgentResume(coldRestoreStartup) + startFreshColdRestoreAgentResume(coldRestoreStartup, { + forceBlankRestoredViewport: true + }) return } reportError(message) - startFreshColdRestoreAgentResume(coldRestoreStartup) + startFreshColdRestoreAgentResume(coldRestoreStartup, { + forceBlankRestoredViewport: true + }) }) } else if (detachedRemoteLeafPtyId || detachedLivePtyId || eagerLivePtyId) { // Why: mirrored web terminal layouts mount one pane per host leaf. diff --git a/src/renderer/src/components/terminal-pane/terminal-restored-viewport.test.ts b/src/renderer/src/components/terminal-pane/terminal-restored-viewport.test.ts new file mode 100644 index 000000000..02517dc67 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-restored-viewport.test.ts @@ -0,0 +1,37 @@ +import { Terminal } from '@xterm/headless' +import { describe, expect, it } from 'vitest' + +import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport' + +function writeTerminal(term: Terminal, data: string): Promise { + return new Promise((resolve) => term.write(data, resolve)) +} + +function visibleLines(term: Terminal): string[] { + const buffer = term.buffer.active + return Array.from( + { length: term.rows }, + (_, row) => buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? '' + ) +} + +describe('buildFreshShellViewportBlankingSequence', () => { + it('preserves restored rows in scrollback even after a stale TUI scroll region', async () => { + const term = new Terminal({ cols: 20, rows: 5, allowProposedApi: true }) + try { + await writeTerminal(term, 'row1\r\nrow2\r\nrow3\r\nrow4\r\nrow5\x1b[2;4r\x1b[?6h\x1b[H') + await writeTerminal(term, buildFreshShellViewportBlankingSequence(term.rows)) + + expect(term.buffer.active.baseY).toBeGreaterThan(0) + expect(visibleLines(term)).toEqual(['', '', '', '', '']) + expect( + Array.from( + { length: term.buffer.active.length }, + (_, row) => term.buffer.active.getLine(row)?.translateToString(true) ?? '' + ) + ).toEqual(expect.arrayContaining(['row1', 'row2', 'row3', 'row4', 'row5'])) + } finally { + term.dispose() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-restored-viewport.ts b/src/renderer/src/components/terminal-pane/terminal-restored-viewport.ts new file mode 100644 index 000000000..6aff5b34d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-restored-viewport.ts @@ -0,0 +1,10 @@ +import type { RefObject } from 'react' + +export type RestoredViewportBlankingPanesRef = RefObject> + +export function buildFreshShellViewportBlankingSequence(rows: number): string { + const viewportRows = Math.max(1, Math.floor(Number.isFinite(rows) ? rows : 24)) + // Why: newline scrolling preserves restored rows in xterm scrollback; CSI S + // drops them. Reset margins first so stale TUI scroll regions cannot trap it. + return `\x1b[?6l\x1b[r\x1b[${viewportRows};1H${'\r\n'.repeat(viewportRows)}\x1b[H` +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index fd7263b58..e037e1e7f 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -563,6 +563,7 @@ export function useTerminalPaneLifecycle({ const imeCompositionDisposablesRef = useRef(new Map()) const imeNativeTextForwarderDisposablesRef = useRef(new Map()) const queuedInitialCwdRef = useRef(undefined) + const restoredViewportBlankingPanesRef = useRef(new Set()) const applyAppearance = (manager: PaneManager): void => { const currentSettings = settingsRef.current @@ -726,6 +727,7 @@ export function useTerminalPaneLifecycle({ paneMode2031Ref, paneLastThemeModeRef, replayingPanesRef, + restoredViewportBlankingPanesRef, isActiveRef, isVisibleRef, onPtyExitRef, @@ -1215,6 +1217,7 @@ export function useTerminalPaneLifecycle({ clearRuntimePaneTitle(tabId, paneId) paneFontSizesRef.current.delete(paneId) replayingPanesRef.current.delete(paneId) + restoredViewportBlankingPanesRef.current.delete(paneId) // Clean up pane title state so closed panes don't leave stale entries. setPaneTitles((prev) => { if (!(paneId in prev)) { @@ -1406,7 +1409,13 @@ export function useTerminalPaneLifecycle({ const restoredPaneByLeafId = replayTerminalLayout(manager, initialLayoutRef.current, isActive) const restoredBuffers = initialLayoutRef.current.buffersByLeafId - restoreScrollbackBuffers(manager, restoredBuffers, restoredPaneByLeafId, replayingPanesRef) + restoreScrollbackBuffers( + manager, + restoredBuffers, + restoredPaneByLeafId, + replayingPanesRef, + restoredViewportBlankingPanesRef + ) if (restoredBuffers && initialLayoutRef.current.scrollbackRefsByLeafId) { const layoutWithoutRestoredBuffers = { ...initialLayoutRef.current } delete layoutWithoutRestoredBuffers.buffersByLeafId