Fix stale terminal grid drift (#6959)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
6b66bda634
commit
f9d8b532f1
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue