diff --git a/config/mobile-localization-coverage-allowlist.json b/config/mobile-localization-coverage-allowlist.json
index 8b3a409b7aa..f11df3608de 100644
--- a/config/mobile-localization-coverage-allowlist.json
+++ b/config/mobile-localization-coverage-allowlist.json
@@ -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
},
diff --git a/config/scripts/mobile-web-app/mobile-web-app-terminal-live-input-render.test.mjs b/config/scripts/mobile-web-app/mobile-web-app-terminal-live-input-render.test.mjs
index 6ec91edf34d..aa2a4080ce1 100644
--- a/config/scripts/mobile-web-app/mobile-web-app-terminal-live-input-render.test.mjs
+++ b/config/scripts/mobile-web-app/mobile-web-app-terminal-live-input-render.test.mjs
@@ -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 =
diff --git a/config/scripts/mobile-web-app/preview/mobile-web-app-held-press-probe-routes.mjs b/config/scripts/mobile-web-app/preview/mobile-web-app-held-press-probe-routes.mjs
index 54f50da49aa..dc198f698d0 100644
--- a/config/scripts/mobile-web-app/preview/mobile-web-app-held-press-probe-routes.mjs
+++ b/config/scripts/mobile-web-app/preview/mobile-web-app-held-press-probe-routes.mjs
@@ -107,7 +107,6 @@ export default function CommandDockProbeRoute() {
canSend: true,
canCompose: true,
liveInputEnabled: false,
- focusLiveInput: noop,
showNativeChat: false,
dictation: IDLE,
cancelDictation: noop,
diff --git a/mobile/src/session/MobileSessionCommandDock.tsx b/mobile/src/session/MobileSessionCommandDock.tsx
index ebbbe4600d7..794c6ad4838 100644
--- a/mobile/src/session/MobileSessionCommandDock.tsx
+++ b/mobile/src/session/MobileSessionCommandDock.tsx
@@ -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 : }
{}
{liveInputEnabled ? (
-
- [
- 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"
- >
-
+
+
+
-
+
-
) : (
diff --git a/mobile/src/session/MobileTerminalLiveInputStatus.tsx b/mobile/src/session/MobileTerminalLiveInputStatus.tsx
index 7dae438bc19..e3bc31c9774 100644
--- a/mobile/src/session/MobileTerminalLiveInputStatus.tsx
+++ b/mobile/src/session/MobileTerminalLiveInputStatus.tsx
@@ -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 (
-
+
{title}
@@ -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
diff --git a/mobile/src/session/mobile-session-command-input-styles.ts b/mobile/src/session/mobile-session-command-input-styles.ts
index 383640fabf6..dbe936c1a88 100644
--- a/mobile/src/session/mobile-session-command-input-styles.ts
+++ b/mobile/src/session/mobile-session-command-input-styles.ts
@@ -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,
diff --git a/mobile/src/session/use-mobile-session-terminal-runtime.ts b/mobile/src/session/use-mobile-session-terminal-runtime.ts
index f3edc78c077..5fcabdaa26c 100644
--- a/mobile/src/session/use-mobile-session-terminal-runtime.ts
+++ b/mobile/src/session/use-mobile-session-terminal-runtime.ts
@@ -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,
diff --git a/mobile/src/terminal/terminal-live-input-text-write.ts b/mobile/src/terminal/terminal-live-input-text-write.ts
index 65cfa1c7e1e..7e5806cadfb 100644
--- a/mobile/src/terminal/terminal-live-input-text-write.ts
+++ b/mobile/src/terminal/terminal-live-input-text-write.ts
@@ -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, text: string): void {
+export function writeTerminalLiveInputText(
+ ref: RefObject,
+ 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)
+ }
}
diff --git a/mobile/src/terminal/terminal-live-input-text-write.web.ts b/mobile/src/terminal/terminal-live-input-text-write.web.ts
index a0af07a176e..05eba7c6e30 100644
--- a/mobile/src/terminal/terminal-live-input-text-write.web.ts
+++ b/mobile/src/terminal/terminal-live-input-text-write.web.ts
@@ -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, text: string): void {
+export function writeTerminalLiveInputText(
+ ref: RefObject,
+ 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)
+ }
}
}
diff --git a/mobile/src/terminal/terminal-live-text-commit.test.ts b/mobile/src/terminal/terminal-live-text-commit.test.ts
index 61f0dd9cea6..ce671309953 100644
--- a/mobile/src/terminal/terminal-live-text-commit.test.ts
+++ b/mobile/src/terminal/terminal-live-text-commit.test.ts
@@ -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', ''],
diff --git a/mobile/src/terminal/terminal-live-text-commit.ts b/mobile/src/terminal/terminal-live-text-commit.ts
index 2ccacdf73d0..5b64ae1090f 100644
--- a/mobile/src/terminal/terminal-live-text-commit.ts
+++ b/mobile/src/terminal/terminal-live-text-commit.ts
@@ -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)
}
diff --git a/mobile/src/terminal/terminal-text-field-submit-binding.web.test.ts b/mobile/src/terminal/terminal-text-field-submit-binding.web.test.ts
index e9b5700cc11..4922c9146fa 100644
--- a/mobile/src/terminal/terminal-text-field-submit-binding.web.test.ts
+++ b/mobile/src/terminal/terminal-text-field-submit-binding.web.test.ts
@@ -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 }],
diff --git a/mobile/src/terminal/terminal-text-field-submit-binding.web.ts b/mobile/src/terminal/terminal-text-field-submit-binding.web.ts
index e9a0c518ff9..86177631b94 100644
--- a/mobile/src/terminal/terminal-text-field-submit-binding.web.ts
+++ b/mobile/src/terminal/terminal-text-field-submit-binding.web.ts
@@ -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
diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts
index 18c31c1f738..d784919ea77 100644
--- a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts
+++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts
@@ -3,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
readonly applyLiveInputMirror: ReturnType
+ readonly queueLiveInputControl: ReturnType
readonly waitForPendingLiveInputFlush: ReturnType
+ readonly setLiveInputCapture: ReturnType
+ 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 = { current: heldText }
const liveInputComposingRef: RefObject = { current: composing }
const sentLiveInputTextRef: RefObject = { current: sentText }
const pendingLiveInputHandleRef: RefObject = { current: pendingHandle }
- const liveInputRef: RefObject = { current: null }
+ const liveInputTextRef: RefObject = { 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()
+ })
})
diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts
index 2ccb8352f02..078d4c05bf7 100644
--- a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts
+++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts
@@ -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
readonly liveInputComposingRef: RefObject
readonly liveInputRef: RefObject
+ readonly liveInputTextRef: RefObject
readonly liveInputTerminalHandles: ReadonlySet
readonly onInteraction: () => void
readonly pendingLiveInputHandleRef: RefObject
@@ -40,6 +43,29 @@ type TerminalLiveAccessoryInputCommitOptions = {
readonly waitForPendingLiveInputFlush: () => Promise
}
+// 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,
diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts
index 8d474b61b50..fe7944835a0 100644
--- a/mobile/src/terminal/use-terminal-live-input-commit.ts
+++ b/mobile/src/terminal/use-terminal-live-input-commit.ts
@@ -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({
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({
advanceLiveInputInteractionGeneration,
clearPendingLiveInputCommit,
endFieldSessionThenSend,
+ liveInputRef,
liveInputTerminalHandles,
queueLiveInputControl
]
@@ -273,6 +280,7 @@ export function useTerminalLiveInputCommit({
heldLiveInputTextRef,
liveInputComposingRef,
liveInputRef,
+ liveInputTextRef,
liveInputTerminalHandles,
onInteraction: advanceLiveInputInteractionGeneration,
pendingLiveInputHandleRef,