From f11e1d3e439c586b6391f522bc7bdbb1949cbf5a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:19:44 -0700 Subject: [PATCH] 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 --- .../foreground-output-refresh.ts | 6 +- .../pane-host-capability-flags.ts | 6 + .../terminal-input-echo-delay.test.ts | 162 ++++++++++++++++++ .../terminal-input-echo-delay.ts | 51 ++++++ ...y-output-to-xterm-startup-callback.test.ts | 2 + .../write-pty-output-to-xterm.ts | 11 +- 6 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/terminal-input-echo-delay.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/terminal-input-echo-delay.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts index a836d6712b9..9448871df7b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts @@ -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, diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-host-capability-flags.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-host-capability-flags.ts index 911fc9ef086..b687d091f14 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-host-capability-flags.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-host-capability-flags.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/pty-connection/terminal-input-echo-delay.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/terminal-input-echo-delay.test.ts new file mode 100644 index 00000000000..35c013bfe68 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/terminal-input-echo-delay.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/terminal-input-echo-delay.ts b/src/renderer/src/components/terminal-pane/pty-connection/terminal-input-echo-delay.ts new file mode 100644 index 00000000000..460d43922c8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/terminal-input-echo-delay.ts @@ -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)) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm-startup-callback.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm-startup-callback.test.ts index 86602e1ae4b..4736784c03d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm-startup-callback.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm-startup-callback.test.ts @@ -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), diff --git a/src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm.ts b/src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm.ts index 34fdd14bf70..1bc9e45a61b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm.ts @@ -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 }