diff --git a/packages/app/src/components/terminal-emulator-contract.ts b/packages/app/src/components/terminal-emulator-contract.ts index 4db0b8eb4..5f2d3eb3a 100644 --- a/packages/app/src/components/terminal-emulator-contract.ts +++ b/packages/app/src/components/terminal-emulator-contract.ts @@ -34,6 +34,7 @@ export interface TerminalEmulatorProps { fontFamily?: string; fontSize?: number; keyboardInset?: number; + isKeyboardVisible?: boolean; swipeGesturesEnabled?: boolean; onSwipeLeft?: () => void; onSwipeRight?: () => void; diff --git a/packages/app/src/components/terminal-emulator-native-grid.native.tsx b/packages/app/src/components/terminal-emulator-native-grid.native.tsx index 59957c528..311528bda 100644 --- a/packages/app/src/components/terminal-emulator-native-grid.native.tsx +++ b/packages/app/src/components/terminal-emulator-native-grid.native.tsx @@ -168,6 +168,7 @@ function NativeTerminalEmulator({ fontFamily, fontSize, keyboardInset = 0, + isKeyboardVisible = false, initialSnapshot = null, onInput, onTerminalKey, @@ -993,6 +994,7 @@ function NativeTerminalEmulator({ {terminalGrid} void; onSwipeRight?: () => void; diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index f5a88cfde..fd5dd6992 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -222,6 +222,7 @@ export function TerminalPane({ enabled: isMobile, }); const [keyboardInset, setKeyboardInset] = useState(0); + const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); @@ -298,8 +299,8 @@ export function TerminalPane({ }, [keyboardInset, refreshClipboardAvailability]); useEffect(() => { - setIsKeyboardToggleVisible(keyboardInset > 0); - }, [keyboardInset]); + setIsKeyboardToggleVisible(isKeyboardVisible); + }, [isKeyboardVisible]); const handleSelectionChange = useCallback((nextHasSelection: boolean) => { setHasSelection(nextHasSelection); @@ -418,12 +419,13 @@ export function TerminalPane({ ); }, [clearKeyboardRefitTimeouts, requestTerminalReflow]); - const handleKeyboardInsetChange = useCallback( - (nextInset: number) => { - setKeyboardInset(nextInset); + const handleKeyboardChange = useCallback( + (nextShift: number) => { + setKeyboardInset(isMobile ? nextShift : 0); + setIsKeyboardVisible(nextShift > 0); pulseKeyboardRefits(); }, - [pulseKeyboardRefits], + [isMobile, pulseKeyboardRefits], ); useEffect(() => { @@ -431,14 +433,14 @@ export function TerminalPane({ }, [clearKeyboardRefitTimeouts]); useAnimatedReaction( - () => (isMobile ? Math.round(keyboardShift.value) : 0), + () => Math.round(keyboardShift.value), (next, prev) => { if (next === prev) { return; } - runOnJS(handleKeyboardInsetChange)(next); + runOnJS(handleKeyboardChange)(next); }, - [isMobile, handleKeyboardInsetChange], + [handleKeyboardChange], ); useEffect(() => { @@ -842,9 +844,8 @@ export function TerminalPane({ setIsKeyboardToggleVisible(true); emulatorRef.current?.showKeyboard(); - requestTerminalFocus(); requestTerminalReflow(); - }, [isKeyboardToggleVisible, requestTerminalFocus, requestTerminalReflow]); + }, [isKeyboardToggleVisible, requestTerminalReflow]); const handleInputModeChange = useCallback((state: TerminalInputModeState) => { inputModeRef.current = state; @@ -1014,6 +1015,7 @@ export function TerminalPane({ fontFamily={terminalFontFamily} fontSize={settings.codeFontSize} keyboardInset={keyboardInset} + isKeyboardVisible={isKeyboardVisible} swipeGesturesEnabled={swipeGesturesEnabled} initialSnapshot={initialSnapshot} onRendererReadyChange={handleRendererReadyChange} diff --git a/packages/app/src/terminal/native-renderer/terminal-input.native.test.ts b/packages/app/src/terminal/native-renderer/terminal-input.native.test.ts index cbfa46114..78f3c934a 100644 --- a/packages/app/src/terminal/native-renderer/terminal-input.native.test.ts +++ b/packages/app/src/terminal/native-renderer/terminal-input.native.test.ts @@ -30,7 +30,7 @@ describe("native terminal typed input", () => { expect(input.receiveTextChange("npm run typecheck")).toEqual({ data: "npm run typecheck", - shouldClear: false, + shouldClear: true, }); }); @@ -94,10 +94,9 @@ describe("native terminal typed input", () => { it("clears accumulated hidden input text after terminal submit", () => { const input = createTerminalTextInputState(); - expect(input.receiveTextChange("echo hello")).toEqual({ - data: "echo hello", - shouldClear: false, - }); + for (const key of "echo hello") { + input.receiveKeyPress(key); + } expect(input.receiveKeyPress("Enter")).toEqual({ data: "\r", shouldClear: true }); input.reset(); @@ -109,10 +108,9 @@ describe("native terminal typed input", () => { it("ignores late newline text change after Return keypress submits", () => { const input = createTerminalTextInputState(); - expect(input.receiveTextChange("echo hello")).toEqual({ - data: "echo hello", - shouldClear: false, - }); + for (const key of "echo hello") { + input.receiveKeyPress(key); + } expect(input.receiveKeyPress("Enter")).toEqual({ data: "\r", shouldClear: true }); input.reset(); @@ -120,12 +118,15 @@ describe("native terminal typed input", () => { expect(input.receiveTextChange("echo hello\n")).toEqual({ data: "", shouldClear: false }); }); - it("keeps long paste-like input intact until terminal submit", () => { + it("accepts the same long keyboard clipboard item twice", () => { const input = createTerminalTextInputState(); const longText = "x".repeat(512); - expect(input.receiveTextChange(longText)).toEqual({ data: longText, shouldClear: false }); - expect(input.receiveTextChange(`${longText}y`)).toEqual({ data: "y", shouldClear: false }); + expect(input.receiveTextChange(longText)).toEqual({ data: longText, shouldClear: true }); + + input.reset(); + + expect(input.receiveTextChange(longText)).toEqual({ data: longText, shouldClear: true }); }); it("translates software keyboard terminal control keys", () => { @@ -179,24 +180,31 @@ describe("native terminal typed input", () => { }); }); - it("refocuses an already-focused hidden input without relying on software-keyboard state", () => { - expect(resolveTerminalInputFocusRequest({ isInputFocused: true })).toEqual("refocus"); - expect(resolveTerminalInputFocusRequest({ isInputFocused: false })).toEqual("focus"); + it("does not disturb an already-focused input while the keyboard is visible", () => { + expect( + resolveTerminalInputFocusRequest({ isInputFocused: true, isKeyboardVisible: true }), + ).toEqual("none"); + expect( + resolveTerminalInputFocusRequest({ isInputFocused: true, isKeyboardVisible: false }), + ).toEqual("refocus"); + expect( + resolveTerminalInputFocusRequest({ isInputFocused: false, isKeyboardVisible: false }), + ).toEqual("focus"); }); - it("accepts fresh printable text after keyboard-hide reset and tap refocus", () => { + it("accepts the same keyboard clipboard item after terminal focus synchronization", () => { const input = createTerminalTextInputState(); - expect(input.receiveTextChange("echo stale")).toEqual({ - data: "echo stale", - shouldClear: false, + expect(input.receiveTextChange("echo clipboard item")).toEqual({ + data: "echo clipboard item", + shouldClear: true, }); input.reset(); - expect(input.receiveTextChange("printf 'M1_TAP_OK\\n'")).toEqual({ - data: "printf 'M1_TAP_OK\\n'", - shouldClear: false, + expect(input.receiveTextChange("echo clipboard item")).toEqual({ + data: "echo clipboard item", + shouldClear: true, }); }); }); diff --git a/packages/app/src/terminal/native-renderer/terminal-input.native.tsx b/packages/app/src/terminal/native-renderer/terminal-input.native.tsx index 24932c373..6aa2f7020 100644 --- a/packages/app/src/terminal/native-renderer/terminal-input.native.tsx +++ b/packages/app/src/terminal/native-renderer/terminal-input.native.tsx @@ -1,12 +1,4 @@ -import { - forwardRef, - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState, -} from "react"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react"; import { StyleSheet, TextInput, @@ -32,7 +24,7 @@ export interface TerminalTextInputState { reset: () => void; } -export type TerminalInputFocusRequest = "focus" | "refocus"; +export type TerminalInputFocusRequest = "focus" | "refocus" | "none"; export interface TerminalInputHandle { focus: () => void; @@ -41,6 +33,7 @@ export interface TerminalInputHandle { } interface TerminalInputProps { + isKeyboardVisible: boolean; onFocus?: () => void; onInput?: (data: string) => void; onTerminalKey?: (key: NativeTerminalKey) => void; @@ -53,8 +46,12 @@ function isPrintableKey(key: string): boolean { export function resolveTerminalInputFocusRequest(input: { isInputFocused: boolean; + isKeyboardVisible: boolean; }): TerminalInputFocusRequest { - return input.isInputFocused ? "refocus" : "focus"; + if (!input.isInputFocused) { + return "focus"; + } + return input.isKeyboardVisible ? "none" : "refocus"; } export function createTerminalTextInputState(): TerminalTextInputState { @@ -110,7 +107,10 @@ export function createTerminalTextInputState(): TerminalTextInputState { previousText = text; return { data: appendedText, - shouldClear: false, + // Treat multi-character IME commits as complete units. Clearing lets + // the keyboard commit the same clipboard item again without needing + // a different intermediate value. + shouldClear: appendedText.length > 1, }; }, reset(): void { @@ -120,14 +120,12 @@ export function createTerminalTextInputState(): TerminalTextInputState { } export const TerminalInput = forwardRef( - function TerminalInput({ onFocus, onInput, onTerminalKey, style }, ref) { + function TerminalInput({ isKeyboardVisible, onFocus, onInput, onTerminalKey, style }, ref) { const inputRef = useRef(null); const isFocusedRef = useRef(false); const pendingFocusFrameRef = useRef(null); - const shouldRefocusAfterClearRef = useRef(false); const inputState = useMemo(() => createTerminalTextInputState(), []); const inputStyle = useMemo(() => [styles.input, style], [style]); - const [inputEpoch, setInputEpoch] = useState(0); const clearPendingFocus = useCallback(() => { if (pendingFocusFrameRef.current === null) { @@ -149,15 +147,32 @@ export const TerminalInput = forwardRef return; } + // Keep the native IME buffer aligned with the terminal. Some keyboards + // do not emit a text change when a clipboard item replaces identical + // stale input, so clear the buffer even when focus is already correct. + resetNativeInput(); + + const focusRequest = resolveTerminalInputFocusRequest({ + isInputFocused: isFocusedRef.current || input.isFocused(), + isKeyboardVisible, + }); + if (focusRequest === "none") { + return; + } + + if (focusRequest === "focus") { + input.focus(); + return; + } + input.blur(); isFocusedRef.current = false; - resetNativeInput(); input.focus(); pendingFocusFrameRef.current = requestAnimationFrame(() => { pendingFocusFrameRef.current = null; inputRef.current?.focus(); }); - }, [clearPendingFocus, resetNativeInput]); + }, [clearPendingFocus, isKeyboardVisible, resetNativeInput]); const blurNativeInput = useCallback(() => { clearPendingFocus(); @@ -167,38 +182,11 @@ export const TerminalInput = forwardRef }, [clearPendingFocus, resetNativeInput]); const focusNativeInput = useCallback(() => { - clearPendingFocus(); - const input = inputRef.current; - if (!input) { - return; - } - - const focusRequest = resolveTerminalInputFocusRequest({ - isInputFocused: isFocusedRef.current || input.isFocused(), - }); - if (focusRequest === "focus") { - input.focus(); - return; - } - showNativeKeyboard(); - }, [clearPendingFocus, showNativeKeyboard]); + }, [showNativeKeyboard]); useEffect(() => clearPendingFocus, [clearPendingFocus]); - useEffect(() => { - if (!shouldRefocusAfterClearRef.current) { - return; - } - shouldRefocusAfterClearRef.current = false; - const frame = requestAnimationFrame(() => { - inputRef.current?.focus(); - }); - return () => { - cancelAnimationFrame(frame); - }; - }, [inputEpoch]); - useImperativeHandle( ref, () => ({ @@ -232,12 +220,10 @@ export const TerminalInput = forwardRef onInput?.(change.data); } if (change.shouldClear) { - inputState.reset(); - shouldRefocusAfterClearRef.current = true; - setInputEpoch((current) => current + 1); + resetNativeInput(); } }, - [inputState, onInput], + [inputState, onInput, resetNativeInput], ); const handleKeyPress = useCallback( @@ -250,17 +236,14 @@ export const TerminalInput = forwardRef onInput?.(change.data); } if (change.shouldClear) { - inputState.reset(); - shouldRefocusAfterClearRef.current = true; - setInputEpoch((current) => current + 1); + resetNativeInput(); } }, - [inputState, onInput, onTerminalKey], + [inputState, onInput, onTerminalKey, resetNativeInput], ); return (