From eb4f541cdab4e1c67aedf4a3120f3aa6f38bac55 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:43:47 -0700 Subject: [PATCH] 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 * 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 Co-authored-by: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> --- ...obile-localization-coverage-allowlist.json | 11 ++- ...eb-app-terminal-live-input-render.test.mjs | 23 +++++ ...mobile-web-app-held-press-probe-routes.mjs | 1 - .../src/session/MobileSessionCommandDock.tsx | 72 +++++++------- .../session/MobileTerminalLiveInputStatus.tsx | 34 ++++--- .../mobile-session-command-input-styles.ts | 35 ++----- .../use-mobile-session-terminal-runtime.ts | 3 +- .../terminal-live-input-text-write.ts | 11 ++- .../terminal-live-input-text-write.web.ts | 10 +- .../terminal-live-text-commit.test.ts | 42 +++++++++ .../src/terminal/terminal-live-text-commit.ts | 40 +++++--- ...inal-text-field-submit-binding.web.test.ts | 4 + .../terminal-text-field-submit-binding.web.ts | 4 + ...rminal-live-accessory-input-commit.test.ts | 94 ++++++++++++++++++- ...se-terminal-live-accessory-input-commit.ts | 48 +++++++++- .../use-terminal-live-input-commit.ts | 10 +- 16 files changed, 339 insertions(+), 103 deletions(-) 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,