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