Keep the mobile terminal keyboard open while typing (#4469)

* fix(app): keep terminal keyboard focus stable

Clearing the terminal buffer remounted the shared native input, triggering an IME hide/show cycle. Keep text replacement in place and make the composer explicitly reset its editor to preserve multiline layout clearing.

* test(app): verify terminal keyboard continuity on Android

Replace the new JSDOM regression with a real Android harness. It detects focused native input replacement and IME hide/show requests through terminal toolbar controls, and fails with the previous remount behavior.
This commit is contained in:
Mohamed Boudra
2026-09-08 09:48:48 +02:00
committed by GitHub
parent 229f3cd5a8
commit 1622da40d9
8 changed files with 185 additions and 22 deletions
+12
View File
@@ -35,6 +35,18 @@ PASEO_MOBILE_E2E_METRO_PORT=62093 npm run test:e2e:mobile
[native-terminal-basic.ios.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad) and [native-terminal-basic.android.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.android.ad) are the smallest examples. Each opens a fresh terminal, types a command at zero delay, submits it, and asserts its distinct output. The app must be connected to a daemon with an active workspace.
For Android keyboard continuity, run the current checkout in the app, open an idle terminal
with an empty prompt, hide its keyboard, and run:
```bash
ANDROID_SERIAL=emulator-5554 node packages/app/e2e/mobile/terminal-keyboard/android.mjs
```
Use a real docked software keyboard. The harness taps Ctrl, Esc, and Enter and checks Android's
focused input identity and IME hide/show events. It saves screenshots and logs under
`.dev/agent-device-artifacts/terminal-keyboard-android`. Set `PASEO_TERMINAL_KEYBOARD_APP_ID=sh.paseo`
to test an installed production build. It never submits a chat message.
When replay diverges, read its ranked selector suggestions. Edit the script deliberately and rerun it from the beginning. `--update` is retained for compatibility but no longer rewrites scripts.
## Maestro compatibility
@@ -0,0 +1,117 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { setTimeout } from "node:timers/promises";
// Open an idle terminal with an empty prompt and hide its keyboard before running.
// The app must already run the checkout/build being verified against a real daemon.
const appId = process.env.PASEO_TERMINAL_KEYBOARD_APP_ID ?? "sh.paseo.debug";
const serial = process.env.ANDROID_SERIAL ?? "emulator-5554";
const artifacts = resolve(".dev/agent-device-artifacts/terminal-keyboard-android");
const session = "terminal-keyboard-android";
const env = {
...process.env,
AGENT_DEVICE_STATE_DIR: resolve(".dev/agent-device-terminal-keyboard"),
};
mkdirSync(artifacts, { recursive: true });
function run(binary, args) {
return execFileSync(binary, args, { env, encoding: "utf8", timeout: 30_000 });
}
function ad(...args) {
return run("agent-device", [...args, "--session", session]);
}
function adb(...args) {
return run("adb", ["-s", serial, ...args]);
}
function keyboardState() {
const state = adb("shell", "dumpsys", "input_method");
const view = state.match(/^ mServedView=(.+)$/m)?.[1] ?? "";
return {
shown: /^ mInputShown=true$/m.test(state),
// The native object identity survives layout/flag changes but not a remount.
input: view.match(/ReactEditText\{(\w+)/)?.[1] ?? null,
};
}
async function waitForKeyboard(shown) {
let stable = 0;
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
const state = keyboardState();
stable = state.shown === shown ? stable + 1 : 0;
if (stable === 3) return state;
await setTimeout(100);
}
throw new Error(`Keyboard did not settle at shown=${shown}`);
}
function keyboardShift() {
const state = adb("shell", "dumpsys", "window");
const ime = state.match(/type=ime frame=\[0,(\d+)\].*visible=true/);
const navigation = state.match(/type=navigationBars frame=\[0,(\d+)\]/);
assert.ok(ime && navigation, "Expected a docked keyboard and navigation bar");
return Number(navigation[1]) - Number(ime[1]);
}
function imeEvents() {
return adb("logcat", "-d", "-v", "threadtime", "ImeTracker:I", "*:S")
.split("\n")
.filter((line) => line.includes(`${appId}:`));
}
function tap(rect, shift) {
ad(
"press",
String(Math.round(rect.x + rect.width / 2)),
String(Math.round(rect.y + rect.height / 2 - shift)),
);
}
let toggle;
try {
ad("open", appId, "--platform", "android", "--serial", serial, "--no-test-ime");
assert.equal(keyboardState().shown, false, "Start with the terminal keyboard hidden");
// Accessibility snapshots can hang with a real IME open. Measure the controls
// while hidden, then project them by Android's actual docked-keyboard inset.
const snapshot = JSON.parse(ad("snapshot", "-i", "--json"));
const rectFor = (id) => {
const node = snapshot.data.nodes.find((candidate) => candidate.identifier === id);
assert.ok(node?.rect, `Open a terminal first: missing ${id}`);
return node.rect;
};
toggle = rectFor("terminal-keyboard-toggle");
const controls = ["ctrl", "ctrl", "esc", "enter"].map((key) => ({
key,
rect: rectFor(`terminal-key-${key}`),
}));
tap(toggle, 0);
const baseline = await waitForKeyboard(true);
assert.notEqual(baseline.input, null, "Expected the native terminal input to own the IME");
const shift = keyboardShift();
const before = new Set(imeEvents());
ad("screenshot", resolve(artifacts, "before.png"));
for (const { key, rect } of controls) {
tap(rect, shift);
const after = await waitForKeyboard(true);
const events = imeEvents().filter((line) => !before.has(line));
writeFileSync(resolve(artifacts, "ime-events.log"), events.join("\n"));
writeFileSync(
resolve(artifacts, "focus.json"),
JSON.stringify({ key, baseline, after }, null, 2),
);
assert.equal(after.input, baseline.input, `${key} replaced the focused native input`);
assert.deepEqual(
events.filter((line) => /onRequestHide|onRequestShow/.test(line)),
[],
`${key} cycled the software keyboard`,
);
}
ad("screenshot", resolve(artifacts, "after.png"));
console.log("PASS: Ctrl, Esc and Enter preserve the native input without IME hide/show requests");
} finally {
if (toggle && keyboardState().shown) {
tap(toggle, keyboardShift());
await waitForKeyboard(false);
}
ad("close");
run("agent-device", ["daemon", "stop", "--clean"]);
}
@@ -92,7 +92,7 @@ describe("EditingTextInputNative", () => {
expect(handleRef.current?.getText()).toBe("");
});
it("replaces the native input when clearing text", () => {
it("replaces the native input when resetting the editor", () => {
const handleRef = createRef<EditingTextInputHandle>();
act(() => {
@@ -107,13 +107,13 @@ describe("EditingTextInputNative", () => {
const grownInput = container?.querySelector("input");
act(() => {
handleRef.current?.replaceText("");
handleRef.current?.reset();
});
expect(container?.querySelector("input")).not.toBe(grownInput);
});
it("restores focus after replacing a cleared native input", () => {
it("restores focus after replacing a reset native input", () => {
const handleRef = createRef<EditingTextInputHandle>();
act(() => {
@@ -125,13 +125,13 @@ describe("EditingTextInputNative", () => {
originalInput.focus();
act(() => {
handleRef.current?.replaceText("");
handleRef.current?.reset();
});
expect(document.activeElement).toBe(container?.querySelector("input"));
});
it("focuses the replacement input when focus is requested before a cleared input remounts", () => {
it("focuses the replacement input when focus is requested before an editor reset remounts", () => {
const handleRef = createRef<EditingTextInputHandle>();
act(() => {
@@ -142,7 +142,7 @@ describe("EditingTextInputNative", () => {
const originalFocus = vi.spyOn(originalInput, "focus");
act(() => {
handleRef.current?.replaceText("");
handleRef.current?.reset();
handleRef.current?.focus();
});
@@ -152,7 +152,7 @@ describe("EditingTextInputNative", () => {
expect(document.activeElement).toBe(replacementInput);
});
it("drops a pending focus restore when blur is requested before a cleared input remounts", () => {
it("drops a pending focus restore when blur is requested before an editor reset remounts", () => {
const handleRef = createRef<EditingTextInputHandle>();
act(() => {
@@ -164,7 +164,7 @@ describe("EditingTextInputNative", () => {
originalInput.focus();
act(() => {
handleRef.current?.replaceText("");
handleRef.current?.reset();
handleRef.current?.blur();
});
@@ -35,7 +35,8 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
const inputRef = useRef<NativeInput | null>(null);
const initialTextRef = useRef(initialValue);
const textRef = useRef(initialTextRef.current);
// Clearing swaps the native input for a fresh instance (see replaceText).
// Resetting the editor swaps the native input to reset intrinsic multiline
// sizing. Text replacement (including an empty buffer) keeps the input mounted.
// Until React commits that swap, `inputRef` still points at the doomed
// instance: focusing it asks Android for the keyboard and then tears the
// focused view down, which cancels the show. Fabric also runs view
@@ -74,27 +75,31 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
getText: () => textRef.current,
replaceText: (nextText, selection) => {
textRef.current = nextText;
if (nextText === "") {
const autoFocus = inputRef.current?.isFocused?.() ?? false;
if (inputRef.current?.replaceText) {
inputRef.current.replaceText(nextText, selection);
} else {
inputRef.current?.clear?.();
}
isAwaitingReplacementRef.current = true;
setReplacement((current) => ({ revision: current.revision + 1, autoFocus }));
return;
}
if (inputRef.current?.replaceText) {
inputRef.current.replaceText(nextText, selection);
return;
}
if (nextText === "") {
inputRef.current?.clear?.();
return;
}
inputRef.current?.setNativeProps?.({
text: nextText,
...(selection ? { selection } : {}),
});
if (selection) inputRef.current?.setSelection?.(selection.start, selection.end);
},
reset: () => {
textRef.current = "";
const autoFocus = inputRef.current?.isFocused?.() ?? false;
if (inputRef.current?.replaceText) {
inputRef.current.replaceText("");
} else {
inputRef.current?.clear?.();
}
isAwaitingReplacementRef.current = true;
setReplacement((current) => ({ revision: current.revision + 1, autoFocus }));
},
getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current,
}));
@@ -75,6 +75,11 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
input.setSelectionRange(selection.start, selection.end);
}
},
reset: () => {
textRef.current = "";
const input = inputRef.current as WebTextInputElement | null;
if (input && "value" in input) input.value = "";
},
getNativeRef: () => inputRef.current,
}));
@@ -7,6 +7,8 @@ export interface EditingTextInputHandle {
isFocused(): boolean;
getText(): string;
replaceText(text: string, selection?: { start: number; end: number }): void;
/** Clear the editor and reset its intrinsic layout, preserving focus intent. */
reset(): void;
getNativeRef(): unknown;
}
+10 -2
View File
@@ -1240,7 +1240,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
valueRef.current = nextText;
updateLiveTextPresence(nextText);
selectionRef.current = selection ?? { start: nextText.length, end: nextText.length };
textInputRef.current?.replaceText(nextText, selection);
if (nextText === "") {
textInputRef.current?.reset();
} else {
textInputRef.current?.replaceText(nextText, selection);
}
onChangeText(nextText);
},
[onChangeText, updateComposerHeightForText, updateLiveTextPresence],
@@ -1292,7 +1296,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
updateComposerHeightForText?.(valueRef.current, textReplacement.text);
valueRef.current = textReplacement.text;
updateLiveTextPresence(textReplacement.text);
textInputRef.current?.replaceText(textReplacement.text);
if (textReplacement.text === "") {
textInputRef.current?.reset();
} else {
textInputRef.current?.replaceText(textReplacement.text);
}
}, [textReplacement, updateComposerHeightForText, updateLiveTextPresence]);
useEffect(() => {
@@ -82,6 +82,20 @@ afterEach(() => {
});
describe("ComposerTextInput web IME composition", () => {
it("resets the composer text while preserving focus", () => {
const mounted = mountInput(ignoreTextChange);
act(() => {
mounted.inputRef.current?.focus();
typeFromIme(mounted.textarea, "line one\nline two");
});
act(() => mounted.inputRef.current?.reset());
expect(mounted.textarea.value).toBe("");
expect(mounted.inputRef.current?.getText()).toBe("");
expect(document.activeElement).toBe(mounted.textarea);
});
it("keeps locally typed text when its parent rerenders with a stale value", () => {
const recorder = createTextRecorder();
const mounted = mountInput(recorder.onChangeText);