mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Keep Android terminal keyboard input stable (#2830)
* fix(app): stabilize native terminal keyboard input Terminal focus requests blurred and remounted the hidden input even when the software keyboard was already visible, causing visible IME cycles and leaving clipboard commits coupled to stale native text. Keep focus requests idempotent while resetting only the native input buffer. * test(app): remove misleading keyboard device scenario Agent Device cannot durably assert software-keyboard continuity or commit a keyboard clipboard suggestion, so the scenario overstated coverage already owned by focused input-policy tests. * fix(app): derive terminal keyboard focus from visibility Use the keyboard-controller inset as the source of truth so a system-dismissed keyboard cannot leave focus activation suppressed. Avoid a duplicate focus request while explicitly showing the keyboard. * fix(app): track terminal keyboard visibility across layouts Keep keyboard visibility independent from compact-only terminal padding so tablet taps remain idempotent while the software keyboard is open.
This commit is contained in:
@@ -34,6 +34,7 @@ export interface TerminalEmulatorProps {
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
keyboardInset?: number;
|
||||
isKeyboardVisible?: boolean;
|
||||
swipeGesturesEnabled?: boolean;
|
||||
onSwipeLeft?: () => void;
|
||||
onSwipeRight?: () => void;
|
||||
|
||||
@@ -168,6 +168,7 @@ function NativeTerminalEmulator({
|
||||
fontFamily,
|
||||
fontSize,
|
||||
keyboardInset = 0,
|
||||
isKeyboardVisible = false,
|
||||
initialSnapshot = null,
|
||||
onInput,
|
||||
onTerminalKey,
|
||||
@@ -993,6 +994,7 @@ function NativeTerminalEmulator({
|
||||
{terminalGrid}
|
||||
<TerminalInput
|
||||
ref={inputRef}
|
||||
isKeyboardVisible={isKeyboardVisible}
|
||||
onFocus={handleTerminalFocus}
|
||||
onInput={handleTerminalInput}
|
||||
onTerminalKey={handleNativeTerminalKey}
|
||||
|
||||
@@ -106,6 +106,7 @@ interface TerminalEmulatorProps {
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
keyboardInset?: number;
|
||||
isKeyboardVisible?: boolean;
|
||||
swipeGesturesEnabled?: boolean;
|
||||
onSwipeLeft?: () => void;
|
||||
onSwipeRight?: () => void;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<TerminalInputHandle, TerminalInputProps>(
|
||||
function TerminalInput({ onFocus, onInput, onTerminalKey, style }, ref) {
|
||||
function TerminalInput({ isKeyboardVisible, onFocus, onInput, onTerminalKey, style }, ref) {
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const isFocusedRef = useRef(false);
|
||||
const pendingFocusFrameRef = useRef<number | null>(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<TerminalInputHandle, TerminalInputProps>
|
||||
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<TerminalInputHandle, TerminalInputProps>
|
||||
}, [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<TerminalInputHandle, TerminalInputProps>
|
||||
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<TerminalInputHandle, TerminalInputProps>
|
||||
onInput?.(change.data);
|
||||
}
|
||||
if (change.shouldClear) {
|
||||
inputState.reset();
|
||||
shouldRefocusAfterClearRef.current = true;
|
||||
setInputEpoch((current) => current + 1);
|
||||
resetNativeInput();
|
||||
}
|
||||
},
|
||||
[inputState, onInput, onTerminalKey],
|
||||
[inputState, onInput, onTerminalKey, resetNativeInput],
|
||||
);
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
key={inputEpoch}
|
||||
ref={inputRef}
|
||||
accessibilityLabel="Terminal input"
|
||||
accessible={true}
|
||||
|
||||
Reference in New Issue
Block a user