Files
orca/mobile/src/session/MobileSessionCommandDock.tsx
T
eb4f541cda 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>
2026-10-29 02:43:47 +00:00

186 lines
7.1 KiB
TypeScript

import { View, TextInput, Pressable } from 'react-native'
import { hostOs } from '../platform/host-os'
import { ArrowUp } from 'lucide-react-native'
import {
getTerminalCommandKeyboardType,
getTerminalLiveInputKeyboardType
} from '../terminal/terminal-keyboard-type'
import { MobileTerminalLiveInputStatus } from './MobileTerminalLiveInputStatus'
import { MobileTerminalInputActions } from './MobileTerminalInputActions'
import { MobileSessionAccessoryKeys } from './MobileSessionAccessoryKeys'
import { colors } from '../theme/mobile-theme'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { styles } from './mobile-session-styles'
import type { MobileSessionController } from './use-mobile-session-controller'
// Markdown, file, browser and chat tabs bring their own input; only terminals get the dock and its keys.
export function sessionCommandDockShown(controller: MobileSessionController): boolean {
return (
!controller.activeMarkdownTab &&
!controller.activeFileTab &&
!controller.activeBrowserTab &&
!controller.showNativeChat
)
}
export function MobileSessionCommandDock({
controller,
keysInRail
}: {
controller: MobileSessionController
keysInRail: boolean
}) {
useMobileColorScheme()
const {
insets,
bufferedTerminalDraftState,
autocompleteEnabled,
liveInputCapture,
dictationMode,
dictationEnabled,
bindCommandField,
handleLiveInputChange,
handleLiveInputKeyPress,
bindLiveInputField,
submitLiveInput,
canSend,
canCompose,
liveInputEnabled,
dictation,
cancelDictation,
handleDictationToggle,
handleDictationPressIn,
handleDictationPressOut,
handleSend,
isAttaching,
attachImage,
keyboardLift
} = controller
return (
sessionCommandDockShown(controller) && (
<View
style={[
styles.commandDock,
{ paddingBottom: insets.bottom, transform: [{ translateY: -keyboardLift }] }
]}
>
{}
{keysInRail ? null : <MobileSessionAccessoryKeys controller={controller} />}
{}
{liveInputEnabled ? (
<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}
/>
</View>
<MobileTerminalInputActions
canSend={canSend}
isAttaching={isAttaching}
dictation={dictation}
dictationMode={dictationMode}
dictationEnabled={dictationEnabled}
buttonStyle={styles.dictationButton}
activeButtonStyle={styles.dictationButtonActive}
disabledButtonStyle={styles.sendButtonDisabled}
onAttachImage={() => void attachImage('library')}
onAttachFile={() => void attachImage('files')}
onDictationToggle={handleDictationToggle}
onDictationPressIn={handleDictationPressIn}
onDictationPressOut={handleDictationPressOut}
onDictationCancel={cancelDictation}
/>
</View>
) : (
<View style={styles.inputBar}>
<TextInput
ref={bindCommandField}
// Why: Android caches IME inputType at mount, so toggling autocomplete must remount there; iOS updates in place.
key={
hostOs() === 'android'
? autocompleteEnabled
? 'cmd-input-ac-on'
: 'cmd-input-ac-off'
: 'cmd-input'
}
style={styles.textInput}
value={bufferedTerminalDraftState.input}
// Why: iOS kills active dictation/IME if JS writes a value differing from native text; store raw, normalize at send.
onChangeText={bufferedTerminalDraftState.setInput}
placeholder="Type a command…"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={autocompleteEnabled}
spellCheck={autocompleteEnabled}
smartInsertDelete={false}
// Why: not autofill content, but keyboard must stay default so non-Latin IMEs remain selectable.
autoComplete="off"
keyboardType={getTerminalCommandKeyboardType(hostOs(), autocompleteEnabled)}
returnKeyType="send"
blurOnSubmit={false}
// Why: composing is local — an outage must not lock the field or discard typed text (#6713).
editable={canCompose}
onSubmitEditing={() => void handleSend()}
/>
<MobileTerminalInputActions
canSend={canSend}
isAttaching={isAttaching}
dictation={dictation}
dictationMode={dictationMode}
dictationEnabled={dictationEnabled}
buttonStyle={styles.dictationButton}
activeButtonStyle={styles.dictationButtonActive}
disabledButtonStyle={styles.sendButtonDisabled}
onAttachImage={() => void attachImage('library')}
onAttachFile={() => void attachImage('files')}
onDictationToggle={handleDictationToggle}
onDictationPressIn={handleDictationPressIn}
onDictationPressOut={handleDictationPressOut}
onDictationCancel={cancelDictation}
/>
<Pressable
style={[styles.sendButton, !canSend && styles.sendButtonDisabled]}
disabled={!canSend}
onPress={() => void handleSend()}
accessibilityLabel="Send command"
>
<ArrowUp size={18} color={colors.textSecondary} strokeWidth={2.5} />
</Pressable>
</View>
)}
</View>
)
)
}