Coalesce PTY input write bursts (#7205)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-07-02 23:43:35 -07:00 committed by GitHub
parent 2848c5daf4
commit 10e8f86899
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 756 additions and 103 deletions

View File

@ -0,0 +1,157 @@
import { describe, expect, it } from 'vitest'
import {
TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS,
createPtyInputWriteQueue
} from './pty-input-write-queue'
import {
TERMINAL_INPUT_CHUNK_MAX_BYTES,
TERMINAL_INPUT_MAX_BYTES
} from '../../../../shared/terminal-input'
const WHEEL_UP_REPORT = '\x1b[<64;60;20M'
type WriteRecord = { id: string; data: string }
function createRecordingQueue(options: { writable?: () => boolean } = {}): {
writes: WriteRecord[]
queue: ReturnType<typeof createPtyInputWriteQueue>
} {
const writes: WriteRecord[] = []
const queue = createPtyInputWriteQueue({
isWritable: () => options.writable?.() ?? true,
write: (id, data) => writes.push({ id, data })
})
return { writes, queue }
}
describe('pty input write queue', () => {
it('coalesces a dense burst of wheel reports instead of one write per macrotask turn', async () => {
const { writes, queue } = createRecordingQueue()
// Simulates a 2s aggressive trackpad gesture at 120Hz: 240 SGR reports
// enqueued while the drain cannot run between events.
for (let i = 0; i < 240; i += 1) {
expect(queue.enqueue('pty-1', WHEEL_UP_REPORT)).toBe(true)
}
await queue.waitForDrain()
// First report flushes immediately (keystroke latency); everything queued
// behind it must drain as a single coalesced write, not 239 timer turns.
expect(writes.length).toBe(2)
expect(writes[0]?.data).toBe(WHEEL_UP_REPORT)
expect(writes[1]?.data).toBe(WHEEL_UP_REPORT.repeat(239))
expect(writes.map((write) => write.id)).toEqual(['pty-1', 'pty-1'])
})
it('preserves byte order and content across coalesced writes', async () => {
const { writes, queue } = createRecordingQueue()
const inputs = ['a', '\x1b[<65;1;1M', 'bc', '\x1b[A', 'd']
for (const input of inputs) {
queue.enqueue('pty-1', input)
}
await queue.waitForDrain()
expect(writes.map((write) => write.data).join('')).toBe(inputs.join(''))
})
it('does not coalesce across different PTY ids', async () => {
const writes: WriteRecord[] = []
const queue = createPtyInputWriteQueue({
isWritable: () => true,
write: (id, data) => writes.push({ id, data })
})
queue.enqueue('pty-1', 'a')
queue.enqueue('pty-1', 'b')
queue.enqueue('pty-2', 'c')
queue.enqueue('pty-1', 'd')
await queue.waitForDrain()
expect(writes).toEqual([
{ id: 'pty-1', data: 'a' },
{ id: 'pty-1', data: 'b' },
{ id: 'pty-2', data: 'c' },
{ id: 'pty-1', data: 'd' }
])
})
it('keeps coalesced payloads under the input chunk byte cap', async () => {
const { writes, queue } = createRecordingQueue()
const piece = 'x'.repeat(1000)
for (let i = 0; i < 12; i += 1) {
queue.enqueue('pty-1', piece)
}
await queue.waitForDrain()
expect(writes.map((write) => write.data).join('')).toBe(piece.repeat(12))
for (const write of writes) {
expect(write.data.length).toBeLessThanOrEqual(TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS)
}
expect(writes.length).toBeGreaterThan(1)
})
it('still chunks oversized items and keeps trailing input ordered behind them', async () => {
const { writes, queue } = createRecordingQueue()
const large = 'y'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2 + 100)
queue.enqueue('pty-1', 'before')
queue.enqueue('pty-1', large)
queue.enqueue('pty-1', 'after')
await queue.waitForDrain()
expect(writes.map((write) => write.data).join('')).toBe(`before${large}after`)
expect(writes.at(-1)?.data).toBe('after')
for (const write of writes) {
expect(write.data.length).toBeLessThanOrEqual(TERMINAL_INPUT_CHUNK_MAX_BYTES)
}
})
it('rejects input over the terminal input byte limit without writing', async () => {
const { writes, queue } = createRecordingQueue()
expect(queue.enqueue('pty-1', 'z'.repeat(TERMINAL_INPUT_MAX_BYTES + 1))).toBe(false)
await queue.waitForDrain()
expect(writes).toEqual([])
})
it('drops queued input for PTYs that are no longer writable', async () => {
let writable = true
const { writes, queue } = createRecordingQueue({ writable: () => writable })
queue.enqueue('pty-1', 'a')
writable = false
queue.enqueue('pty-1', 'b')
await queue.waitForDrain()
expect(writes).toEqual([{ id: 'pty-1', data: 'a' }])
})
it('clear() drops pending input that has not been written yet', async () => {
const writes: WriteRecord[] = []
const pendingYields: (() => void)[] = []
const queue = createPtyInputWriteQueue({
isWritable: () => true,
write: (id, data) => writes.push({ id, data }),
yieldBetweenWrites: () =>
new Promise<void>((resolve) => {
pendingYields.push(resolve)
})
})
const large = 'y'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 3)
queue.enqueue('pty-1', large)
queue.enqueue('pty-1', 'tail')
// First chunk is written synchronously, then the drain parks on the yield.
expect(writes.length).toBe(1)
queue.clear()
pendingYields.shift()?.()
await queue.waitForDrain()
expect(writes.length).toBe(1)
expect(writes.map((write) => write.data).join('')).not.toContain('tail')
})
})

View File

@ -0,0 +1,155 @@
import {
isTerminalInputTooLargeWithDeferredMeasurement,
iterateTerminalInputChunks
} from '../../../../shared/terminal-input'
// Why: 4096 UTF-16 code units encode to at most ~12KB UTF-8, safely under the
// 16KB TERMINAL_INPUT_CHUNK_MAX_BYTES cap without paying byte measurement on
// the hot input path.
export const TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS = 4096
type PendingPtyInputWrite = {
id: string
text: string
tooLarge: boolean | Promise<boolean>
chunks?: Iterator<string>
nextChunk?: string
}
export type PtyInputWriteQueue = {
enqueue: (id: string, data: string) => boolean
waitForDrain: () => Promise<void>
clear: () => void
}
export type PtyInputWriteQueueDeps = {
isWritable: (id: string) => boolean
write: (id: string, data: string) => void
yieldBetweenWrites?: () => Promise<void>
}
function defaultYieldBetweenWrites(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0))
}
function isCoalescibleText(text: string): boolean {
return text.length <= TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS
}
export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInputWriteQueue {
const yieldBetweenWrites = deps.yieldBetweenWrites ?? defaultYieldBetweenWrites
let pending: PendingPtyInputWrite[] = []
let drainPromise: Promise<void> | null = null
async function drain(): Promise<void> {
while (pending.length > 0) {
const next = pending[0]
if (!next) {
pending.shift()
continue
}
if (!deps.isWritable(next.id)) {
pending.shift()
continue
}
if (next.tooLarge !== false) {
next.tooLarge = await Promise.resolve(next.tooLarge).catch(() => true)
if (next.tooLarge) {
pending.shift()
continue
}
if (!deps.isWritable(next.id)) {
pending.shift()
continue
}
}
// Why: dense input streams (SGR wheel reports during trackpad momentum,
// key auto-repeat) enqueue one tiny item per event. Writing one item per
// macrotask turn lets Chromium's nested-timer clamp pace the drain at
// ≥4ms per item, so a fast gesture's reports reach the PTY seconds after
// the gesture ended and the TUI visibly replays them one by one.
// Coalescing consecutive validated small items into a single write keeps
// the PTY byte stream identical while draining the backlog in one turn.
if (next.chunks === undefined && isCoalescibleText(next.text)) {
let payload = next.text
pending.shift()
while (pending.length > 0) {
const peek = pending[0]
if (
!peek ||
peek.id !== next.id ||
peek.tooLarge !== false ||
peek.chunks !== undefined ||
!isCoalescibleText(peek.text) ||
payload.length + peek.text.length > TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS
) {
break
}
payload += peek.text
pending.shift()
}
deps.write(next.id, payload)
if (pending.length > 0) {
await yieldBetweenWrites()
}
continue
}
next.chunks ??= iterateTerminalInputChunks(next.text)
const chunk =
next.nextChunk === undefined ? next.chunks.next() : { done: false, value: next.nextChunk }
next.nextChunk = undefined
if (chunk.done) {
pending.shift()
continue
}
deps.write(next.id, chunk.value)
const following = next.chunks.next()
if (following.done) {
pending.shift()
} else {
next.nextChunk = following.value
}
if (pending.length > 0) {
await yieldBetweenWrites()
}
}
}
function scheduleDrain(): void {
if (drainPromise) {
return
}
drainPromise = drain().finally(() => {
drainPromise = null
if (pending.length > 0) {
scheduleDrain()
}
})
}
return {
enqueue(id: string, data: string): boolean {
try {
const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data)
if (tooLarge === true) {
return false
}
pending.push({ id, text: data, tooLarge })
scheduleDrain()
return true
} catch {
return false
}
},
async waitForDrain(): Promise<void> {
while (drainPromise) {
await drainPromise
}
},
clear(): void {
pending = []
}
}
}

View File

@ -23,6 +23,7 @@ import {
getEagerPtyBufferHandle
} from './pty-dispatcher'
import { drainPreHandlerPtyData, drainPreHandlerPtyExit } from './pty-pre-handler-buffer'
import { createPtyInputWriteQueue } from './pty-input-write-queue'
import type { PtyDataMeta } from './pty-dispatcher'
import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types'
import { createBellDetector } from './bell-detector'
@ -59,14 +60,6 @@ const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED'
const STALE_TITLE_TIMEOUT = 3000 // ms before stale working title is cleared
const MAX_PTY_SIDE_EFFECTS_PER_DRAIN = 64
type PendingPtyInputWrite = {
id: string
text: string
tooLarge: boolean | Promise<boolean>
chunks?: Iterator<string>
nextChunk?: string
}
// Why: onAgentStatus callback added to IpcPtyTransportOptions in pty-dispatcher
// so the OSC 9999 status payloads can be forwarded to the store.
@ -472,8 +465,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
// unread marks, or notifications for unrelated worktrees just because Orca
// is reconnecting background terminals on launch.
let suppressAttentionEvents = false
let pendingInputWrites: PendingPtyInputWrite[] = []
let inputWriteDrainPromise: Promise<void> | null = null
const inputWriteQueue = createPtyInputWriteQueue({
isWritable: (id) => connected && ptyId === id,
write: (id, data) => window.api.pty.write(id, data)
})
const outputProcessor = createPtyOutputProcessor({
onTitleChange,
onBell,
@ -541,84 +536,6 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
return new Promise((resolve) => setTimeout(resolve, 0))
}
async function drainPendingInputWrites(): Promise<void> {
while (pendingInputWrites.length > 0) {
const next = pendingInputWrites[0]
if (!next) {
continue
}
if (!connected || ptyId !== next.id) {
pendingInputWrites.shift()
continue
}
if (next.tooLarge !== false) {
next.tooLarge = await Promise.resolve(next.tooLarge).catch(() => true)
if (next.tooLarge) {
pendingInputWrites.shift()
continue
}
if (!connected || ptyId !== next.id) {
pendingInputWrites.shift()
continue
}
}
next.chunks ??= iterateTerminalInputChunks(next.text)
const chunk =
next.nextChunk === undefined ? next.chunks.next() : { done: false, value: next.nextChunk }
next.nextChunk = undefined
if (chunk.done) {
pendingInputWrites.shift()
continue
}
window.api.pty.write(next.id, chunk.value)
const following = next.chunks.next()
if (following.done) {
pendingInputWrites.shift()
} else {
next.nextChunk = following.value
}
if (pendingInputWrites.length > 0) {
await yieldToInputWriteDrain()
}
}
}
function schedulePendingInputWriteDrain(): void {
if (inputWriteDrainPromise) {
return
}
inputWriteDrainPromise = drainPendingInputWrites().finally(() => {
inputWriteDrainPromise = null
if (pendingInputWrites.length > 0) {
schedulePendingInputWriteDrain()
}
})
}
function clearPendingInputWrites(): void {
pendingInputWrites = []
}
function enqueuePtyInputWrite(id: string, data: string): boolean {
try {
const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data)
if (tooLarge === true) {
return false
}
pendingInputWrites.push({ id, text: data, tooLarge })
schedulePendingInputWriteDrain()
return true
} catch {
return false
}
}
async function waitForPendingInputWrites(): Promise<void> {
while (inputWriteDrainPromise) {
await inputWriteDrainPromise
}
}
async function writeAcceptedPtyInput(id: string, data: string): Promise<boolean> {
try {
const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data)
@ -887,7 +804,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
disconnect() {
clearAccumulatedState()
clearPendingInputWrites()
inputWriteQueue.clear()
if (ptyId) {
const id = ptyId
window.api.pty.kill(id)
@ -900,7 +817,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
detach() {
clearAccumulatedState()
clearPendingInputWrites()
inputWriteQueue.clear()
if (ptyId) {
// Why: detach() is used for in-session remounts such as moving a tab
// between split groups. Stop delivering data/title events into the
@ -918,7 +835,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
if (!connected || !ptyId) {
return false
}
return enqueuePtyInputWrite(ptyId, data)
return inputWriteQueue.enqueue(ptyId, data)
},
...(connectionId
@ -929,7 +846,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
return false
}
const id = ptyId
await waitForPendingInputWrites()
await inputWriteQueue.waitForDrain()
if (!connected || ptyId !== id) {
return false
}

View File

@ -243,6 +243,66 @@ describe('terminal mouse wheel multiplier', () => {
expect(reports).toEqual([0, 0, 0, 1])
})
it('does not burst-boost rapid trackpad-like pixel deltas', () => {
const state = createTerminalTuiMouseWheelDistanceState()
const reports = [0, 16, 32, 48].map((timeStamp) =>
resolveTerminalTuiMouseWheelReportCount(
{ deltaY: 4, deltaMode: DOM_DELTA_PIXEL, timeStamp },
1,
state,
{ cellHeight: 16 }
)
)
expect(reports).toEqual([0, 0, 0, 1])
})
it('tracks a decaying trackpad-like momentum tail with linear row distance', () => {
const state = createTerminalTuiMouseWheelDistanceState()
const reports = [16, 20, 24, 28, 20, 12, 6, 3].map((deltaY, index) =>
resolveTerminalTuiMouseWheelReportCount(
{ deltaY, deltaMode: DOM_DELTA_PIXEL, timeStamp: index * 16 },
1,
state,
{ cellHeight: 16 }
)
)
expect(reports).toEqual([1, 1, 1, 2, 1, 1, 0, 1])
})
it('drops pending trackpad distance on each direction change', () => {
const state = createTerminalTuiMouseWheelDistanceState()
const reports = [16, 20, 24, -16, -20, -24, -18, -10, 6, -4].map((deltaY, index) =>
resolveTerminalTuiMouseWheelReportCount(
{ deltaY, deltaMode: DOM_DELTA_PIXEL, timeStamp: index * 16 },
1,
state,
{ cellHeight: 16 }
)
)
expect(reports).toEqual([1, 1, 1, 1, 1, 1, 1, 1, 0, 0])
})
it('emits the full linear distance for a fast trackpad-like flick event', () => {
const state = createTerminalTuiMouseWheelDistanceState()
const reports = [16 * 12, 16 * 12, 16 * 12].map((deltaY, index) =>
resolveTerminalTuiMouseWheelReportCount(
{ deltaY, deltaMode: DOM_DELTA_PIXEL, timeStamp: index * 16 },
1,
state,
{ cellHeight: 16 }
)
)
expect(reports).toEqual([12, 12, 12])
})
it('resets pending fractional distance when the user changes direction', () => {
const state = createTerminalTuiMouseWheelDistanceState()
@ -269,7 +329,7 @@ describe('terminal mouse wheel multiplier', () => {
expect(shouldMultiplyTerminalMouseWheel(wheelEvent(), terminalElement(false))).toBe(false)
})
it('leaves trackpad-like pixel scrolling one-to-one', () => {
it('handles trackpad-like TUI pixel scrolling while mouse reporting is active', () => {
expect(
shouldMultiplyTerminalMouseWheel(
wheelEvent({
@ -278,7 +338,7 @@ describe('terminal mouse wheel multiplier', () => {
}),
terminalElement()
)
).toBe(false)
).toBe(true)
})
it('multiplies notched mouse wheel ticks even when Chromium exposes a small pixel delta', () => {
@ -358,6 +418,92 @@ describe('terminal mouse wheel multiplier', () => {
expect(shouldMultiplyTerminalMouseWheel(dispatched[0]!, target)).toBe(false)
})
it('replays trackpad-like TUI pixel scrolling with responsive direction reversal', async () => {
vi.stubGlobal('WheelEvent', TestWheelEvent)
const handlers: ((event: WheelEvent) => boolean)[] = []
const target = Object.assign(new EventTarget(), {
classList: {
contains: (className: string) => className === 'enable-mouse-events'
}
}) as unknown as EventTarget & HTMLElement
const dispatched: WheelEvent[] = []
target.addEventListener('wheel', (event) => dispatched.push(event as WheelEvent))
attachTerminalMouseWheelMultiplier(
{
attachCustomWheelEventHandler: (handler) => {
handlers.push(handler)
},
element: target,
rows: 24
},
{ getTuiMouseWheelMultiplier: () => 1 }
)
const events = [
[4, 0],
[4, 16],
[4, 32],
[4, 48],
[-4, 64],
[-4, 80],
[-4, 96],
[-4, 112]
].map(([deltaY, timeStamp]) => {
const event = new TestWheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaMode: DOM_DELTA_PIXEL,
deltaY
}) as WheelEvent
Object.defineProperty(event, 'timeStamp', {
configurable: true,
value: timeStamp
})
return event
})
for (const event of events) {
expect(handlers[0]?.(event)).toBe(false)
await Promise.resolve()
}
expect(dispatched.map((entry) => entry.deltaY)).toEqual([1, -1])
})
it('replays a fast trackpad-like flick as one full synthetic report batch', async () => {
vi.stubGlobal('WheelEvent', TestWheelEvent)
const handlers: ((event: WheelEvent) => boolean)[] = []
const target = Object.assign(new EventTarget(), {
classList: {
contains: (className: string) => className === 'enable-mouse-events'
}
}) as unknown as EventTarget & HTMLElement
const dispatched: WheelEvent[] = []
target.addEventListener('wheel', (event) => dispatched.push(event as WheelEvent))
attachTerminalMouseWheelMultiplier(
{
attachCustomWheelEventHandler: (handler) => {
handlers.push(handler)
},
element: target,
rows: 24
},
{ getTuiMouseWheelMultiplier: () => 1 }
)
const event = new TestWheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaMode: DOM_DELTA_PIXEL,
deltaY: 16 * 12
}) as WheelEvent
expect(handlers[0]?.(event)).toBe(false)
await Promise.resolve()
expect(dispatched.map((entry) => entry.deltaY)).toEqual(Array(12).fill(1))
})
it('drains resolved TUI wheel reports without a frame-rate cap', async () => {
vi.stubGlobal('WheelEvent', TestWheelEvent)
const handlers: ((event: WheelEvent) => boolean)[] = []

View File

@ -1,7 +1,6 @@
import type { Terminal } from '@xterm/xterm'
import {
createTerminalTuiMouseWheelDistanceState,
isDiscreteTerminalTuiWheelEvent,
normalizeTerminalTuiMouseWheelMultiplier,
resolveTerminalTuiMouseWheelReportCount,
resolveTerminalWheelDirection
@ -110,8 +109,7 @@ export function shouldMultiplyTerminalMouseWheel(
isReplayedWheelEvent(event) ||
!terminalElement?.classList.contains(XTERM_MOUSE_REPORTING_CLASS) ||
event.deltaY === 0 ||
event.shiftKey ||
!isDiscreteTerminalTuiWheelEvent(event)
event.shiftKey
) {
return false
}

View File

@ -67,6 +67,11 @@ function legacyVerticalWheelDelta(event: TerminalTuiWheelEventInput): number | n
return null
}
function hasDiscreteLegacyWheelDelta(event: TerminalTuiWheelEventInput): boolean {
const legacyDelta = legacyVerticalWheelDelta(event)
return legacyDelta !== null && Math.abs(legacyDelta) >= LEGACY_MOUSE_WHEEL_DELTA_MIN
}
export function isDiscreteTerminalTuiWheelEvent(event: TerminalTuiWheelEventInput): boolean {
if ((event.deltaMode ?? DOM_DELTA_PIXEL) !== DOM_DELTA_PIXEL) {
return true
@ -76,8 +81,21 @@ export function isDiscreteTerminalTuiWheelEvent(event: TerminalTuiWheelEventInpu
return true
}
const legacyDelta = legacyVerticalWheelDelta(event)
return legacyDelta !== null && Math.abs(legacyDelta) >= LEGACY_MOUSE_WHEEL_DELTA_MIN
return hasDiscreteLegacyWheelDelta(event)
}
function canBurstBoostWheelEvent(event: TerminalTuiWheelEventInput): boolean {
if ((event.deltaMode ?? DOM_DELTA_PIXEL) !== DOM_DELTA_PIXEL) {
return true
}
return hasDiscreteLegacyWheelDelta(event)
}
function isTrackpadLikePixelWheelEvent(event: TerminalTuiWheelEventInput): boolean {
return (
(event.deltaMode ?? DOM_DELTA_PIXEL) === DOM_DELTA_PIXEL && !hasDiscreteLegacyWheelDelta(event)
)
}
function wheelInputTime(event: TerminalTuiWheelEventInput): number | null {
@ -130,6 +148,13 @@ function resolveBurstWheelDistanceRows(
state: TerminalTuiMouseWheelDistanceState,
distanceRows: number
): number {
if (!canBurstBoostWheelEvent(event)) {
state.fastStreak = 0
state.lastDistanceRows = null
state.lastInputAt = null
return 0
}
const currentInputAt = wheelInputTime(event)
if (currentInputAt === null) {
state.fastStreak = 0
@ -165,6 +190,26 @@ function resolveBurstWheelDistanceRows(
return TUI_WHEEL_BURST_MAX_BONUS_ROWS * cadence * (state.fastStreak / TUI_WHEEL_BURST_RAMP_EVENTS)
}
function resolveTrackpadPixelWheelReportCount(
event: TerminalTuiWheelEventInput,
state: TerminalTuiMouseWheelDistanceState,
distanceRows: number
): number | null {
if (!isTrackpadLikePixelWheelEvent(event)) {
return null
}
// Why: trackpad pixel streams map 1:1 to physical distance: one report per
// terminal row scrolled, fractional remainder carried. No per-event cap and
// no momentum-tail suppression: the input write queue batches whatever a
// busy frame accumulates into a single PTY write, so the TUI applies it at
// once instead of replaying it, and inertial scrolling stays real-time.
const totalRows = state.pendingRows + distanceRows
const reports = Math.trunc(totalRows)
state.pendingRows = totalRows - reports
return reports
}
export function normalizeTerminalTuiMouseWheelMultiplier(value: number | undefined): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER
@ -193,6 +238,11 @@ export function resolveTerminalTuiMouseWheelReportCount(
state.pendingDirection = direction
const distanceRows = resolveWheelDistanceRows(event, metrics)
const trackpadReportCount = resolveTrackpadPixelWheelReportCount(event, state, distanceRows)
if (trackpadReportCount !== null) {
return trackpadReportCount
}
const rows =
Math.min(
TUI_WHEEL_BURST_MAX_DISTANCE_ROWS_PER_EVENT,

View File

@ -1,6 +1,20 @@
const fs = require('node:fs')
const ESC = '\x1b'
const MOUSE_REPORT_PATTERN = new RegExp(`${ESC}\\[<(64|65);\\d+;\\d+M`, 'g')
// Optional flags:
// --log <path> append "<Date.now()> <reportsInChunk>" per consumed stdin
// chunk so tests can measure when reports reached the PTY.
// --heavy emit Claude-Code-scale redraw frames (~4KB of styled cells
// per render, ~430KB/s at 120 reports/s — matches output
// volume measured from a real Claude Code session scrolling
// at 120 reports/s) instead of minimal rows.
const args = process.argv.slice(2)
const logIndex = args.indexOf('--log')
const LOG_PATH = logIndex >= 0 ? args[logIndex + 1] : null
const HEAVY_FRAMES = args.includes('--heavy')
let offset = 0
let pending = ''
@ -12,12 +26,28 @@ function visibleRows() {
return Math.max(3, process.stdout.rows || 24)
}
function visibleCols() {
return Math.max(20, process.stdout.columns || 80)
}
function heavyRowFiller(row, cols) {
// ~19 bytes per 8 visible columns of styled filler.
const unit = `${ESC}[38;5;${((row * 17) % 200) + 16}m········`
const units = Math.max(0, Math.floor((cols - 24) / 8))
return `${unit.repeat(units)}${ESC}[0m`
}
function render() {
write(`${ESC}[H`)
write(`TUI_SCROLL_READY offset=${offset}${ESC}[K`)
for (let row = 1; row < visibleRows(); row += 1) {
write(`\r\nTUI_SCROLL_ROW_${String(offset + row - 1).padStart(4, '0')}${ESC}[K`)
const rows = visibleRows()
const cols = visibleCols()
let frame = `${ESC}[?2026h${ESC}[H`
frame += `TUI_SCROLL_READY offset=${offset}${ESC}[K`
for (let row = 1; row < rows; row += 1) {
const label = `TUI_SCROLL_ROW_${String(offset + row - 1).padStart(4, '0')}`
frame += `\r\n${label}${HEAVY_FRAMES ? ` ${heavyRowFiller(row, cols)}` : ''}${ESC}[K`
}
frame += `${ESC}[?2026l`
write(frame)
}
function cleanup() {
@ -42,14 +72,19 @@ process.stdin.on('data', (chunk) => {
pending += chunk
let match
let lastIndex = 0
let reportsInChunk = 0
MOUSE_REPORT_PATTERN.lastIndex = 0
while ((match = MOUSE_REPORT_PATTERN.exec(pending)) !== null) {
offset = Math.max(0, offset + (match[1] === '65' ? 1 : -1))
reportsInChunk += 1
lastIndex = MOUSE_REPORT_PATTERN.lastIndex
}
if (lastIndex > 0) {
if (LOG_PATH) {
fs.appendFileSync(LOG_PATH, `${Date.now()} ${reportsInChunk}\n`)
}
pending = pending.slice(lastIndex)
render()
return

View File

@ -0,0 +1,195 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
execInTerminal,
waitForActivePanePtyId,
waitForActiveTerminalManager
} from './helpers/terminal'
const VISIBLE_TUI_FIXTURE_PATH = path.join(
process.cwd(),
'tests/e2e/fixtures/visible-tui-scroll-fixture.cjs'
)
// Reports must stop reaching the PTY shortly after the gesture ends. Before
// the input-write-queue coalescing fix, each SGR report drained in its own
// >=4ms-clamped macrotask turn; a dense trackpad stream delivered through the
// real input pipeline (which outprioritizes timers) plus TUI-scale redraw
// output starved that drain, and the backlog replayed for seconds after the
// fingers left the trackpad.
const MAX_ARRIVAL_LAG_MS = 900
const WHEEL_EVENTS = 240
type WheelStreamResult = {
dispatchedEvents: number
inputEndWallClockMs: number
}
async function startHeavyTuiFixture(page: Page, logPath: string): Promise<void> {
await waitForSessionReady(page)
await waitForActiveWorktree(page)
await ensureTerminalVisible(page)
await waitForActiveTerminalManager(page, 30_000)
await page.evaluate(() =>
window.__store?.getState().updateSettings({ terminalTuiScrollSensitivity: 1 })
)
const ptyId = await waitForActivePanePtyId(page)
await execInTerminal(
page,
ptyId,
`node ${JSON.stringify(VISIBLE_TUI_FIXTURE_PATH)} --heavy --log ${JSON.stringify(logPath)}`
)
await expect
.poll(
() =>
page.evaluate(() => {
const state = window.__store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
return pane?.terminal.element?.classList.contains('enable-mouse-events') ?? false
}),
{ timeout: 15_000, message: 'fixture did not enable mouse reporting' }
)
.toBe(true)
}
async function terminalWheelTarget(
page: Page
): Promise<{ x: number; y: number; cellHeight: number }> {
return page.evaluate(() => {
const state = window.__store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
const screen = pane?.terminal.element?.querySelector<HTMLElement>('.xterm-screen')
if (!pane?.terminal || !screen) {
throw new Error('Active terminal screen unavailable')
}
const rect = screen.getBoundingClientRect()
return {
x: rect.left + rect.width / 2,
y: rect.top + Math.min(rect.height - 1, 40),
cellHeight:
pane.terminal._core?._renderService?.dimensions?.css?.cell?.height ??
rect.height / pane.terminal.rows
}
})
}
/**
* Drives real wheel input (CDP mouse events through the compositor input
* pipeline the same priority class as physical trackpad input) rather than
* synthetic DOM dispatchEvent, so input can genuinely compete with the
* renderer's timer-based PTY input drain the way a physical gesture does.
*/
async function dispatchTrackpadWheelStream(
page: Page,
options: { alternate: boolean; events: number; deltaY: number }
): Promise<WheelStreamResult> {
const target = await terminalWheelTarget(page)
await page.mouse.move(target.x, target.y)
for (let i = 0; i < options.events; i += 1) {
const direction = options.alternate && Math.floor(i / 18) % 2 === 1 ? -1 : 1
// No artificial sleep: CDP round-trips pace this near real trackpad rates
// while keeping the renderer's input queue continuously occupied.
await page.mouse.wheel(0, direction * options.deltaY)
}
const inputEndWallClockMs = await page.evaluate(() => Date.now())
return { dispatchedEvents: options.events, inputEndWallClockMs }
}
type ReportArrival = { atMs: number; reports: number }
function readReportArrivalLog(logPath: string): ReportArrival[] {
if (!fs.existsSync(logPath)) {
return []
}
return fs
.readFileSync(logPath, 'utf8')
.split('\n')
.filter(Boolean)
.map((line) => {
const [atMs, reports] = line.split(' ')
return { atMs: Number(atMs), reports: Number(reports) }
})
}
function summarizeArrivals(
arrivals: ReportArrival[],
input: WheelStreamResult
): { arrivalLagMs: number; chunks: number; totalReports: number } {
const totalReports = arrivals.reduce((sum, entry) => sum + entry.reports, 0)
const lastArrivalMs = arrivals.at(-1)?.atMs ?? 0
return {
arrivalLagMs: lastArrivalMs - input.inputEndWallClockMs,
chunks: arrivals.length,
totalReports
}
}
test.describe('terminal TUI wheel report drain', () => {
test('dense trackpad-like wheel stream reaches the PTY while the gesture happens', async ({
orcaPage
}) => {
const logPath = path.join(os.tmpdir(), `tui-wheel-drain-${Date.now()}.log`)
await startHeavyTuiFixture(orcaPage, logPath)
const target = await terminalWheelTarget(orcaPage)
const input = await dispatchTrackpadWheelStream(orcaPage, {
alternate: false,
events: WHEEL_EVENTS,
deltaY: Math.min(49, target.cellHeight)
})
// Give a laggy drain ample time to expose itself before reading the log.
await orcaPage.waitForTimeout(8000)
const summary = summarizeArrivals(readReportArrivalLog(logPath), input)
fs.rmSync(logPath, { force: true })
console.log(`[tui-wheel-drain] dense: ${JSON.stringify(summary)}`)
// The full gesture distance must reach the TUI (no dead/eaten scrolls)...
expect(summary.totalReports, JSON.stringify(summary)).toBeGreaterThanOrEqual(WHEEL_EVENTS - 10)
// ...while the gesture happens, not replayed 1-by-1 afterwards.
expect(summary.arrivalLagMs, JSON.stringify(summary)).toBeLessThanOrEqual(MAX_ARRIVAL_LAG_MS)
})
test('aggressive alternating trackpad-like gesture does not replay after input ends', async ({
orcaPage
}) => {
const logPath = path.join(os.tmpdir(), `tui-wheel-drain-alt-${Date.now()}.log`)
await startHeavyTuiFixture(orcaPage, logPath)
const target = await terminalWheelTarget(orcaPage)
const input = await dispatchTrackpadWheelStream(orcaPage, {
alternate: true,
events: WHEEL_EVENTS,
deltaY: Math.min(49, target.cellHeight)
})
await orcaPage.waitForTimeout(8000)
const summary = summarizeArrivals(readReportArrivalLog(logPath), input)
fs.rmSync(logPath, { force: true })
console.log(`[tui-wheel-drain] alternate: ${JSON.stringify(summary)}`)
expect(summary.arrivalLagMs, JSON.stringify(summary)).toBeLessThanOrEqual(MAX_ARRIVAL_LAG_MS)
})
})