diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index cf0f1dd7edb..40d7fac6bfa 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -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) { 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 | null>(null) - const repeatIntervalRef = useRef | null>(null) + const accessoryRepeatRef = useRef( + createTerminalAccessoryRepeatController>() + ) // 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) => { - 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={() => { diff --git a/mobile/src/terminal/terminal-accessory-repeat.test.ts b/mobile/src/terminal/terminal-accessory-repeat.test.ts new file mode 100644 index 00000000000..fa132823b3e --- /dev/null +++ b/mobile/src/terminal/terminal-accessory-repeat.test.ts @@ -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((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() + + 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() + + 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() + + 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() + + repeat.start('down', send) + await vi.runAllTimersAsync() + + expect(send).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/terminal/terminal-accessory-repeat.ts b/mobile/src/terminal/terminal-accessory-repeat.ts new file mode 100644 index 00000000000..8b4a4d41d61 --- /dev/null +++ b/mobile/src/terminal/terminal-accessory-repeat.ts @@ -0,0 +1,56 @@ +export const TERMINAL_ACCESSORY_REPEAT_DELAY_MS = 400 +export const TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS = 45 + +type TerminalAccessoryRepeatSender = (input: TInput) => Promise + +export function createTerminalAccessoryRepeatController() { + let generation = 0 + let timer: ReturnType | null = null + + const stop = () => { + generation += 1 + if (timer !== null) { + clearTimeout(timer) + timer = null + } + } + + const start = (input: TInput, send: TerminalAccessoryRepeatSender) => { + 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 } +} diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts b/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts index 7a1110b407b..4a9f03edc32 100644 --- a/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts +++ b/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts @@ -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) }) }) diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send.ts b/mobile/src/terminal/terminal-live-accessory-raw-send.ts index 792491f6c63..4e6efc08abd 100644 --- a/mobile/src/terminal/terminal-live-accessory-raw-send.ts +++ b/mobile/src/terminal/terminal-live-accessory-raw-send.ts @@ -15,7 +15,7 @@ type TerminalLiveAccessoryRawSendArgs = { export async function sendTerminalLiveAccessoryRawBytes( args: TerminalLiveAccessoryRawSendArgs -): Promise { +): Promise { // 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 ) }