Fix Windows ConPTY overlay after cold restore (#7310)
This commit is contained in:
parent
7595baccef
commit
a074ed6182
|
|
@ -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<number, number>() }
|
||||
const restoredViewportBlankingPanesRef = { current: new Set<number>() }
|
||||
|
||||
restoreScrollbackBuffers(
|
||||
manager as unknown as Parameters<typeof restoreScrollbackBuffers>[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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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<string, string> | undefined,
|
||||
restoredPaneByLeafId: Map<string, number>,
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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<Map<number, boolean>>
|
||||
paneLastThemeModeRef: React.RefObject<Map<number, TerminalColorSchemeMode>>
|
||||
replayingPanesRef: ReplayingPanesRef
|
||||
restoredViewportBlankingPanesRef?: RestoredViewportBlankingPanesRef
|
||||
isActiveRef: React.RefObject<boolean>
|
||||
isVisibleRef: React.RefObject<boolean>
|
||||
onPtyExitRef: React.RefObject<(ptyId: string) => void>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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<string, string>
|
||||
}
|
||||
|
||||
type FreshSpawnOptions = {
|
||||
forceBlankRestoredViewport?: boolean
|
||||
}
|
||||
|
||||
type ColdRestoreAgentResumeStartup = PendingStartupCommand & {
|
||||
agent: ResumableTuiAgent
|
||||
launchConfig: NonNullable<ReturnType<typeof buildAgentResumeStartupPlan>>['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.
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import type { RefObject } from 'react'
|
||||
|
||||
export type RestoredViewportBlankingPanesRef = RefObject<Set<number>>
|
||||
|
||||
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`
|
||||
}
|
||||
|
|
@ -563,6 +563,7 @@ export function useTerminalPaneLifecycle({
|
|||
const imeCompositionDisposablesRef = useRef(new Map<number, IDisposable>())
|
||||
const imeNativeTextForwarderDisposablesRef = useRef(new Map<number, IDisposable>())
|
||||
const queuedInitialCwdRef = useRef<string | null | undefined>(undefined)
|
||||
const restoredViewportBlankingPanesRef = useRef(new Set<number>())
|
||||
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue