fix(mobile): send terminal controls in key order with live typing (#33724)

This commit is contained in:
Kelvin Amoaba
2026-10-26 19:38:23 +00:00
committed by GitHub
parent fa3bd28995
commit 2451037176
10 changed files with 179 additions and 289 deletions
@@ -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<void>((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
@@ -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<boolean>()
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'])
})
})
@@ -1,12 +0,0 @@
export type TerminalLiveAsyncSendStep = () => Promise<boolean>
export async function sendTerminalLiveControlAfterPendingFlush(
flushPendingText: TerminalLiveAsyncSendStep,
sendControlBytes: TerminalLiveAsyncSendStep
): Promise<boolean> {
const flushed = await flushPendingText()
if (!flushed) {
return false
}
return sendControlBytes()
}
@@ -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<boolean>((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<boolean>((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<boolean> => {
writes.push(payload)
return writes.length === 1
? new Promise<boolean>((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()
@@ -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<boolean>
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<boolean>
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<boolean> {
return queueTerminalLiveSend(state, handle, payload, sender, false)
}
export function queueTerminalLiveControlSend(
state: TerminalLivePendingFlushState,
handle: string,
bytes: string,
sender: TerminalLiveMirrorSender
): Promise<boolean> {
return queueTerminalLiveSend(state, handle, bytes, sender, true)
}
function queueTerminalLiveSend(
state: TerminalLivePendingFlushState,
handle: string,
payload: string,
sender: TerminalLiveMirrorSender,
control: boolean
): Promise<boolean> {
let resolveRequest: (sent: boolean) => void = () => {}
const request = new Promise<boolean>((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
})
}
@@ -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<TerminalLiveAccessoryInputCommitResult>
readonly sent: readonly string[]
readonly applyLiveInputMirror: ReturnType<typeof vi.fn>
readonly flushPendingLiveInputText: ReturnType<typeof vi.fn>
readonly waitForPendingLiveInputFlush: ReturnType<typeof vi.fn>
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<string | null> = { current: pendingHandle }
const liveInputRef: RefObject<TextInput | null> = { current: null }
const liveInputTerminalHandles = new Set([activeHandle])
const sent: string[] = []
const sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender> = {
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 () => {
@@ -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<boolean>
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly endLiveInputSessionThenSend: (handle: string, bytes: string) => Promise<boolean>
readonly heldLiveInputTextRef: RefObject<string>
readonly liveInputComposingRef: RefObject<boolean | undefined>
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputTerminalHandles: ReadonlySet<string>
readonly onInteraction: () => void
readonly pendingLiveInputHandleRef: RefObject<string | null>
readonly queueLiveInputControl: (handle: string, bytes: string) => Promise<boolean>
readonly sentLiveInputTextRef: RefObject<string>
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
readonly setLiveInputCapture: (text: string) => void
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
}
@@ -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
]
@@ -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)
@@ -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<TTabType extends string>({
heldLiveInputTextRef,
liveInputComposingRef,
pendingLiveInputHandleRef,
queueLiveInputControl,
sentLiveInputTextRef,
waitForPendingLiveInputFlush
} = useTerminalLivePendingInputFlush({
@@ -151,8 +151,8 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
]
)
// 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<boolean> => {
advanceLiveInputInteractionGeneration()
const fieldText = liveInputTextRef.current
@@ -246,15 +246,10 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
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<TTabType extends string>({
activeHandle,
advanceLiveInputInteractionGeneration,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
endFieldSessionThenSend,
liveInputTerminalHandles,
sendLiveTerminalInputRef,
waitForPendingLiveInputFlush
queueLiveInputControl
]
)
@@ -275,15 +269,15 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
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<TTabType extends string>({
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<TTabType extends string>({
handleLiveInputChange,
handleLiveInputKeyPress,
handleLiveInputSubmit,
sendLiveInputClick
sendLiveInputClick: endFieldSessionThenSend
}
}
@@ -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<string>
readonly liveInputComposingRef: RefObject<boolean | undefined>
readonly pendingLiveInputHandleRef: RefObject<string | null>
readonly queueLiveInputControl: (handle: string, bytes: string) => Promise<boolean>
readonly sentLiveInputTextRef: RefObject<string>
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
}
@@ -115,7 +117,11 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
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<TTabType extends string>({
[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<boolean> => {
// 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<boolean> => {
const pendingHandle = pendingLiveInputHandleRef.current
@@ -202,17 +223,22 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
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<TTabType extends string>({
heldLiveInputTextRef,
liveInputComposingRef,
pendingLiveInputHandleRef,
queueLiveInputControl,
sentLiveInputTextRef,
waitForPendingLiveInputFlush
}