mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 11:07:42 +00:00
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:
+4
-2
@@ -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
|
||||
|
||||
+162
@@ -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))
|
||||
}
|
||||
+2
@@ -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),
|
||||
|
||||
+10
-1
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user