fix(mobile): show live input caret and preserve selection edits (#31477)

* fix(mobile): show live input caret and preserve selection edits

Display the existing input directly so its native caret shows. Accessory
Backspace/Delete edit at the field's caret, read from the input when pressed,
so edits that keep the caret (IME composition) still delete, and the caret is
restored for subsequent typing.
Port and adapt community PR #28294 to the current command dock.

Co-authored-by: Kyino <kyino1025@gmail.com>

* fix(mobile): keep live input caret keys local and the dock height fixed

Hardware Left/Right/Home/End moved both the visible field caret and the
PTY cursor, so the mirror erased at the wrong place. With field text they
now move only the field caret, like Backspace and Delete; Right/End at the
field end still reach the PTY so shells can accept an autosuggestion. On
Android the key binding lets plain caret keys through to that decision
instead of sending them to the PTY. The dictation and upload status covers
the field instead of adding a row, so starting or stopping either no longer
resizes the terminal.

* test(mobile): annotate the DOM field cast in the accessory input test

* chore(mobile): allowlist the live input field strings for localization coverage

---------

Co-authored-by: Kyino <kyino1025@gmail.com>
Co-authored-by: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com>
This commit is contained in:
Neil
2026-10-29 02:43:47 +00:00
committed by GitHub
co-authored by Kyino Kelvin Amoaba
parent c9745246d4
commit eb4f541cda
16 changed files with 339 additions and 103 deletions
@@ -1269,14 +1269,21 @@
{
"filePath": "mobile/src/session/MobileSessionCommandDock.tsx",
"kind": "jsx-attribute:accessibilityLabel",
"text": "Send command",
"text": "Live terminal input",
"dynamic": false,
"count": 1
},
{
"filePath": "mobile/src/session/MobileSessionCommandDock.tsx",
"kind": "jsx-attribute:accessibilityLabel",
"text": "Show keyboard for live terminal input",
"text": "Send command",
"dynamic": false,
"count": 1
},
{
"filePath": "mobile/src/session/MobileSessionCommandDock.tsx",
"kind": "jsx-attribute:placeholder",
"text": "Live input",
"dynamic": false,
"count": 1
},
@@ -280,6 +280,29 @@ describeRender(
await page.close()
}, 300_000)
it('preserves the middle caret after accessory Delete and subsequent typing', async () => {
const { errors, page } = await openProbe()
await page.focus(`#${LIVE_INPUT_FIELD_ID}`)
await page.keyboard.type('abcdef')
const sentBeforeSelection = await page.evaluate(() => globalThis.__orcaLiveInputProbe.sent())
await page.evaluate((id) => {
document.getElementById(id).setSelectionRange(3, 3)
}, LIVE_INPUT_FIELD_ID)
expect(await page.evaluate(() => globalThis.__orcaLiveInputProbe.sent())).toEqual(
sentBeforeSelection
)
expect(
await page.evaluate(() =>
globalThis.__orcaLiveInputProbe.accessory({ bytes: '\x1b[3~', localEdit: 'delete' })
)
).toEqual({ kind: 'handled' })
expect(await fieldValue(page)).toBe('abcef')
await page.keyboard.type('X')
expect(await fieldValue(page)).toBe('abcXef')
expect(errors).toEqual([])
await page.close()
}, 300_000)
describe('under an Android keyboard, which composes every word it types', () => {
// Android WebView Latin keyboard keeps composing region mid-word; native Android reports no range and forwards ASCII immediately.
const ANDROID_WEBVIEW_USER_AGENT =
@@ -107,7 +107,6 @@ export default function CommandDockProbeRoute() {
canSend: true,
canCompose: true,
liveInputEnabled: false,
focusLiveInput: noop,
showNativeChat: false,
dictation: IDLE,
cancelDictation: noop,
+33 -39
View File
@@ -1,6 +1,6 @@
import { View, TextInput, Pressable } from 'react-native'
import { hostOs } from '../platform/host-os'
import { ArrowUp, Keyboard as KeyboardIcon } from 'lucide-react-native'
import { ArrowUp } from 'lucide-react-native'
import {
getTerminalCommandKeyboardType,
getTerminalLiveInputKeyboardType
@@ -46,7 +46,6 @@ export function MobileSessionCommandDock({
canSend,
canCompose,
liveInputEnabled,
focusLiveInput,
dictation,
cancelDictation,
handleDictationToggle,
@@ -69,27 +68,43 @@ export function MobileSessionCommandDock({
{keysInRail ? null : <MobileSessionAccessoryKeys controller={controller} />}
{}
{liveInputEnabled ? (
<View style={[styles.inputBar, styles.liveInputBar]}>
<Pressable
style={({ pressed }) => [
styles.liveInputFocusTarget,
pressed && styles.liveInputFocusTargetPressed,
!canSend && styles.liveInputFocusTargetDisabled
]}
disabled={!canSend}
onPress={focusLiveInput}
accessibilityRole="button"
accessibilityLabel="Show keyboard for live terminal input"
accessibilityHint="Typed text is sent directly to the active terminal"
>
<KeyboardIcon size={16} color={colors.textSecondary} strokeWidth={2} />
<View style={styles.inputBar}>
<View style={styles.liveInputField}>
<TextInput
ref={bindLiveInputField}
style={[
styles.textInput,
styles.liveInputCapture,
!canSend && styles.liveInputCaptureDisabled
]}
value={liveInputCapture}
onChange={handleLiveInputChange}
onKeyPress={handleLiveInputKeyPress}
onSubmitEditing={submitLiveInput}
placeholder="Live input"
placeholderTextColor={colors.textMuted}
selectionColor={colors.accentBlue}
accessibilityLabel="Live terminal input"
accessibilityHint="Typed text is sent directly to the active terminal"
showSoftInputOnFocus
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
smartInsertDelete={false}
// Why: iOS textContentType overrides autoComplete and can narrow the keyboard; keep IME switching available.
autoComplete="off"
keyboardType={getTerminalLiveInputKeyboardType(hostOs())}
returnKeyType="default"
blurOnSubmit={false}
editable={canSend}
importantForAutofill="no"
/>
<MobileTerminalLiveInputStatus
dictation={dictation}
dictationMode={dictationMode}
isAttaching={isAttaching}
liveInputText={liveInputCapture}
/>
</Pressable>
</View>
<MobileTerminalInputActions
canSend={canSend}
isAttaching={isAttaching}
@@ -106,27 +121,6 @@ export function MobileSessionCommandDock({
onDictationPressOut={handleDictationPressOut}
onDictationCancel={cancelDictation}
/>
<TextInput
ref={bindLiveInputField}
style={styles.liveInputCapture}
value={liveInputCapture}
onChange={handleLiveInputChange}
onKeyPress={handleLiveInputKeyPress}
onSubmitEditing={submitLiveInput}
placeholder=""
showSoftInputOnFocus
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
smartInsertDelete={false}
// Why: iOS textContentType overrides autoComplete and can narrow the keyboard; keep IME switching available.
autoComplete="off"
keyboardType={getTerminalLiveInputKeyboardType(hostOs())}
returnKeyType="default"
blurOnSubmit={false}
editable={canSend}
importantForAutofill="no"
/>
</View>
) : (
<View style={styles.inputBar}>
@@ -1,5 +1,5 @@
import { Text, View } from 'react-native'
import { colors, typography } from '../theme/mobile-theme'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { themedStyleSheet } from '../theme/themed-styles'
import {
@@ -11,17 +11,20 @@ type MobileTerminalLiveInputStatusProps = {
readonly dictation: MobileDictationStatusFlags
readonly dictationMode: string | undefined
readonly isAttaching: boolean
readonly liveInputText: string
}
/** Shows transient recording/upload status over the field without intercepting its touches. */
export function MobileTerminalLiveInputStatus({
dictation,
dictationMode,
isAttaching,
liveInputText
isAttaching
}: MobileTerminalLiveInputStatusProps) {
useMobileColorScheme()
const title = mobileDictationStatusTitle(dictation) ?? 'Live input'
const dictationTitle = mobileDictationStatusTitle(dictation)
if (!dictationTitle && !isAttaching) {
return null
}
const title = dictationTitle ?? 'Uploading image'
const detail = dictation.isRecording
? dictationMode === 'hold'
? 'Release mic to stop'
@@ -30,12 +33,10 @@ export function MobileTerminalLiveInputStatus({
? 'Transcribing on desktop'
: dictation.isStarting
? 'Preparing microphone'
: isAttaching
? 'Uploading image to host'
: liveInputText || 'Tap to show keyboard'
: 'Uploading image to host'
return (
<View style={styles.status}>
<View style={styles.status} pointerEvents="none">
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
@@ -47,9 +48,19 @@ export function MobileTerminalLiveInputStatus({
}
const styles = themedStyleSheet(() => ({
// Cover the field in place so the dock, and with it the PTY, keeps its height.
status: {
flex: 1,
gap: 1
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radii.input,
backgroundColor: colors.bgRaised
},
title: {
color: colors.textPrimary,
@@ -57,6 +68,7 @@ const styles = themedStyleSheet(() => ({
fontWeight: '600'
},
detail: {
flexShrink: 1,
color: colors.textSecondary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily
@@ -193,37 +193,16 @@ export const mobileSessionCommandInputStyles = themedStyleSheet(() => ({
fontFamily: typography.monoFamily,
marginRight: spacing.sm
},
liveInputBar: {
gap: spacing.sm
},
liveInputFocusTarget: {
liveInputField: {
flex: 1,
minHeight: 34,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.input,
paddingHorizontal: spacing.sm + 2
marginRight: spacing.sm
},
liveInputFocusTargetPressed: {
backgroundColor: colors.borderSubtle
},
liveInputFocusTargetDisabled: {
opacity: 0.45
},
liveInputCapture: {
position: 'absolute',
opacity: 0,
width: 1,
height: 1,
color: colors.textPrimary
minWidth: 0,
marginRight: 0
},
liveInputCaptureDisabled: {
opacity: 0.45
},
sendButton: {
backgroundColor: colors.bgRaised,
@@ -144,7 +144,7 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM
const canCompose = inputGate.canCompose
const canSend = inputGate.canSend && clientId !== null
const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false
const { focusLiveInput, handleTerminalTap, resetLiveInputFocus } = useTerminalLiveInputFocus({
const { handleTerminalTap, resetLiveInputFocus } = useTerminalLiveInputFocus({
activeHandleRef,
canSend,
inputRef: liveInputRef,
@@ -216,7 +216,6 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM
canCompose,
canSend,
liveInputEnabled,
focusLiveInput,
handleTerminalTap,
resetLiveInputFocus,
terminalInventoryRecoveryScope,
@@ -1,7 +1,16 @@
import type { RefObject } from 'react'
import type { TextInput } from 'react-native'
import type { TerminalLiveInputSelection } from './terminal-live-text-commit'
// Unchanged controlled values need an imperative IME repair; RN Web refs are DOM nodes and require the platform sibling.
export function writeTerminalLiveInputText(ref: RefObject<TextInput | null>, text: string): void {
export function writeTerminalLiveInputText(
ref: RefObject<TextInput | null>,
text: string,
selection?: TerminalLiveInputSelection
): void {
ref.current?.setNativeProps({ text })
if (selection) {
// Fabric ignores collapsed selection props after replacing text.
ref.current?.setSelection(selection.start, selection.end)
}
}
@@ -1,10 +1,18 @@
import type { RefObject } from 'react'
import type { TextInput } from 'react-native'
import type { TerminalLiveInputSelection } from './terminal-live-text-commit'
// RN Web refs are input/textarea nodes; reject future wrapper shapes before writing value.
export function writeTerminalLiveInputText(ref: RefObject<TextInput | null>, text: string): void {
export function writeTerminalLiveInputText(
ref: RefObject<TextInput | null>,
text: string,
selection?: TerminalLiveInputSelection
): void {
const node = ref.current
if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) {
node.value = text
if (selection) {
node.setSelectionRange(selection.start, selection.end)
}
}
}
@@ -21,6 +21,32 @@ describe('terminal live special key decision', () => {
).toEqual({ kind: 'local-edit' })
})
it.each(['ArrowLeft', 'ArrowRight', 'Home', 'End'])(
'Given %s with field text Then moves only the field caret, keeping the PTY cursor at the mirror end',
(key) => {
expect(
getTerminalLiveSpecialKeyDecision({
key,
heldText: '',
sentText: 'abc',
caretAtFieldEnd: false
})
).toEqual({ kind: 'local-edit' })
expect(getTerminalLiveSpecialKeyDecision({ key, heldText: '', sentText: '' }).kind).toBe(
'send-now'
)
}
)
it.each(['ArrowRight', 'End'])(
'Given %s with the caret at the field end Then sends it so shells can accept an autosuggestion',
(key) => {
expect(
getTerminalLiveSpecialKeyDecision({ key, heldText: '', sentText: 'git ch' }).kind
).toBe('send-now')
}
)
it('Given Backspace with an empty field Then sends terminal backspace bytes', () => {
const decision = getTerminalLiveSpecialKeyDecision({
key: 'Backspace',
@@ -99,6 +125,22 @@ describe('terminal live accessory bytes decision', () => {
})
describe('terminal live accessory local edit text', () => {
it.each([
['backspace', 'a🙂b', { start: 3, end: 3 }, 'ab'],
['delete', 'a🙂b', { start: 1, end: 1 }, 'ab'],
['backspace', '可是你好', { start: 1, end: 3 }, '可好'],
['delete', '可是你好', { start: 1, end: 3 }, '可好'],
['backspace', 'abc', { start: 0, end: 0 }, 'abc'],
['delete', 'abc', { start: 3, end: 3 }, 'abc']
] as const)(
'edits %s in %j at its native UTF-16 selection',
(localEdit, fieldText, selection, expected) => {
expect(getTerminalLiveAccessoryLocalEditText({ localEdit, fieldText, selection })).toBe(
expected
)
}
)
it.each([
['', ''],
['a', ''],
@@ -11,10 +11,13 @@ export type TerminalLiveSpecialKeyDecisionInput = {
readonly key: string
readonly heldText: string
readonly sentText: string
readonly caretAtFieldEnd?: boolean
}
export type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete'
export type TerminalLiveInputSelection = { readonly start: number; readonly end: number }
export type TerminalLiveAccessoryBytesDecision =
| { readonly kind: 'local-edit'; readonly localEdit: TerminalLiveAccessoryLocalEdit }
| { readonly kind: 'send-now'; readonly bytes: string }
@@ -27,18 +30,27 @@ export type TerminalLiveAccessoryBytesDecisionInput = {
readonly sentText: string
}
const FIELD_LOCAL_KEYS = new Set(['Backspace', 'Delete', 'ArrowLeft', 'Home'])
// At the field end these still reach the PTY, where shells accept an autosuggestion.
const FIELD_FORWARD_KEYS = new Set(['ArrowRight', 'End'])
export function getTerminalLiveSpecialKeyDecision({
key,
heldText,
sentText
sentText,
caretAtFieldEnd = true
}: TerminalLiveSpecialKeyDecisionInput): TerminalLiveSpecialKeyDecision {
const bytes = getTerminalLiveSpecialKeyBytes(key)
if (bytes === null) {
return { kind: 'ignore' }
}
// Native edits already emit mirror-diff erase bytes; raw DEL here would erase twice.
if ((key === 'Backspace' || key === 'Delete') && (heldText.length > 0 || sentText.length > 0)) {
// The field edits and moves its own caret and the mirror diff keeps the PTY cursor at its end;
// raw bytes here would erase twice or move the PTY cursor away from the mirror.
if (
(heldText.length > 0 || sentText.length > 0) &&
(FIELD_LOCAL_KEYS.has(key) || (FIELD_FORWARD_KEYS.has(key) && !caretAtFieldEnd))
) {
return { kind: 'local-edit' }
}
@@ -72,19 +84,25 @@ export function getTerminalLiveAccessoryBytesDecision({
return { kind: 'send-now', bytes }
}
/** Uses native UTF-16 selection offsets; collapsed deletion preserves adjacent surrogate pairs. */
export function getTerminalLiveAccessoryLocalEditText({
localEdit,
fieldText
fieldText,
selection
}: {
readonly localEdit: TerminalLiveAccessoryLocalEdit
readonly fieldText: string
readonly selection?: TerminalLiveInputSelection | null
}): string {
if (localEdit === 'delete') {
// Accessory Delete forwards deletion beyond the hidden input’s end; preserve its local text.
return fieldText
const start = Math.max(0, Math.min(selection?.start ?? fieldText.length, fieldText.length))
const end = Math.max(start, Math.min(selection?.end ?? start, fieldText.length))
if (start !== end) {
return fieldText.slice(0, start) + fieldText.slice(end)
}
const end = fieldText.length
const width = end > 1 && readUtf8CodePointAt(fieldText, end - 2) > 0xffff ? 2 : 1
return fieldText.slice(0, -width)
if (localEdit === 'delete') {
const width = readUtf8CodePointAt(fieldText, start) > 0xffff ? 2 : 1
return fieldText.slice(0, start) + fieldText.slice(start + width)
}
const width = start > 1 && readUtf8CodePointAt(fieldText, start - 2) > 0xffff ? 2 : 1
return fieldText.slice(0, Math.max(0, start - width)) + fieldText.slice(start)
}
@@ -149,6 +149,10 @@ describe('live terminal keyboard chords', () => {
['Enter', {}],
['Backspace', {}],
['Delete', {}],
['ArrowLeft', {}],
['ArrowRight', {}],
['Home', {}],
['End', {}],
['c', { ctrlKey: true, isComposing: true }],
['c', { ctrlKey: true, keyCode: 229 }],
['c', { ctrlKey: true, altKey: true }],
@@ -4,6 +4,9 @@ import { createTerminalKeyChordInput } from './terminal-key-chord-input'
import { getTerminalLiveSpecialKeyId } from './terminal-live-input'
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
// Plain caret keys reach onKeyPress, which keeps them in the field while it holds text.
const FIELD_CARET_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'Home', 'End'])
// RN Web skips composing Enter keydowns; beforeinput line breaks recover those submits.
// Submitted keydowns are cancelled, preventing duplicates; candidate acceptance emits insertCompositionText and remains untouched.
export function bindTerminalTextFieldSubmit(
@@ -56,6 +59,7 @@ export function bindTerminalTextFieldSubmit(
event.key === 'Enter' ||
event.key === 'Backspace' ||
event.key === 'Delete' ||
(FIELD_CARET_KEYS.has(event.key) && !event.ctrlKey && !event.altKey && !event.shiftKey) ||
(!specialKey && !(event.key.length === 1 && (event.ctrlKey || event.altKey)))
) {
return
@@ -3,6 +3,7 @@ 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 { TerminalLiveInputSelection } from './terminal-live-text-commit'
import {
getTerminalLiveAccessoryInactiveInputCommitResult,
useTerminalLiveAccessoryInputCommit,
@@ -30,6 +31,8 @@ type AccessoryInputCommitHarnessOptions = {
readonly sentText?: string
readonly pendingHandle?: string | null
readonly waitResult?: boolean
readonly fieldText?: string
readonly selection?: TerminalLiveInputSelection
}
type AccessoryInputCommitHarness = {
@@ -37,23 +40,44 @@ type AccessoryInputCommitHarness = {
input: TerminalLiveAccessoryInput
) => Promise<TerminalLiveAccessoryInputCommitResult>
readonly applyLiveInputMirror: ReturnType<typeof vi.fn>
readonly queueLiveInputControl: ReturnType<typeof vi.fn>
readonly waitForPendingLiveInputFlush: ReturnType<typeof vi.fn>
readonly setLiveInputCapture: ReturnType<typeof vi.fn>
readonly setFieldValue: (value: string) => void
readonly unmount: () => void
}
/** Separates native selection from mirrored text so accessory edits can exercise either state. */
function createAccessoryInputCommitHarness({
composing,
heldText = '',
sentText = '',
pendingHandle = null,
waitResult = true
waitResult = true,
fieldText,
selection
}: AccessoryInputCommitHarnessOptions = {}): AccessoryInputCommitHarness {
const activeHandle = 'terminal-a'
const heldLiveInputTextRef: RefObject<string> = { current: heldText }
const liveInputComposingRef: RefObject<boolean | undefined> = { current: composing }
const sentLiveInputTextRef: RefObject<string> = { current: sentText }
const pendingLiveInputHandleRef: RefObject<string | null> = { current: pendingHandle }
const liveInputRef: RefObject<TextInput | null> = { current: null }
const liveInputTextRef: RefObject<string> = { current: fieldText ?? sentText + heldText }
// An RN-Web ref is the DOM field; without a selection the ref stands in for a native field.
// Node tests resolve the native text writer, so the field also takes its two calls.
const webField = selection
? {
value: liveInputTextRef.current,
selectionStart: selection.start,
selectionEnd: selection.end,
setNativeProps: vi.fn(),
setSelection: vi.fn()
}
: null
const liveInputRef = {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: RN-Web hands the hook the DOM input as its TextInput ref; this models its caret fields.
current: webField as unknown as TextInput | null
}
const liveInputTerminalHandles = new Set([activeHandle])
const applyLiveInputMirror = vi.fn(
async (_handle: string, _fieldText: string, _composing?: boolean) => true
@@ -67,6 +91,7 @@ function createAccessoryInputCommitHarness({
let commit: AccessoryInputCommitHarness['commit'] | null = null
let renderer: ReactTestRenderer | null = null
/** Mounts the real hook in React so callback dependencies follow the production lifecycle. */
function Harness(): null {
commit = useTerminalLiveAccessoryInputCommit({
activeHandle,
@@ -76,6 +101,7 @@ function createAccessoryInputCommitHarness({
heldLiveInputTextRef,
liveInputComposingRef,
liveInputRef,
liveInputTextRef,
liveInputTerminalHandles,
onInteraction: vi.fn(),
pendingLiveInputHandleRef,
@@ -97,7 +123,14 @@ function createAccessoryInputCommitHarness({
return {
commit,
applyLiveInputMirror,
queueLiveInputControl,
waitForPendingLiveInputFlush,
setLiveInputCapture,
setFieldValue: (value: string) => {
if (webField) {
webField.value = value
}
},
unmount: () => {
act(() => renderer?.unmount())
}
@@ -174,4 +207,61 @@ describe('terminal live accessory input commit hook', () => {
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'a', false)
expect(result).toEqual({ kind: 'handled' })
})
it.each([
['backspace', { start: 2, end: 2 }, '可你好'],
['delete', { start: 2, end: 2 }, '可是好'],
['backspace', { start: 1, end: 3 }, '可好'],
['delete', { start: 1, end: 3 }, '可好']
] as const)(
'edits %s at the native selection instead of the field end',
async (localEdit, selection, expected) => {
const harness = createAccessoryInputCommitHarness({
composing: false,
sentText: '可是你好',
pendingHandle: 'terminal-a',
selection
})
const result = await harness.commit({ bytes: '\x7f', localEdit })
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', expected, false)
expect(harness.queueLiveInputControl).not.toHaveBeenCalled()
expect(result).toEqual({ kind: 'handled' })
harness.unmount()
}
)
it('suppresses accessory deletion while the field holds text the mirror has not seen', async () => {
const harness = createAccessoryInputCommitHarness({
composing: false,
fieldText: 'abc',
sentText: 'ab',
pendingHandle: 'terminal-a',
selection: { start: 1, end: 1 }
})
harness.setFieldValue('axbc')
expect(await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })).toEqual({
kind: 'suppress-raw'
})
expect(harness.applyLiveInputMirror).not.toHaveBeenCalled()
harness.unmount()
})
it('keeps raw smart punctuation in the field and normalizes only the mirror', async () => {
const harness = createAccessoryInputCommitHarness({
composing: false,
fieldText: 'a—b',
sentText: 'a--b',
pendingHandle: 'terminal-a',
selection: { start: 0, end: 0 }
})
await harness.commit({ bytes: '\x1b[3~', localEdit: 'delete' })
expect(harness.setLiveInputCapture).toHaveBeenCalledWith('—b')
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', '--b', false)
harness.unmount()
})
})
@@ -3,10 +3,12 @@ import type { TextInput } from 'react-native'
import {
getTerminalLiveAccessoryBytesDecision,
getTerminalLiveAccessoryLocalEditText,
terminalLiveAccessoryInputEndsLine
terminalLiveAccessoryInputEndsLine,
type TerminalLiveInputSelection
} from './terminal-live-text-commit'
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
import { writeTerminalLiveInputText } from './terminal-live-input-text-write'
import { normalizeTerminalTextInput } from './terminal-text-input-normalization'
export type TerminalLiveAccessoryInputCommitResult =
| { readonly kind: 'allow-raw' }
@@ -31,6 +33,7 @@ type TerminalLiveAccessoryInputCommitOptions = {
readonly heldLiveInputTextRef: RefObject<string>
readonly liveInputComposingRef: RefObject<boolean | undefined>
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputTextRef: RefObject<string>
readonly liveInputTerminalHandles: ReadonlySet<string>
readonly onInteraction: () => void
readonly pendingLiveInputHandleRef: RefObject<string | null>
@@ -40,6 +43,29 @@ type TerminalLiveAccessoryInputCommitOptions = {
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
}
// The RN-Web ref is the field itself, so its caret is current even when an edit kept the offsets
// and fired no selection event; native refs expose no caret, so edit at the end.
export function readLiveInputSelection(
node: TextInput | null,
fieldText: string
): TerminalLiveInputSelection | null {
if (
!node ||
!('selectionStart' in node) ||
!('selectionEnd' in node) ||
typeof node.selectionStart !== 'number' ||
typeof node.selectionEnd !== 'number'
) {
return { start: fieldText.length, end: fieldText.length }
}
// A field that no longer holds the mirrored text would delete at an unrelated offset.
if (!('value' in node) || node.value !== fieldText) {
return null
}
return { start: node.selectionStart, end: node.selectionEnd }
}
/** Keeps accessory edits local and orders terminal controls after pending text has been sent. */
export function useTerminalLiveAccessoryInputCommit({
activeHandle,
applyLiveInputMirror,
@@ -48,6 +74,7 @@ export function useTerminalLiveAccessoryInputCommit({
heldLiveInputTextRef,
liveInputComposingRef,
liveInputRef,
liveInputTextRef,
liveInputTerminalHandles,
onInteraction,
pendingLiveInputHandleRef,
@@ -83,17 +110,29 @@ export function useTerminalLiveAccessoryInputCommit({
return sent ? { kind: 'handled' } : { kind: 'suppress-raw' }
}
case 'local-edit': {
const fieldText = liveInputTextRef.current
const selection = readLiveInputSelection(liveInputRef.current, fieldText)
if (!selection) {
return { kind: 'suppress-raw' }
}
const editedText = getTerminalLiveAccessoryLocalEditText({
localEdit: decision.localEdit,
fieldText: sentText + heldText
fieldText,
selection
})
const start = Math.max(0, Math.min(selection.start, fieldText.length))
const cursor =
decision.localEdit === 'backspace' && selection.start === selection.end
? start - (fieldText.length - editedText.length)
: start
const nextSelection = { start: cursor, end: cursor }
// Accessory buttons emit no native edits; change the field so mirror diffs synchronize PTY echo.
setLiveInputCapture(editedText)
writeTerminalLiveInputText(liveInputRef, editedText)
writeTerminalLiveInputText(liveInputRef, editedText, nextSelection)
// Undefined composition enables Android’s timed heuristic hold.
const sent = await applyLiveInputMirror(
activeHandle,
editedText,
normalizeTerminalTextInput(editedText),
liveInputComposingRef.current
)
return sent ? { kind: 'handled' } : { kind: 'suppress-raw' }
@@ -115,6 +154,7 @@ export function useTerminalLiveAccessoryInputCommit({
heldLiveInputTextRef,
liveInputComposingRef,
liveInputRef,
liveInputTextRef,
liveInputTerminalHandles,
onInteraction,
pendingLiveInputHandleRef,
@@ -8,6 +8,7 @@ import { writeTerminalLiveInputText } from './terminal-live-input-text-write'
import { normalizeTerminalTextInput } from './terminal-text-input-normalization'
import { useTerminalLivePendingInputFlush } from './use-terminal-live-pending-input-flush'
import {
readLiveInputSelection,
useTerminalLiveAccessoryInputCommit,
type TerminalLiveAccessoryInputCommitResult
} from './use-terminal-live-accessory-input-commit'
@@ -236,10 +237,15 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
if (pendingLiveInputHandleRef.current && !ownsPendingState) {
clearPendingLiveInputCommit()
}
const fieldText = liveInputTextRef.current
// Key events precede the caret move, so this is where the key starts from.
const selection = readLiveInputSelection(liveInputRef.current, fieldText)
const decision = getTerminalLiveSpecialKeyDecision({
key: event.nativeEvent.key,
heldText: ownsPendingState ? heldLiveInputTextRef.current : '',
sentText: ownsPendingState ? sentLiveInputTextRef.current : ''
sentText: ownsPendingState ? sentLiveInputTextRef.current : '',
caretAtFieldEnd:
!selection || (selection.start === fieldText.length && selection.end === fieldText.length)
})
switch (decision.kind) {
case 'ignore':
@@ -260,6 +266,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
advanceLiveInputInteractionGeneration,
clearPendingLiveInputCommit,
endFieldSessionThenSend,
liveInputRef,
liveInputTerminalHandles,
queueLiveInputControl
]
@@ -273,6 +280,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
heldLiveInputTextRef,
liveInputComposingRef,
liveInputRef,
liveInputTextRef,
liveInputTerminalHandles,
onInteraction: advanceLiveInputInteractionGeneration,
pendingLiveInputHandleRef,