fix(terminal): widen interactive-redraw windows by the pane's measured echo delay (#29419)

Remote panes cannot echo a keystroke before one round trip, so past ~150ms
RTT their own typing fell out of the fast paint lane. Each pane now keeps the
recent minimum input-to-output delay and adds it (clamped to 500ms) to the
redraw and synchronized-frame windows; local panes measure ~0 and keep the
existing windows.

Linear: STA-5314
This commit is contained in:
Neil
2026-10-15 23:19:44 -07:00
committed by GitHub
parent d71e1981e0
commit f11e1d3e43
6 changed files with 235 additions and 3 deletions
@@ -29,6 +29,8 @@ import {
consumeInactiveForegroundImmediateBudget
} from './foreground-output-scan'
import { terminalInputEchoAllowanceMs } from './terminal-input-echo-delay'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
export function bindForegroundOutputRefresh(session: ConnectPanePtySession): void {
@@ -261,7 +263,7 @@ export function bindForegroundOutputRefresh(session: ConnectPanePtySession): voi
}
const recentInput =
performance.now() - session.lastInteractiveRedrawInputAt <=
FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS
FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS + terminalInputEchoAllowanceMs(session.inputEchoDelay)
if (
recentInput &&
data.length <= FOREGROUND_INTERACTIVE_REDRAW_CHARS &&
@@ -303,7 +305,7 @@ export function bindForegroundOutputRefresh(session: ConnectPanePtySession): voi
session.foregroundRewriteOutputPrefersRenderRefresh(data)
const recentInput =
performance.now() - session.lastInteractiveRedrawInputAt <=
FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS
FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS + terminalInputEchoAllowanceMs(session.inputEchoDelay)
if (session.foregroundRendererRiskOutputPrefersRenderRefresh(data)) {
return {
refresh: true,
@@ -31,6 +31,10 @@ import {
import { resolveLatestAgentDoneStartedAt } from './agent-done-started-at'
import { rendererAgentStatusObservations } from '@/lib/renderer-agent-status-observations'
import {
createTerminalInputEchoDelay,
noteTerminalInputAwaitingEcho
} from './terminal-input-echo-delay'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
@@ -220,6 +224,7 @@ export function installPaneHostCapabilityFlags(session: ConnectPanePtySession):
session.hadExistingPaneTransportAtConnect = session.deps.paneTransportsRef.current.size > 0
session.lastTerminalInputAt = Number.NEGATIVE_INFINITY
session.lastInteractiveRedrawInputAt = Number.NEGATIVE_INFINITY
session.inputEchoDelay = createTerminalInputEchoDelay()
session.hasReceivedPtyOutput = false
session.deferredReattachLiveData = null
session.reattachLiveDataDeferralDepth = 0
@@ -231,6 +236,7 @@ export function installPaneHostCapabilityFlags(session: ConnectPanePtySession):
}
session.markInteractiveRedrawInput = (): void => {
session.lastInteractiveRedrawInputAt = performance.now()
noteTerminalInputAwaitingEcho(session.inputEchoDelay, session.lastInteractiveRedrawInputAt)
if (session.synchronizedForegroundOutputActive) {
session.synchronizedForegroundFrameInteractive = true
session.synchronizedForegroundInteractivePresentPending = true
@@ -0,0 +1,162 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
INPUT_ECHO_DELAY_MAX_ALLOWANCE_MS,
createTerminalInputEchoDelay,
noteTerminalInputAwaitingEcho,
noteTerminalOutputAfterInput,
terminalInputEchoAllowanceMs,
type TerminalInputEchoDelay
} from './terminal-input-echo-delay'
import { bindForegroundOutputRefresh } from './foreground-output-refresh'
import { bindWritePtyOutputToXterm } from './write-pty-output-to-xterm'
import { FOREGROUND_THROUGHPUT_IMMEDIATE_CHARS } from './foreground-output-budgets'
const writeTerminalOutput = vi.hoisted(() => vi.fn())
vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({
writeTerminalOutput
}))
vi.mock('@/lib/pane-manager/terminal-render-pause-release', () => ({
forceFullViewportPresent: vi.fn()
}))
vi.mock('@/lib/pane-manager/terminal-delivery-credit', () => ({
takeCurrentTerminalDeliveryCredit: vi.fn(() => undefined)
}))
afterEach(() => {
vi.restoreAllMocks()
writeTerminalOutput.mockReset()
})
describe('terminal input echo delay', () => {
it('measures the round trip from the oldest unanswered input', () => {
const delay = createTerminalInputEchoDelay()
noteTerminalInputAwaitingEcho(delay, 1000)
noteTerminalInputAwaitingEcho(delay, 1040)
noteTerminalOutputAfterInput(delay, 1200)
expect(terminalInputEchoAllowanceMs(delay)).toBe(200)
})
it('stays at zero with no input, so output alone never widens the windows', () => {
const delay = createTerminalInputEchoDelay()
noteTerminalOutputAfterInput(delay, 5000)
expect(terminalInputEchoAllowanceMs(delay)).toBe(0)
})
it('takes the recent minimum so one slow frame cannot inflate it', () => {
const delay = createTerminalInputEchoDelay()
for (const [input, output] of [
[0, 210],
[1000, 1900],
[2000, 2205]
]) {
noteTerminalInputAwaitingEcho(delay, input)
noteTerminalOutputAfterInput(delay, output)
}
expect(terminalInputEchoAllowanceMs(delay)).toBe(205)
})
it('ignores output that cannot be the echo and clamps the allowance', () => {
const delay = createTerminalInputEchoDelay()
noteTerminalInputAwaitingEcho(delay, 0)
noteTerminalOutputAfterInput(delay, 5000)
expect(terminalInputEchoAllowanceMs(delay)).toBe(0)
noteTerminalInputAwaitingEcho(delay, 6000)
noteTerminalOutputAfterInput(delay, 6900)
expect(terminalInputEchoAllowanceMs(delay)).toBe(INPUT_ECHO_DELAY_MAX_ALLOWANCE_MS)
})
it('restarts a pending input that went unanswered past the sample limit', () => {
const delay = createTerminalInputEchoDelay()
noteTerminalInputAwaitingEcho(delay, 0)
noteTerminalInputAwaitingEcho(delay, 3000)
noteTerminalOutputAfterInput(delay, 3180)
expect(terminalInputEchoAllowanceMs(delay)).toBe(180)
})
})
function mockNow(now: { value: number }): void {
vi.spyOn(performance, 'now').mockImplementation(() => now.value)
}
describe('remote echo keeps typing latency-sensitive', () => {
const largeRedraw = `\x1b[H${'x'.repeat(FOREGROUND_THROUGHPUT_IMMEDIATE_CHARS + 1)}`
type RefreshSession = {
[key: string]: unknown
lastInteractiveRedrawInputAt: number
inputEchoDelay: TerminalInputEchoDelay
isLatencySensitiveForegroundOutput?: (data: string) => boolean
}
function createRefreshSession(): RefreshSession {
const session: RefreshSession = {
deps: { isActiveRef: { current: true } },
manager: { getActivePane: () => null },
foregroundImmediateBudget: { chars: 0, windowStart: 0 },
lastInteractiveRedrawInputAt: Number.NEGATIVE_INFINITY,
inputEchoDelay: createTerminalInputEchoDelay()
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: isLatencySensitiveForegroundOutput reads only the fields above plus its own bindings.
bindForegroundOutputRefresh(session as never)
return session
}
it('classifies a 200ms-RTT redraw as interactive once the echo delay is known', () => {
const now = { value: 10_000 }
mockNow(now)
const session = createRefreshSession()
// Local calibration alone: the redraw lands 200ms after input, outside the 150ms window.
session.lastInteractiveRedrawInputAt = now.value
now.value += 200
expect(session.isLatencySensitiveForegroundOutput?.(largeRedraw)).toBe(false)
noteTerminalInputAwaitingEcho(session.inputEchoDelay, 11_000)
noteTerminalOutputAfterInput(session.inputEchoDelay, 11_200)
now.value = 12_000
session.lastInteractiveRedrawInputAt = now.value
now.value += 200
expect(session.isLatencySensitiveForegroundOutput?.(largeRedraw)).toBe(true)
})
it('calibrates the synchronized-frame latch from the echo it is writing', () => {
const now = { value: 50_000 }
mockNow(now)
const inputEchoDelay = createTerminalInputEchoDelay()
const session: {
[key: string]: unknown
writePtyOutputToXterm?: (data: string, foreground: boolean) => void
} = {
kittyKeyboardModes: { scan: vi.fn() },
resetHiddenOutputRestoreIfPtyChanged: vi.fn(),
transport: { getPtyId: vi.fn(() => 'remote:pty-1') },
canUseHiddenOutputSnapshot: vi.fn(() => false),
shouldSnapshotHiddenCodexOutput: false,
shouldProtectNativeWindowsSynchronizedOutput: true,
shouldApplyNativeWindowsRewriteRefresh: false,
synchronizedForegroundMarkerTail: '',
synchronizedForegroundOutputActive: false,
synchronizedForegroundFrameInteractive: false,
synchronizedForegroundInteractivePresentPending: false,
lastTerminalInputAt: now.value,
inputEchoDelay,
scheduleForegroundGridDriftCheck: vi.fn(),
shouldForceForegroundRenderRefresh: vi.fn(() => ({ refresh: false, inPlaceRewrite: false })),
isLatencySensitiveForegroundOutput: vi.fn(() => false),
shouldRefreshForegroundSynchronously: false,
markHiddenOutputRestoreNeeded: vi.fn(),
pane: { terminal: {} }
}
noteTerminalInputAwaitingEcho(inputEchoDelay, now.value)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture supplies exactly the fields read by this isolated binding test.
bindWritePtyOutputToXterm(session as never)
// A 450ms round trip: past the local 400ms latch, inside it once widened by the echo.
now.value += 450
session.writePtyOutputToXterm?.('\x1b[?2026hframe', true)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the scheduler mock receives the binding's documented options object.
const options = writeTerminalOutput.mock.calls[0]?.[2] as { latencySensitive?: boolean }
expect(options.latencySensitive).toBe(true)
})
})
@@ -0,0 +1,51 @@
// Why: the interactive-redraw windows are calibrated against local echo, so a
// remote pane's round trip must widen them or its own typing stops reading as typing (STA-5314).
const INPUT_ECHO_DELAY_SAMPLE_COUNT = 8
// A first output later than this answered something other than the keystroke.
const INPUT_ECHO_DELAY_MAX_SAMPLE_MS = 1000
export const INPUT_ECHO_DELAY_MAX_ALLOWANCE_MS = 500
export type TerminalInputEchoDelay = {
pendingInputAt: number | null
samples: number[]
}
export function createTerminalInputEchoDelay(): TerminalInputEchoDelay {
return { pendingInputAt: null, samples: [] }
}
/** Keeps the oldest unanswered input, so a burst inside one round trip measures that trip. */
export function noteTerminalInputAwaitingEcho(delay: TerminalInputEchoDelay, now: number): void {
if (
delay.pendingInputAt === null ||
now - delay.pendingInputAt > INPUT_ECHO_DELAY_MAX_SAMPLE_MS
) {
delay.pendingInputAt = now
}
}
export function noteTerminalOutputAfterInput(delay: TerminalInputEchoDelay, now: number): void {
if (delay.pendingInputAt === null) {
return
}
const sample = now - delay.pendingInputAt
delay.pendingInputAt = null
if (sample < 0 || sample > INPUT_ECHO_DELAY_MAX_SAMPLE_MS) {
return
}
delay.samples.push(sample)
if (delay.samples.length > INPUT_ECHO_DELAY_SAMPLE_COUNT) {
delay.samples.shift()
}
}
/**
* The recent minimum input-to-output delay. Unrelated output only lowers it,
* which falls back to the local windows; slow app frames cannot inflate it.
*/
export function terminalInputEchoAllowanceMs(delay: TerminalInputEchoDelay): number {
if (delay.samples.length === 0) {
return 0
}
return Math.min(INPUT_ECHO_DELAY_MAX_ALLOWANCE_MS, Math.min(...delay.samples))
}
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { bindWritePtyOutputToXterm } from './write-pty-output-to-xterm'
import { createTerminalInputEchoDelay } from './terminal-input-echo-delay'
const writeTerminalOutput = vi.hoisted(() => vi.fn())
const forceFullViewportPresent = vi.hoisted(() => vi.fn())
@@ -31,6 +32,7 @@ function createSession(startupOnParsed: () => void) {
synchronizedForegroundFrameInteractive: false,
synchronizedForegroundInteractivePresentPending: true,
lastTerminalInputAt: 0,
inputEchoDelay: createTerminalInputEchoDelay(),
scheduleForegroundGridDriftCheck: vi.fn(),
shouldForceForegroundRenderRefresh: vi.fn(() => ({ refresh: false, inPlaceRewrite: false })),
isLatencySensitiveForegroundOutput: vi.fn(() => false),
@@ -11,6 +11,10 @@ import {
containsCursorRestore
} from './foreground-output-scan'
import { containsHiddenStartupRendererQuery } from './hidden-startup-renderer-query'
import {
noteTerminalOutputAfterInput,
terminalInputEchoAllowanceMs
} from './terminal-input-echo-delay'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
@@ -23,6 +27,10 @@ export function bindWritePtyOutputToXterm(session: ConnectPanePtySession): void
): void {
// Why: every application byte funnels through here, so it's the one place the kitty keyboard mirror observes the pane's protocol negotiation.
session.kittyKeyboardModes.scan(data)
// Why before classification: this chunk may be the echo that calibrates its own window.
if (data.length > 0) {
noteTerminalOutputAfterInput(session.inputEchoDelay, performance.now())
}
if (foreground) {
session.resetHiddenOutputRestoreIfPtyChanged()
}
@@ -70,7 +78,8 @@ export function bindWritePtyOutputToXterm(session: ConnectPanePtySession): void
if (synchronizedForegroundOutput && synchronizedOutputStarted) {
session.synchronizedForegroundFrameInteractive =
performance.now() - session.lastTerminalInputAt <=
FOREGROUND_SYNCHRONIZED_FRAME_INTERACTIVE_WINDOW_MS
FOREGROUND_SYNCHRONIZED_FRAME_INTERACTIVE_WINDOW_MS +
terminalInputEchoAllowanceMs(session.inputEchoDelay)
} else if (!nextSynchronizedForegroundOutputActive && !synchronizedOutputEnded) {
session.synchronizedForegroundFrameInteractive = false
}