mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(mobile): send terminal controls in key order with live typing (#33724)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user