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,