Coalesce PTY input write bursts (#7205)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
2848c5daf4
commit
10e8f86899
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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 = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)[] = []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue