From 24510371762aced170cc931e6c3ffd8fe17008f7 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:38:23 +0000 Subject: [PATCH] fix(mobile): send terminal controls in key order with live typing (#33724) --- ...erminal-field-submit-binding-send.test.tsx | 40 ++++++++- .../terminal-live-control-send-order.test.ts | 71 --------------- .../terminal-live-control-send-order.ts | 12 --- .../terminal-live-pending-flush-state.test.ts | 83 ++++++----------- .../terminal-live-pending-flush-state.ts | 47 +++++++--- ...rminal-live-accessory-input-commit.test.ts | 90 +------------------ ...se-terminal-live-accessory-input-commit.ts | 29 +++--- .../use-terminal-live-input-commit.test.ts | 19 ++++ .../use-terminal-live-input-commit.ts | 40 +++------ .../use-terminal-live-pending-input-flush.ts | 37 ++++++-- 10 files changed, 179 insertions(+), 289 deletions(-) delete mode 100644 mobile/src/terminal/terminal-live-control-send-order.test.ts delete mode 100644 mobile/src/terminal/terminal-live-control-send-order.ts diff --git a/mobile/src/session/terminal-field-submit-binding-send.test.tsx b/mobile/src/session/terminal-field-submit-binding-send.test.tsx index e233f0af4ae..4b1427283ad 100644 --- a/mobile/src/session/terminal-field-submit-binding-send.test.tsx +++ b/mobile/src/session/terminal-field-submit-binding-send.test.tsx @@ -202,6 +202,12 @@ function pressControl(): KeyboardEvent { return event } +function pressEnter(): void { + liveField.dispatchEvent( + new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'insertLineBreak' }) + ) +} + function holdAcknowledgement(): () => void { let release = (): void => {} const pending = new Promise((resolve) => { @@ -235,9 +241,35 @@ describe('keyboard controls through the live pending-text queue', () => { expect(pressControl().defaultPrevented).toBe(false) }) - it.each(['terminal switch', 'disconnect and reconnect'] as const)( - 'drops a queued control after %s even when the original terminal returns', - async (change) => { + it.each([ + { key: 'Ctrl-C', press: pressControl, fieldAfter: 'hellox', expected: 'hello\x03x' }, + { key: 'Enter', press: pressEnter, fieldAfter: 'x', expected: 'hello\rx' } + ])('keeps text typed right after $key behind it', async ({ press, fieldAfter, expected }) => { + mocks.sent.length = 0 + const release = holdAcknowledgement() + let renderer: ReactTestRenderer | null = null + await act(async () => { + renderer = create(createElement(LiveHarness, { session: READY })) + }) + await act(async () => { + changeLiveText('hello') + press() + changeLiveText(fieldAfter) + }) + await act(async () => { + release() + }) + expect(mocks.sent.map((send) => send.text).join('')).toBe(expected) + act(() => renderer?.unmount()) + }) + + it.each([ + { key: 'Ctrl-C', press: pressControl, change: 'terminal switch' }, + { key: 'Ctrl-C', press: pressControl, change: 'disconnect and reconnect' }, + { key: 'Enter', press: pressEnter, change: 'terminal switch' } + ] as const)( + 'drops a queued $key after $change even when the original terminal returns', + async ({ press, change }) => { mocks.sent.length = 0 const release = holdAcknowledgement() let renderer: ReactTestRenderer | null = null @@ -246,7 +278,7 @@ describe('keyboard controls through the live pending-text queue', () => { }) await act(async () => { changeLiveText('hello') - pressControl() + press() }) const changed = change === 'terminal switch' ? { ...READY, activeHandle: 'terminal-b' } : OFFLINE diff --git a/mobile/src/terminal/terminal-live-control-send-order.test.ts b/mobile/src/terminal/terminal-live-control-send-order.test.ts deleted file mode 100644 index 7d7b7dca625..00000000000 --- a/mobile/src/terminal/terminal-live-control-send-order.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' - -describe('terminal live control send order', () => { - it('Given a failed pending text flush When control bytes follow Then skips the control bytes', async () => { - const events: string[] = [] - const flushPendingText = vi.fn(async () => { - events.push('flush') - return false - }) - const sendControlBytes = vi.fn(async () => { - events.push('control') - return true - }) - - const result = await sendTerminalLiveControlAfterPendingFlush( - flushPendingText, - sendControlBytes - ) - - expect(result).toBe(false) - expect(sendControlBytes).not.toHaveBeenCalled() - expect(events).toEqual(['flush']) - }) - - it('Given a successful pending text flush When control bytes follow Then sends them afterward', async () => { - vi.useFakeTimers() - try { - const events: string[] = [] - const pendingFlush = Promise.withResolvers() - const flushPendingText = vi.fn(() => { - events.push('flush') - return pendingFlush.promise - }) - const sendControlBytes = vi.fn(async () => { - events.push('control') - return true - }) - - const result = sendTerminalLiveControlAfterPendingFlush(flushPendingText, sendControlBytes) - await vi.advanceTimersByTimeAsync(0) - expect(events).toEqual(['flush']) - expect(sendControlBytes).not.toHaveBeenCalled() - pendingFlush.resolve(true) - await expect(result).resolves.toBe(true) - expect(events).toEqual(['flush', 'control']) - } finally { - vi.useRealTimers() - } - }) - - it('Given a failed control byte send When pending text flushed Then reports failure', async () => { - const events: string[] = [] - const flushPendingText = vi.fn(async () => { - events.push('flush') - return true - }) - const sendControlBytes = vi.fn(async () => { - events.push('control') - return false - }) - - const result = await sendTerminalLiveControlAfterPendingFlush( - flushPendingText, - sendControlBytes - ) - - expect(result).toBe(false) - expect(events).toEqual(['flush', 'control']) - }) -}) diff --git a/mobile/src/terminal/terminal-live-control-send-order.ts b/mobile/src/terminal/terminal-live-control-send-order.ts deleted file mode 100644 index 185b49ee48e..00000000000 --- a/mobile/src/terminal/terminal-live-control-send-order.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type TerminalLiveAsyncSendStep = () => Promise - -export async function sendTerminalLiveControlAfterPendingFlush( - flushPendingText: TerminalLiveAsyncSendStep, - sendControlBytes: TerminalLiveAsyncSendStep -): Promise { - const flushed = await flushPendingText() - if (!flushed) { - return false - } - return sendControlBytes() -} diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts index 655a78253fc..207fc339d17 100644 --- a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts +++ b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' import { isTerminalLiveInputWithinByteLimit, TERMINAL_LIVE_INPUT_MAX_BYTES @@ -7,6 +6,7 @@ import { import { cancelTerminalLivePendingFlush, createTerminalLivePendingFlushState, + queueTerminalLiveControlSend, queueTerminalLiveMirrorSend, waitForTerminalLivePendingFlush } from './terminal-live-pending-flush-state' @@ -17,52 +17,6 @@ describe('terminal live pending flush state', () => { await expect(waitForTerminalLivePendingFlush(state)).resolves.toBe(true) }) - - it('Given an in-flight flush When control input waits Then control is held until flush succeeds', async () => { - const events: string[] = [] - let resolveFlush: (value: boolean) => void = () => {} - const flushPromise = new Promise((resolve) => { - resolveFlush = resolve - }) - const state = createTerminalLivePendingFlushState() - state.current = flushPromise - - const controlSend = sendTerminalLiveControlAfterPendingFlush( - () => waitForTerminalLivePendingFlush(state), - async () => { - events.push('control') - return true - } - ) - await Promise.resolve() - - expect(events).toEqual([]) - resolveFlush(true) - await expect(controlSend).resolves.toBe(true) - expect(events).toEqual(['control']) - }) - - it('Given an in-flight flush fails When control input waits Then control is skipped', async () => { - const events: string[] = [] - let resolveFlush: (value: boolean) => void = () => {} - const flushPromise = new Promise((resolve) => { - resolveFlush = resolve - }) - const state = createTerminalLivePendingFlushState() - state.current = flushPromise - - const controlSend = sendTerminalLiveControlAfterPendingFlush( - () => waitForTerminalLivePendingFlush(state), - async () => { - events.push('control') - return true - } - ) - resolveFlush(false) - - await expect(controlSend).resolves.toBe(false) - expect(events).toEqual([]) - }) }) describe('terminal live mirror send queue', () => { @@ -185,6 +139,31 @@ describe('terminal live mirror send queue', () => { await expect(second).resolves.toBe(true) }) + it('batches repeated controls behind an in-flight send, apart from the text before them', async () => { + const state = createTerminalLivePendingFlushState() + const writes: string[] = [] + let release = (): void => {} + const sender = (_handle: string, payload: string): Promise => { + writes.push(payload) + return writes.length === 1 + ? new Promise((resolve) => { + release = () => resolve(true) + }) + : Promise.resolve(true) + } + const text = queueTerminalLiveMirrorSend(state, 'terminal-1', 'ls', sender) + void queueTerminalLiveMirrorSend(state, 'terminal-1', ' -la', sender) + const controls = ['\x1b[A', '\x1b[A', '\r'].map((bytes) => + queueTerminalLiveControlSend(state, 'terminal-1', bytes, sender) + ) + + release() + + await expect(text).resolves.toBe(true) + await expect(Promise.all(controls)).resolves.toEqual([true, true, true]) + expect(writes).toEqual(['ls', ' -la', '\x1b[A\x1b[A\r']) + }) + it('Given a settled mirror send When it was the newest Then the state resets to null', async () => { const state = createTerminalLivePendingFlushState() @@ -207,15 +186,8 @@ describe('terminal live mirror send queue', () => { releaseRetired = resolve }) ) - const controlBytes: string[] = [] let controlResult: boolean | null = null - const control = sendTerminalLiveControlAfterPendingFlush( - () => waitForTerminalLivePendingFlush(state), - async () => { - controlBytes.push('enter') - return true - } - ).then((sent) => { + const control = waitForTerminalLivePendingFlush(state).then((sent) => { controlResult = sent }) @@ -232,7 +204,6 @@ describe('terminal live mirror send queue', () => { const freshBarrier = waitForTerminalLivePendingFlush(state) try { await vi.waitFor(() => expect(controlResult).toBe(false)) - expect(controlBytes).toEqual([]) await expect(retired).resolves.toBe(false) releaseRetired(true) await Promise.resolve() diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.ts b/mobile/src/terminal/terminal-live-pending-flush-state.ts index 890acfc1c68..9975502c115 100644 --- a/mobile/src/terminal/terminal-live-pending-flush-state.ts +++ b/mobile/src/terminal/terminal-live-pending-flush-state.ts @@ -10,6 +10,7 @@ type TerminalLivePendingBatch = { byteLength: number readonly requests: TerminalLivePendingRequest[] readonly sender: TerminalLiveMirrorSender + readonly control: boolean } export type TerminalLivePendingFlushState = { @@ -63,15 +64,19 @@ async function drainTerminalLiveMirrorSends( } state.activeRequests = batch.requests - let delivery: Promise - try { - delivery = batch.sender(batch.handle, batch.payload) - } catch { - delivery = Promise.resolve(false) - } - const sent = await delivery.catch(() => false) - if (state.generation !== generation) { - return false + let sent = false + // A control after undelivered text could act on the wrong line, e.g. Return running a partial command. + if (allSent || !batch.control) { + let delivery: Promise + try { + delivery = batch.sender(batch.handle, batch.payload) + } catch { + delivery = Promise.resolve(false) + } + sent = await delivery.catch(() => false) + if (state.generation !== generation) { + return false + } } state.activeRequests = [] @@ -87,6 +92,25 @@ export function queueTerminalLiveMirrorSend( handle: string, payload: string, sender: TerminalLiveMirrorSender +): Promise { + return queueTerminalLiveSend(state, handle, payload, sender, false) +} + +export function queueTerminalLiveControlSend( + state: TerminalLivePendingFlushState, + handle: string, + bytes: string, + sender: TerminalLiveMirrorSender +): Promise { + return queueTerminalLiveSend(state, handle, bytes, sender, true) +} + +function queueTerminalLiveSend( + state: TerminalLivePendingFlushState, + handle: string, + payload: string, + sender: TerminalLiveMirrorSender, + control: boolean ): Promise { let resolveRequest: (sent: boolean) => void = () => {} const request = new Promise((resolve) => { @@ -94,9 +118,11 @@ export function queueTerminalLiveMirrorSend( }) const byteLength = getTerminalInputByteLength(payload) const pendingTail = state.pendingBatches.at(-1) + // A control never joins a text batch, so it can be skipped when that text fails to arrive. if ( pendingTail?.handle === handle && pendingTail.sender === sender && + (!control || pendingTail.control) && pendingTail.byteLength + byteLength <= TERMINAL_LIVE_INPUT_MAX_BYTES ) { pendingTail.payload += payload @@ -108,7 +134,8 @@ export function queueTerminalLiveMirrorSend( payload, byteLength, requests: [{ resolve: resolveRequest }], - sender + sender, + control }) } diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts index 0ff5d08ed9e..18c31c1f738 100644 --- a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts @@ -3,7 +3,6 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import type { TextInput } from 'react-native' import { describe, expect, it, vi } from 'vitest' import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input' -import type { TerminalLiveInputSender } from './terminal-live-input-sender' import { getTerminalLiveAccessoryInactiveInputCommitResult, useTerminalLiveAccessoryInputCommit, @@ -30,8 +29,6 @@ type AccessoryInputCommitHarnessOptions = { readonly heldText?: string readonly sentText?: string readonly pendingHandle?: string | null - readonly sendResult?: boolean - readonly flushResult?: boolean readonly waitResult?: boolean } @@ -39,9 +36,7 @@ type AccessoryInputCommitHarness = { readonly commit: ( input: TerminalLiveAccessoryInput ) => Promise - readonly sent: readonly string[] readonly applyLiveInputMirror: ReturnType - readonly flushPendingLiveInputText: ReturnType readonly waitForPendingLiveInputFlush: ReturnType readonly unmount: () => void } @@ -51,8 +46,6 @@ function createAccessoryInputCommitHarness({ heldText = '', sentText = '', pendingHandle = null, - sendResult = true, - flushResult = true, waitResult = true }: AccessoryInputCommitHarnessOptions = {}): AccessoryInputCommitHarness { const activeHandle = 'terminal-a' @@ -62,18 +55,12 @@ function createAccessoryInputCommitHarness({ const pendingLiveInputHandleRef: RefObject = { current: pendingHandle } const liveInputRef: RefObject = { current: null } const liveInputTerminalHandles = new Set([activeHandle]) - const sent: string[] = [] - const sendLiveTerminalInputRef: RefObject = { - current: async (_handle, bytes) => { - sent.push(bytes) - return sendResult - } - } const applyLiveInputMirror = vi.fn( async (_handle: string, _fieldText: string, _composing?: boolean) => true ) const clearPendingLiveInputCommit = vi.fn(() => {}) - const flushPendingLiveInputText = vi.fn(async (_expectedHandle: string | null) => flushResult) + const endLiveInputSessionThenSend = vi.fn(async (_handle: string, _bytes: string) => true) + const queueLiveInputControl = vi.fn(async (_handle: string, _bytes: string) => true) const waitForPendingLiveInputFlush = vi.fn(async () => waitResult) const setLiveInputCapture = vi.fn((_text: string) => {}) @@ -85,15 +72,15 @@ function createAccessoryInputCommitHarness({ activeHandle, applyLiveInputMirror, clearPendingLiveInputCommit, - flushPendingLiveInputText, + endLiveInputSessionThenSend, heldLiveInputTextRef, liveInputComposingRef, liveInputRef, liveInputTerminalHandles, onInteraction: vi.fn(), pendingLiveInputHandleRef, + queueLiveInputControl, sentLiveInputTextRef, - sendLiveTerminalInputRef, setLiveInputCapture, waitForPendingLiveInputFlush }) @@ -109,9 +96,7 @@ function createAccessoryInputCommitHarness({ return { commit, - sent, applyLiveInputMirror, - flushPendingLiveInputText, waitForPendingLiveInputFlush, unmount: () => { act(() => renderer?.unmount()) @@ -149,72 +134,6 @@ describe('terminal live accessory inactive input commit result', () => { }) describe('terminal live accessory input commit hook', () => { - it('Given raw accessory bytes with a held syllable When committed Then flushes held text before sending bytes', async () => { - const harness = createAccessoryInputCommitHarness({ - heldText: '한', - sentText: '', - pendingHandle: 'terminal-a' - }) - - const result = await harness.commit({ bytes: '\x1b' }) - - expect(harness.flushPendingLiveInputText).toHaveBeenCalledWith('terminal-a') - expect(harness.sent).toEqual(['\x1b']) - expect(result).toEqual({ kind: 'handled' }) - }) - - it('Given held text with a failed control send When committed Then reports the accessory input as suppressed', async () => { - const harness = createAccessoryInputCommitHarness({ - heldText: '한', - pendingHandle: 'terminal-a', - sendResult: false - }) - - const result = await harness.commit({ bytes: '\x1b' }) - - expect(harness.flushPendingLiveInputText).toHaveBeenCalledWith('terminal-a') - expect(harness.sent).toEqual(['\x1b']) - expect(result).toEqual({ kind: 'suppress-raw' }) - }) - - it('Given raw accessory bytes with no held text When committed Then allows the raw send without flushing', async () => { - const harness = createAccessoryInputCommitHarness({ pendingHandle: null }) - - const result = await harness.commit({ bytes: '\x1b' }) - - expect(result).toEqual({ kind: 'allow-raw' }) - expect(harness.flushPendingLiveInputText).not.toHaveBeenCalled() - expect(harness.sent).toEqual([]) - }) - - // Line-ending controls must clear mirrored text even with no held preedit; otherwise later keystrokes append to the previous command. - it('Given an accessory Enter with mirrored sent text and no held text When committed Then ends the field session before the raw send', async () => { - const harness = createAccessoryInputCommitHarness({ - pendingHandle: 'terminal-a', - sentText: 'ls' - }) - - const result = await harness.commit({ bytes: '\r' }) - - expect(harness.flushPendingLiveInputText).toHaveBeenCalledWith('terminal-a') - // The caller sends this control; a second hook send would duplicate Return. - expect(result).toEqual({ kind: 'allow-raw' }) - expect(harness.sent).toEqual([]) - }) - - it('Given an accessory Enter whose field session cannot be ended When committed Then suppresses the raw send', async () => { - const harness = createAccessoryInputCommitHarness({ - pendingHandle: 'terminal-a', - sentText: 'ls', - flushResult: false - }) - - const result = await harness.commit({ bytes: '\r' }) - - expect(result).toEqual({ kind: 'suppress-raw' }) - expect(harness.sent).toEqual([]) - }) - it('Given accessory backspace with reported composition When committed Then keeps the edited preedit held', async () => { const harness = createAccessoryInputCommitHarness({ composing: true, @@ -227,7 +146,6 @@ describe('terminal live accessory input commit hook', () => { expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'ni ha', true) expect(result).toEqual({ kind: 'handled' }) - expect(harness.sent).toEqual([]) }) it('Given accessory backspace with an unreported hold When committed Then preserves the Android fallback', async () => { diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts index f6d0806a735..2ccb8352f02 100644 --- a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts @@ -6,8 +6,6 @@ import { terminalLiveAccessoryInputEndsLine } from './terminal-live-text-commit' import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input' -import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' -import type { TerminalLiveInputSender } from './terminal-live-input-sender' import { writeTerminalLiveInputText } from './terminal-live-input-text-write' export type TerminalLiveAccessoryInputCommitResult = @@ -29,15 +27,15 @@ type TerminalLiveAccessoryInputCommitOptions = { composing?: boolean ) => Promise readonly clearPendingLiveInputCommit: () => void - readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise + readonly endLiveInputSessionThenSend: (handle: string, bytes: string) => Promise readonly heldLiveInputTextRef: RefObject readonly liveInputComposingRef: RefObject readonly liveInputRef: RefObject readonly liveInputTerminalHandles: ReadonlySet readonly onInteraction: () => void readonly pendingLiveInputHandleRef: RefObject + readonly queueLiveInputControl: (handle: string, bytes: string) => Promise readonly sentLiveInputTextRef: RefObject - readonly sendLiveTerminalInputRef: RefObject readonly setLiveInputCapture: (text: string) => void readonly waitForPendingLiveInputFlush: () => Promise } @@ -46,15 +44,15 @@ export function useTerminalLiveAccessoryInputCommit({ activeHandle, applyLiveInputMirror, clearPendingLiveInputCommit, - flushPendingLiveInputText, + endLiveInputSessionThenSend, heldLiveInputTextRef, liveInputComposingRef, liveInputRef, liveInputTerminalHandles, onInteraction, pendingLiveInputHandleRef, + queueLiveInputControl, sentLiveInputTextRef, - sendLiveTerminalInputRef, setLiveInputCapture, waitForPendingLiveInputFlush }: TerminalLiveAccessoryInputCommitOptions): ( @@ -78,11 +76,11 @@ export function useTerminalLiveAccessoryInputCommit({ const decision = getTerminalLiveAccessoryBytesDecision({ ...input, heldText, sentText }) switch (decision.kind) { case 'send-now': { - // Order controls behind mirror sends; line-ending controls also end the field session so later input cannot reuse stale text. - const ready = terminalLiveAccessoryInputEndsLine(input.bytes) - ? await flushPendingLiveInputText(activeHandle) - : await waitForPendingLiveInputFlush() - return ready ? { kind: 'allow-raw' } : { kind: 'suppress-raw' } + // Line-ending controls also end the field session so later input cannot reuse stale text. + const sent = await (terminalLiveAccessoryInputEndsLine(input.bytes) + ? endLiveInputSessionThenSend(activeHandle, input.bytes) + : queueLiveInputControl(activeHandle, input.bytes)) + return sent ? { kind: 'handled' } : { kind: 'suppress-raw' } } case 'local-edit': { const editedText = getTerminalLiveAccessoryLocalEditText({ @@ -101,10 +99,7 @@ export function useTerminalLiveAccessoryInputCommit({ return sent ? { kind: 'handled' } : { kind: 'suppress-raw' } } case 'commit-held-then-send': { - const sent = await sendTerminalLiveControlAfterPendingFlush( - () => flushPendingLiveInputText(activeHandle), - () => sendLiveTerminalInputRef.current(activeHandle, decision.bytes) - ) + const sent = await endLiveInputSessionThenSend(activeHandle, decision.bytes) return sent ? { kind: 'handled' } : { kind: 'suppress-raw' } } default: @@ -116,15 +111,15 @@ export function useTerminalLiveAccessoryInputCommit({ activeHandle, applyLiveInputMirror, clearPendingLiveInputCommit, - flushPendingLiveInputText, + endLiveInputSessionThenSend, heldLiveInputTextRef, liveInputComposingRef, liveInputRef, liveInputTerminalHandles, onInteraction, pendingLiveInputHandleRef, + queueLiveInputControl, sentLiveInputTextRef, - sendLiveTerminalInputRef, setLiveInputCapture, waitForPendingLiveInputFlush ] diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts index da2eb42656f..61901a9f083 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -216,6 +216,25 @@ describe('terminal live input commit hook', () => { await vi.waitFor(() => expect(sent).toEqual(['한'])) }) + it('Given a held syllable When an accessory Esc arrives Then commits the syllable before the Esc', async () => { + const { handlers, sent } = createTerminalLiveInputCommitHarness() + changeLiveInput(handlers, '한') + + await handlers.handleLiveInputAccessoryBytes({ bytes: '\x1b' }) + + expect(sent).toEqual(['한', '\x1b']) + }) + + it('Given mirrored text When an accessory Enter arrives Then later typing starts a new line', async () => { + const { handlers, sent } = createTerminalLiveInputCommitHarness() + changeLiveInput(handlers, 'ls') + + await handlers.handleLiveInputAccessoryBytes({ bytes: '\r' }) + changeLiveInput(handlers, 'x') + + await vi.waitFor(() => expect(sent).toEqual(['ls', '\r', 'x'])) + }) + it('Given a failed mirrored Backspace When accessory input commits Then reports failure', async () => { const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false }) changeLiveInput(handlers, 'a', false) diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts index 6e2f57d48d7..8d474b61b50 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.ts @@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, type RefObject } from 'react' import type { TextInput } from 'react-native' import { reportedLiveInputComposing } from '../platform/live-input-composing-range' import { getTerminalLiveSpecialKeyDecision } from './terminal-live-text-commit' -import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input' import type { TerminalLiveInputSender } from './terminal-live-input-sender' import { writeTerminalLiveInputText } from './terminal-live-input-text-write' @@ -95,6 +94,7 @@ export function useTerminalLiveInputCommit({ heldLiveInputTextRef, liveInputComposingRef, pendingLiveInputHandleRef, + queueLiveInputControl, sentLiveInputTextRef, waitForPendingLiveInputFlush } = useTerminalLivePendingInputFlush({ @@ -151,8 +151,8 @@ export function useTerminalLiveInputCommit({ ] ) - // Clicks move the cursor away from the mirror’s assumed end; start a new editing session. - const sendLiveInputClick = useCallback( + // Clicks, Return and held-text controls leave the mirror’s assumed field; start a new editing session. + const endFieldSessionThenSend = useCallback( (handle: string, bytes: string): Promise => { advanceLiveInputInteractionGeneration() const fieldText = liveInputTextRef.current @@ -246,15 +246,10 @@ export function useTerminalLiveInputCommit({ case 'local-edit': return case 'send-now': - void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () => - sendLiveTerminalInputRef.current(activeHandle, decision.bytes) - ) + void queueLiveInputControl(activeHandle, decision.bytes) return case 'commit-held-then-send': - void sendTerminalLiveControlAfterPendingFlush( - () => flushPendingLiveInputText(activeHandle), - () => sendLiveTerminalInputRef.current(activeHandle, decision.bytes) - ) + void endFieldSessionThenSend(activeHandle, decision.bytes) return default: decision satisfies never @@ -264,10 +259,9 @@ export function useTerminalLiveInputCommit({ activeHandle, advanceLiveInputInteractionGeneration, clearPendingLiveInputCommit, - flushPendingLiveInputText, + endFieldSessionThenSend, liveInputTerminalHandles, - sendLiveTerminalInputRef, - waitForPendingLiveInputFlush + queueLiveInputControl ] ) @@ -275,15 +269,15 @@ export function useTerminalLiveInputCommit({ activeHandle, applyLiveInputMirror, clearPendingLiveInputCommit, - flushPendingLiveInputText, + endLiveInputSessionThenSend: endFieldSessionThenSend, heldLiveInputTextRef, liveInputComposingRef, liveInputRef, liveInputTerminalHandles, onInteraction: advanceLiveInputInteractionGeneration, pendingLiveInputHandleRef, + queueLiveInputControl, sentLiveInputTextRef, - sendLiveTerminalInputRef, setLiveInputCapture: updateLiveInputCapture, waitForPendingLiveInputFlush }) @@ -292,18 +286,8 @@ export function useTerminalLiveInputCommit({ if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) { return Promise.resolve(false) } - advanceLiveInputInteractionGeneration() - return sendTerminalLiveControlAfterPendingFlush( - () => flushPendingLiveInputText(activeHandle), - () => sendLiveTerminalInputRef.current(activeHandle, '\r') - ) - }, [ - activeHandle, - advanceLiveInputInteractionGeneration, - flushPendingLiveInputText, - liveInputTerminalHandles, - sendLiveTerminalInputRef - ]) + return endFieldSessionThenSend(activeHandle, '\r') + }, [activeHandle, endFieldSessionThenSend, liveInputTerminalHandles]) return { clearPendingLiveInputCommit, @@ -313,6 +297,6 @@ export function useTerminalLiveInputCommit({ handleLiveInputChange, handleLiveInputKeyPress, handleLiveInputSubmit, - sendLiveInputClick + sendLiveInputClick: endFieldSessionThenSend } } diff --git a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts index 7e25e4733e0..b0a3e33de69 100644 --- a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts +++ b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts @@ -10,6 +10,7 @@ import { import { cancelTerminalLivePendingFlush, createTerminalLivePendingFlushState, + queueTerminalLiveControlSend, queueTerminalLiveMirrorSend, waitForTerminalLivePendingFlush } from './terminal-live-pending-flush-state' @@ -42,6 +43,7 @@ type TerminalLivePendingInputFlush = { readonly heldLiveInputTextRef: RefObject readonly liveInputComposingRef: RefObject readonly pendingLiveInputHandleRef: RefObject + readonly queueLiveInputControl: (handle: string, bytes: string) => Promise readonly sentLiveInputTextRef: RefObject readonly waitForPendingLiveInputFlush: () => Promise } @@ -115,7 +117,11 @@ export function useTerminalLivePendingInputFlush({ heldLiveInputTextRef.current = step.heldText liveInputComposingRef.current = composing pendingLiveInputHandleRef.current = - step.heldText.length > 0 || step.nextSentText.length > 0 ? handle : null + step.heldText.length > 0 || + step.nextSentText.length > 0 || + pendingLiveInputFlushRef.current.current + ? handle + : null clearHeldCommitTimer() // Reported preedit must never idle-commit; only unreported heuristic holds may settle on a timer. @@ -183,7 +189,22 @@ export function useTerminalLivePendingInputFlush({ [clearPendingLiveInputCommit, runMirrorStep, waitForPendingLiveInputFlush] ) - // Queue preedit and click synchronously so the next keystroke follows the cursor move. + // Queue at key time; waiting for the drain would let text typed meanwhile go first. + const queueLiveInputControl = useCallback( + (handle: string, bytes: string): Promise => { + // Own the queued bytes so a terminal switch retires them even with an empty field. + pendingLiveInputHandleRef.current = handle + return queueTerminalLiveControlSend( + pendingLiveInputFlushRef.current, + handle, + bytes, + sendQueuedMirrorPayload + ) + }, + [sendQueuedMirrorPayload] + ) + + // Queue held text and the control synchronously so the next keystroke follows them. const endLiveInputSessionThenSend = useCallback( (handle: string, bytes: string): Promise => { const pendingHandle = pendingLiveInputHandleRef.current @@ -202,17 +223,22 @@ export function useTerminalLivePendingInputFlush({ void queueTerminalLiveMirrorSend(flushState, handle, payload, sendQueuedMirrorPayload) } } - const sent = queueTerminalLiveMirrorSend(flushState, handle, bytes, sendQueuedMirrorPayload) + const sent = queueLiveInputControl(handle, bytes) clearHeldCommitTimer() heldLiveInputTextRef.current = '' liveInputComposingRef.current = undefined sentLiveInputTextRef.current = '' - pendingLiveInputHandleRef.current = null setLiveInputCapture('') writeTerminalLiveInputText(liveInputRef, '') return sent }, - [clearHeldCommitTimer, liveInputRef, sendQueuedMirrorPayload, setLiveInputCapture] + [ + clearHeldCommitTimer, + liveInputRef, + queueLiveInputControl, + sendQueuedMirrorPayload, + setLiveInputCapture + ] ) useEffect(() => { @@ -237,6 +263,7 @@ export function useTerminalLivePendingInputFlush({ heldLiveInputTextRef, liveInputComposingRef, pendingLiveInputHandleRef, + queueLiveInputControl, sentLiveInputTextRef, waitForPendingLiveInputFlush }