fix(mobile): backpressure accessory key repeats

This commit is contained in:
Brennan Benson
2026-08-12 22:42:43 -07:00
parent 9a10561258
commit 89299be5e4
5 changed files with 165 additions and 34 deletions
+12 -22
View File
@@ -116,6 +116,7 @@ import {
} from '../../../../src/terminal/terminal-accessory-layout'
import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input'
import { sendTerminalLiveAccessoryRawBytes } from '../../../../src/terminal/terminal-live-accessory-raw-send'
import { createTerminalAccessoryRepeatController } from '../../../../src/terminal/terminal-accessory-repeat'
import {
clearTerminalLiveInputFocusTimer,
isTerminalLiveInputWithinByteLimit,
@@ -2947,14 +2948,14 @@ export default function SessionScreen() {
async function handleAccessoryKey(input: ReturnType<typeof createTerminalLiveAccessoryInput>) {
if (!client || !activeHandle || !canSend) {
return
return false
}
const targetHandle = activeHandle
const accessoryCommit = await handleLiveInputAccessoryBytes(input)
if (accessoryCommit.kind !== 'allow-raw') {
return
return accessoryCommit.kind === 'handled'
}
await sendTerminalLiveAccessoryRawBytes({
return sendTerminalLiveAccessoryRawBytes({
client: clientRef.current,
targetHandle,
activeHandle: activeHandleRef.current,
@@ -3347,32 +3348,22 @@ export default function SessionScreen() {
}
}
// Why: hold-to-repeat matches iOS cadence (400ms then 45ms); non-repeatable keys fire once (holding is destructive).
const repeatTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const repeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const accessoryRepeatRef = useRef(
createTerminalAccessoryRepeatController<ReturnType<typeof createTerminalLiveAccessoryInput>>()
)
// Why: ref keeps repeat firing the current callback; else a mid-hold tab switch/reconnect routes bytes to a stale terminal.
const handleAccessoryKeyRef = useRef(handleAccessoryKey)
handleAccessoryKeyRef.current = handleAccessoryKey
const stopAccessoryRepeat = useCallback(() => {
if (repeatTimeoutRef.current) {
clearTimeout(repeatTimeoutRef.current)
repeatTimeoutRef.current = null
}
if (repeatIntervalRef.current) {
clearInterval(repeatIntervalRef.current)
repeatIntervalRef.current = null
}
accessoryRepeatRef.current.stop()
}, [])
const startAccessoryRepeat = useCallback(
(input: ReturnType<typeof createTerminalLiveAccessoryInput>) => {
stopAccessoryRepeat()
repeatTimeoutRef.current = setTimeout(() => {
repeatIntervalRef.current = setInterval(() => {
void handleAccessoryKeyRef.current(input)
}, 45)
}, 400)
accessoryRepeatRef.current.start(input, (nextInput) =>
handleAccessoryKeyRef.current(nextInput)
)
},
[stopAccessoryRepeat]
[]
)
const setMobileSessionRootRef = useCallback(
(node: View | null): void => {
@@ -4773,7 +4764,6 @@ export default function SessionScreen() {
return
}
const input = createTerminalLiveAccessoryInput(key)
void handleAccessoryKey(input)
startAccessoryRepeat(input)
}}
onPressOut={() => {
@@ -0,0 +1,83 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createTerminalAccessoryRepeatController,
TERMINAL_ACCESSORY_REPEAT_DELAY_MS,
TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS
} from './terminal-accessory-repeat'
function deferred() {
let resolve: () => void = () => {}
const promise = new Promise<void>((done) => {
resolve = done
})
return { promise, resolve }
}
describe('terminal accessory repeat', () => {
afterEach(() => vi.useRealTimers())
it('keeps one send in flight and preserves repeat order under host latency', async () => {
vi.useFakeTimers()
const first = deferred()
const second = deferred()
const sent: string[] = []
const send = vi.fn((input: string) => {
sent.push(input)
return (sent.length === 1 ? first.promise : second.promise).then(() => true)
})
const repeat = createTerminalAccessoryRepeatController<string>()
repeat.start('down', send)
expect(sent).toEqual(['down'])
await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_DELAY_MS + 500)
expect(sent).toEqual(['down'])
first.resolve()
await vi.advanceTimersByTimeAsync(0)
expect(sent).toEqual(['down', 'down'])
await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS + 500)
expect(sent).toEqual(['down', 'down'])
second.resolve()
await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS)
expect(sent).toEqual(['down', 'down', 'down'])
})
it('does not schedule another repeat after release while a send is pending', async () => {
vi.useFakeTimers()
const pending = deferred()
const send = vi.fn(() => pending.promise.then(() => true))
const repeat = createTerminalAccessoryRepeatController<string>()
repeat.start('down', send)
repeat.stop()
pending.resolve()
await vi.runAllTimersAsync()
expect(send).toHaveBeenCalledTimes(1)
})
it('stops repeating when the transport rejects a send', async () => {
vi.useFakeTimers()
const send = vi.fn(() => Promise.reject(new Error('disconnected')))
const repeat = createTerminalAccessoryRepeatController<string>()
repeat.start('down', send)
await vi.runAllTimersAsync()
expect(send).toHaveBeenCalledTimes(1)
})
it('stops repeating when the send is dropped before reaching the transport', async () => {
vi.useFakeTimers()
const send = vi.fn(async () => false)
const repeat = createTerminalAccessoryRepeatController<string>()
repeat.start('down', send)
await vi.runAllTimersAsync()
expect(send).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,56 @@
export const TERMINAL_ACCESSORY_REPEAT_DELAY_MS = 400
export const TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS = 45
type TerminalAccessoryRepeatSender<TInput> = (input: TInput) => Promise<boolean>
export function createTerminalAccessoryRepeatController<TInput>() {
let generation = 0
let timer: ReturnType<typeof setTimeout> | null = null
const stop = () => {
generation += 1
if (timer !== null) {
clearTimeout(timer)
timer = null
}
}
const start = (input: TInput, send: TerminalAccessoryRepeatSender<TInput>) => {
stop()
const activeGeneration = generation
const pressedAt = Date.now()
const schedule = (delayMs: number) => {
timer = setTimeout(() => {
timer = null
if (generation !== activeGeneration) {
return
}
void send(input).then(
(sent) => {
if (sent && generation === activeGeneration) {
schedule(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS)
}
},
() => undefined
)
}, delayMs)
}
void send(input).then(
(sent) => {
if (sent && generation === activeGeneration) {
schedule(
Math.min(
TERMINAL_ACCESSORY_REPEAT_DELAY_MS,
Math.max(0, TERMINAL_ACCESSORY_REPEAT_DELAY_MS - (Date.now() - pressedAt))
)
)
}
},
() => undefined
)
}
return { start, stop }
}
@@ -20,7 +20,7 @@ describe('terminal live accessory raw send', () => {
it('sends raw bytes now-or-never with the device presence tag', async () => {
const { client, sendRequest } = captureClient()
await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })
await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(true)
expect(sendRequest).toHaveBeenCalledWith(
'terminal.send',
@@ -33,7 +33,9 @@ describe('terminal live accessory raw send', () => {
it('drops the bytes instead of sending while disconnected', async () => {
const { client, sendRequest } = captureClient()
await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, connState: 'reconnecting' })
await expect(
sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, connState: 'reconnecting' })
).resolves.toBe(false)
expect(sendRequest).not.toHaveBeenCalled()
})
@@ -41,16 +43,16 @@ describe('terminal live accessory raw send', () => {
it('drops the bytes when the terminal selection went stale mid-flush', async () => {
const { client, sendRequest } = captureClient()
await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, activeHandle: 'terminal-b' })
await expect(
sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, activeHandle: 'terminal-b' })
).resolves.toBe(false)
expect(sendRequest).not.toHaveBeenCalled()
})
it('swallows a rejected send so accessory taps never surface transport errors', async () => {
it('reports a rejected send without surfacing transport errors', async () => {
const { client } = captureClient(Promise.reject(new Error('Not connected: terminal.send')))
await expect(
sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })
).resolves.toBeUndefined()
await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(false)
})
})
@@ -15,7 +15,7 @@ type TerminalLiveAccessoryRawSendArgs = {
export async function sendTerminalLiveAccessoryRawBytes(
args: TerminalLiveAccessoryRawSendArgs
): Promise<void> {
): Promise<boolean> {
// Why: async IME flushing can outlive the original terminal selection.
const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({
targetHandle: args.targetHandle,
@@ -23,9 +23,9 @@ export async function sendTerminalLiveAccessoryRawBytes(
activeSessionTabType: args.activeSessionTabType
})
if (!args.client || !rawSendTarget || args.connState !== 'connected') {
return
return false
}
await args.client
return args.client
.sendRequest(
'terminal.send',
buildTerminalSendParams({
@@ -37,7 +37,7 @@ export async function sendTerminalLiveAccessoryRawBytes(
TERMINAL_INPUT_SEND_OPTIONS
)
.then(
() => undefined,
() => undefined
() => true,
() => false
)
}