Fix stale terminal grid drift (#6959)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-30 16:29:51 -07:00 committed by GitHub
parent 6b66bda634
commit f9d8b532f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 157 additions and 0 deletions

View File

@ -11613,6 +11613,103 @@ describe('connectPanePty', () => {
}
})
it('repairs stale xterm grid drift on foreground output even without a pane resize', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-pane-2')
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
transport.connect.mockImplementation(
async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'pty-pane-2'
}
)
transportFactoryQueue.push(transport)
const manager = createManager(2)
const deps = createDeps({
restoredLeafId: LEAF_2,
paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
})
const pane = createPane(2)
let proposedGrid = { cols: 62, rows: 63 }
pane.terminal.cols = 62
pane.terminal.rows = 63
pane.fitAddon = {
...pane.fitAddon,
fit: vi.fn(() => {
pane.terminal.cols = proposedGrid.cols
pane.terminal.rows = proposedGrid.rows
}),
proposeDimensions: vi.fn(() => proposedGrid)
} as never
vi.mocked(window.api.pty.getSize).mockResolvedValue({ cols: 62, rows: 63 })
connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks()
proposedGrid = { cols: 65, rows: 63 }
vi.mocked(pane.fitAddon.fit).mockClear()
transport.resize.mockClear()
vi.mocked(window.api.pty.getSize).mockClear()
expect(capturedDataCallback.current).not.toBeNull()
capturedDataCallback.current?.('\x1b[?2026hcodex redraw frame')
await flushAsyncTicks()
expect(pane.fitAddon.fit).toHaveBeenCalled()
expect(window.api.pty.getSize).toHaveBeenCalledWith('pty-pane-2')
expect(transport.resize).toHaveBeenCalledWith(65, 63)
})
it('skips foreground grid drift repair while mobile owns the PTY without a fit override', async () => {
const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state')
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-pane-2')
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
transport.connect.mockImplementation(
async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'pty-pane-2'
}
)
transportFactoryQueue.push(transport)
const manager = createManager(2)
const deps = createDeps({
restoredLeafId: LEAF_2,
paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
})
const pane = createPane(2)
let proposedGrid = { cols: 62, rows: 63 }
pane.terminal.cols = 62
pane.terminal.rows = 63
pane.fitAddon = {
...pane.fitAddon,
fit: vi.fn(() => {
pane.terminal.cols = proposedGrid.cols
pane.terminal.rows = proposedGrid.rows
}),
proposeDimensions: vi.fn(() => proposedGrid)
} as never
try {
connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks()
proposedGrid = { cols: 65, rows: 63 }
setDriverForPty('pty-pane-2', { kind: 'mobile', clientId: 'phone-1' })
vi.mocked(pane.fitAddon.fit).mockClear()
transport.resize.mockClear()
vi.mocked(window.api.pty.getSize).mockClear()
expect(capturedDataCallback.current).not.toBeNull()
capturedDataCallback.current?.('\x1b[?2026hcodex redraw frame')
await flushAsyncTicks()
expect(pane.fitAddon.fit).not.toHaveBeenCalled()
expect(window.api.pty.getSize).not.toHaveBeenCalled()
expect(transport.resize).not.toHaveBeenCalled()
} finally {
setDriverForPty('pty-pane-2', { kind: 'idle' })
}
})
it('reports desktop geometry without resizing while a mobile-fit override is active', async () => {
const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides')
const pane = createPane(2)

View File

@ -182,6 +182,7 @@ const FOREGROUND_SYNCHRONIZED_FRAME_INTERACTIVE_WINDOW_MS = 400
const FOREGROUND_IMMEDIATE_BUDGET_CHARS = 128 * 1024
const FOREGROUND_BUDGET_WINDOW_MS = 500
const INACTIVE_FOREGROUND_IMMEDIATE_BUDGET_CHARS = 32 * 1024
const FOREGROUND_GRID_DRIFT_CHECK_MIN_MS = 250
// Why: this is only shown if hidden renderer output was skipped and main-owned
// terminal state is unavailable, so the user has an explicit loss signal.
const HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING =
@ -2324,6 +2325,58 @@ export function connectPanePty(
getAppliedSize: (ptyId) => window.api.pty.getSize(ptyId),
forwardResize: forwardPtyResize
})
let pendingForegroundGridDriftCheckRaf: number | null = null
let lastForegroundGridDriftCheckAt = Number.NEGATIVE_INFINITY
const readProposedTerminalGrid = (): { cols: number; rows: number } | null => {
try {
const proposed = pane.fitAddon.proposeDimensions()
if (!proposed || proposed.cols <= 0 || proposed.rows <= 0) {
return null
}
return proposed
} catch {
return null
}
}
const terminalGridDriftedFromFit = (): boolean => {
const proposed = readProposedTerminalGrid()
return Boolean(
proposed && (pane.terminal.cols !== proposed.cols || pane.terminal.rows !== proposed.rows)
)
}
const scheduleForegroundGridDriftCheck = (): void => {
// Why: mobile-owned PTYs intentionally keep a non-desktop grid; drift
// healing would refit xterm even if resize forwarding is later suppressed.
if (
disposed ||
!deps.isVisibleRef.current ||
shouldSuppressDesktopPtyResize() ||
pendingForegroundGridDriftCheckRaf !== null
) {
return
}
const now = performance.now()
if (now - lastForegroundGridDriftCheckAt < FOREGROUND_GRID_DRIFT_CHECK_MIN_MS) {
return
}
lastForegroundGridDriftCheckAt = now
pendingForegroundGridDriftCheckRaf = requestAnimationFrame(() => {
pendingForegroundGridDriftCheckRaf = null
if (
disposed ||
!deps.isVisibleRef.current ||
shouldSuppressDesktopPtyResize() ||
!terminalGridDriftedFromFit()
) {
return
}
// Why: xterm cell metrics can settle after the DOM box stops resizing, so
// ResizeObserver never fires even though FitAddon now proposes more cols.
requestStablePaneFit(pane as ManagedPaneInternal, () =>
ptySizeReassertion.request({ fit: false })
)
})
}
// Why: observe the outer pane as the layout signal for both desktop drift
// healing and mobile take-back. Normal desktop panes compare xterm against
@ -3269,6 +3322,9 @@ export function connectPanePty(
const nativeWindowsCursorRestore =
shouldProtectNativeWindowsSynchronizedOutput && foreground && containsCursorRestore(data)
const foregroundOutput = foreground || parseHiddenStartupOutput
if (foreground) {
scheduleForegroundGridDriftCheck()
}
const renderRefreshDecision = foregroundOutput
? shouldForceForegroundRenderRefresh(data)
: { refresh: false, inPlaceRewrite: false, recoverWebglAtlasAfterParse: false }
@ -4829,6 +4885,10 @@ export function connectPanePty(
startupGridSettleHandle?.cancel()
startupGridSettleHandle = null
ptySizeReassertion.dispose()
if (pendingForegroundGridDriftCheckRaf !== null) {
cancelAnimationFrame(pendingForegroundGridDriftCheckRaf)
pendingForegroundGridDriftCheckRaf = null
}
if (terminalKeyTargetSupportsEvents) {
terminalKeyTarget.removeEventListener('keydown', onTerminalKeyDown, { capture: true })
}