fix(app): expose terminal controls on native tablets (#5712)

Wide native panes previously followed the compact-layout gate, hiding terminal keys and skipping software-keyboard clearance. Keep bar eligibility tied to native touch while sizing rows from the available pane width.

Co-authored-by: Leandro Ardissone <lardissone@gmail.com>
This commit is contained in:
Mohamed Boudra
2026-09-30 13:56:14 +01:00
committed by GitHub
co-authored by Leandro Ardissone
parent ad56659a89
commit 2553ab4d82
7 changed files with 248 additions and 11 deletions
@@ -0,0 +1,24 @@
context platform=android timeout=90000 retries=1
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-new-tab-button\"" 45000
press "id=\"workspace-new-tab-button\""
wait "id=\"workspace-new-tab-menu-terminal\"" 10000
press "id=\"workspace-new-tab-menu-terminal\""
wait "id=\"terminal-key-esc\"" 10000
is visible "id=\"terminal-virtual-keyboard-native-wide\""
is visible "id=\"terminal-key-esc\""
is visible "id=\"terminal-key-ctrl\""
is visible "id=\"terminal-key-enter\""
is visible "id=\"terminal-paste\""
press "id=\"terminal-keyboard-toggle\""
type "printf '__AD_%s__\\n' NATIVE_WIDE_KEY_OK" --delay-ms 0
press "id=\"terminal-key-enter\""
wait "text" "__AD_NATIVE_WIDE_KEY_OK__" 10000
press "id=\"terminal-key-ctrl\""
type "c" --delay-ms 0
type "printf '__AD_%s__\\n' MODIFIER_RESET_OK" --delay-ms 0
press "id=\"terminal-key-enter\""
wait "text" "__AD_MODIFIER_RESET_OK__" 10000
close
@@ -0,0 +1,17 @@
context platform=ios timeout=90000 retries=1
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-new-tab-button\"" 30000
press "id=\"workspace-new-tab-button\""
wait "id=\"workspace-new-tab-menu-terminal\"" 10000
press "id=\"workspace-new-tab-menu-terminal\""
wait "id=\"terminal-virtual-keyboard-native-wide\"" 10000
is visible "id=\"terminal-key-esc\""
is visible "id=\"terminal-key-enter\""
is visible "id=\"terminal-paste\""
press "id=\"terminal-keyboard-toggle\""
type "printf '__AD_IOS_%s__\\n' KEY_OK" --delay-ms 0
press "id=\"terminal-key-enter\""
wait "text" "__AD_IOS_KEY_OK__" 10000
close
@@ -0,0 +1,40 @@
appId: sh.paseo.debug
---
- launchApp
- runFlow:
when:
visible: ".*getRegistrationInfoAsync.*"
commands:
- tapOn: ".*getRegistrationInfoAsync.*"
- tapOn: Dismiss
- assertNotVisible: ".*getRegistrationInfoAsync.*"
- extendedWaitUntil:
visible:
id: workspace-new-tab-button
timeout: 30000
- tapOn:
id: workspace-new-tab-button
- tapOn:
id: workspace-new-tab-menu-terminal
- extendedWaitUntil:
visible:
id: terminal-virtual-keyboard-native-wide
timeout: 10000
- assertVisible:
id: terminal-key-esc
- assertVisible:
id: terminal-key-enter
- assertVisible:
id: terminal-paste
- tapOn:
id: terminal-paste
- runFlow:
when:
visible: Allow Paste
commands:
- tapOn: Allow Paste
- tapOn:
id: terminal-key-enter
- extendedWaitUntil:
visible: __AD_IOS_PASTE_OK__
timeout: 10000
@@ -0,0 +1,18 @@
# Native wide terminal device flows
Run these against a connected task-owned tablet with the current development app, Metro bundle, and a workspace whose terminal opens successfully. The flows assert `terminal-virtual-keyboard-native-wide`, which is emitted only for a native noncompact window. That assertion makes a compact phone fail before any PTY success marker. A landscape phone that reaches the noncompact breakpoint is excluded by the explicit tablet `--device` argument; do not rely on the runner's default device.
Android Pixel Tablet API 36.1:
```sh
agent-device replay packages/app/e2e/mobile/agent-device/native-terminal-wide-keybar.android.ad --platform android --device paseo-tablet-p1-api36
```
iPad Pro 11-inch (M5), iOS 26.5: from the Namespace Mac checkout, prefill the simulator clipboard with the command below, then run the Maestro compatibility flow. Its `runFlow.when.visible` presses **Allow Paste** only if the iOS sheet appears; every other action and output assertion remains strict. The clipboard payload contains `PASTE_OK`, while only executed PTY output contains `__AD_IOS_PASTE_OK__`.
```sh
printf '%s' "printf '__AD_IOS_%s__\\n' PASTE_OK" | xcrun simctl pbcopy E49C88C3-9A60-4715-B63A-881498D1991A
agent-device replay packages/app/e2e/mobile/agent-device/native-terminal-wide-paste.ios.yaml --maestro --platform ios --device 'iPad Pro 11-inch (M5)'
```
The unsigned simulator build used for phase 1 raises a known `getRegistrationInfoAsync` entitlement overlay on launch. The flow dismisses only that exact development error and asserts it is gone before touching terminal controls; another overlay still fails the flow. `native-terminal-wide-keybar.ios.ad` separately proves that the wide iPad bar sends Enter to the PTY. It needs no clipboard permission. The Maestro flow above is the paste gate for both first-run and already-granted states.
+51 -11
View File
@@ -4,7 +4,13 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as Clipboard from "expo-clipboard";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import {
Pressable,
Text,
View,
type LayoutChangeEvent,
type PressableStateCallbackType,
} from "react-native";
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Keyboard as KeyboardIcon, KeyboardOff as KeyboardOffIcon } from "lucide-react-native";
@@ -31,9 +37,10 @@ import {
} from "@/terminal/runtime/terminal-key-dispatch";
import {
getTerminalVirtualKeyboardControlId,
resolveTerminalVirtualKeyboardRows,
shouldShowTerminalVirtualKeyBar,
shouldShowTerminalFloatingCopyAction,
shouldShowTerminalPasteAction,
TERMINAL_VIRTUAL_KEYBOARD_ROWS,
type TerminalVirtualKeyboardControl,
} from "@/terminal/runtime/terminal-virtual-keyboard";
import { pasteTerminalClipboard } from "@/terminal/runtime/terminal-paste";
@@ -93,6 +100,7 @@ interface TerminalPaneProps {
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
const TERMINAL_RESIZE_DEBOUNCE_MS = 100;
const TERMINAL_KEY_BAR_MAX_WIDTH = 880;
const MODIFIER_LABELS = {
ctrl: "Ctrl",
@@ -138,8 +146,16 @@ function ModifierButton({ modifier, active, onToggle }: ModifierButtonProps) {
() => [styles.keyButtonText, active && styles.keyButtonTextActive],
[active],
);
const accessibilityState = useMemo(() => ({ selected: active }), [active]);
return (
<Pressable testID={`terminal-key-${modifier}`} onPress={handlePress} style={pressableStyle}>
<Pressable
accessibilityLabel={MODIFIER_LABELS[modifier]}
accessibilityRole="button"
accessibilityState={accessibilityState}
testID={`terminal-key-${modifier}`}
onPress={handlePress}
style={pressableStyle}
>
<Text style={textStyle}>{MODIFIER_LABELS[modifier]}</Text>
</Pressable>
);
@@ -162,7 +178,13 @@ function VirtualKeyButton({ id, label, keyValue, onSend }: VirtualKeyButtonProps
[],
);
return (
<Pressable testID={`terminal-key-${id}`} onPress={handlePress} style={pressableStyle}>
<Pressable
accessibilityLabel={label}
accessibilityRole="button"
testID={`terminal-key-${id}`}
onPress={handlePress}
style={pressableStyle}
>
<Text style={styles.keyButtonText}>{label}</Text>
</Pressable>
);
@@ -222,12 +244,18 @@ export function TerminalPane({
return trimmed.length > 0 ? trimmed : undefined;
}, [settings.monoFontFamily]);
const isMobile = useIsCompactFormFactor();
const showVirtualKeyBar = shouldShowTerminalVirtualKeyBar({ isNative, isCompact: isMobile });
const [keyBarWidth, setKeyBarWidth] = useState(0);
const virtualKeyboardRows = resolveTerminalVirtualKeyboardRows({
isCompact: isMobile,
availableWidth: keyBarWidth,
});
const mobileView = usePanelStore((state) => state.mobilePanel.target);
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
const swipeGesturesEnabled = isMobile;
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({
mode: "padding",
enabled: isMobile,
enabled: isNative || isMobile,
});
const [keyboardInset, setKeyboardInset] = useState(0);
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
@@ -292,7 +320,7 @@ export function TerminalPane({
}, [terminalId]);
const refreshClipboardAvailability = useCallback(async () => {
if (!isMobile) {
if (!showVirtualKeyBar) {
setHasClipboardText(false);
return;
}
@@ -302,7 +330,7 @@ export function TerminalPane({
} catch {
setHasClipboardText(false);
}
}, [isMobile]);
}, [showVirtualKeyBar]);
useEffect(() => {
void refreshClipboardAvailability();
@@ -436,7 +464,7 @@ export function TerminalPane({
const handleKeyboardChange = useCallback(
(nextShift: number) => {
setKeyboardInset(isMobile ? nextShift : 0);
setKeyboardInset(isNative || isMobile ? nextShift : 0);
setIsKeyboardVisible(nextShift > 0);
pulseKeyboardRefits();
},
@@ -955,6 +983,11 @@ export function TerminalPane({
[keyboardPaddingStyle],
);
const handleKeyBarLayout = useCallback((event: LayoutChangeEvent) => {
const width = event.nativeEvent.layout.width;
if (width > 0) setKeyBarWidth(width);
}, []);
const handleSwipeRight = useCallback(() => {
if (!swipeGesturesEnabled) return;
emulatorRef.current?.blur();
@@ -1099,10 +1132,14 @@ export function TerminalPane({
</View>
) : null}
{isMobile ? (
{showVirtualKeyBar ? (
<View style={styles.keyboardContainer} testID="terminal-virtual-keyboard">
<View style={styles.keyboardRows}>
{TERMINAL_VIRTUAL_KEYBOARD_ROWS.map((row) => (
<View
style={styles.keyboardRows}
onLayout={handleKeyBarLayout}
testID={isNative && !isMobile ? "terminal-virtual-keyboard-native-wide" : undefined}
>
{virtualKeyboardRows.map((row) => (
<View
key={row.map(getTerminalVirtualKeyboardControlId).join(":")}
style={styles.keyboardRow}
@@ -1165,6 +1202,9 @@ const styles = StyleSheet.create((theme) => ({
},
keyboardRows: {
gap: theme.spacing[1],
width: "100%",
maxWidth: TERMINAL_KEY_BAR_MAX_WIDTH,
alignSelf: "center",
},
keyboardRow: {
flexDirection: "row",
@@ -2,9 +2,13 @@ import { describe, expect, it } from "vitest";
import {
getTerminalVirtualKeyboardControlId,
resolveTerminalVirtualKeyboardRows,
shouldShowTerminalVirtualKeyBar,
shouldShowTerminalFloatingCopyAction,
shouldShowTerminalPasteAction,
TERMINAL_VIRTUAL_KEYBOARD_ROWS,
TERMINAL_VIRTUAL_KEYBOARD_THREE_COLUMN_ROWS,
TERMINAL_VIRTUAL_KEYBOARD_WIDE_ROWS,
type TerminalVirtualKeyboardControl,
} from "./terminal-virtual-keyboard";
@@ -41,6 +45,38 @@ function controlsByType(
}
describe("terminal virtual keyboard policy", () => {
it("keeps terminal controls on wide native touch screens with either IME state", () => {
expect(shouldShowTerminalVirtualKeyBar({ isNative: true, isCompact: false })).toBe(true);
expect(shouldShowTerminalVirtualKeyBar({ isNative: true, isCompact: true })).toBe(true);
expect(shouldShowTerminalVirtualKeyBar({ isNative: false, isCompact: true })).toBe(true);
expect(shouldShowTerminalVirtualKeyBar({ isNative: false, isCompact: false })).toBe(false);
});
it("selects the same controls in narrow and wide row arrangements", () => {
const ids = (rows: readonly (readonly TerminalVirtualKeyboardControl[])[]) =>
rows.flatMap((row) => row.map(getTerminalVirtualKeyboardControlId));
expect([...ids(TERMINAL_VIRTUAL_KEYBOARD_WIDE_ROWS)].sort()).toEqual(
[...ids(TERMINAL_VIRTUAL_KEYBOARD_ROWS)].sort(),
);
expect([...ids(TERMINAL_VIRTUAL_KEYBOARD_THREE_COLUMN_ROWS)].sort()).toEqual(
[...ids(TERMINAL_VIRTUAL_KEYBOARD_ROWS)].sort(),
);
expect(resolveTerminalVirtualKeyboardRows({ isCompact: false, availableWidth: 0 })).toBe(
TERMINAL_VIRTUAL_KEYBOARD_ROWS,
);
expect(resolveTerminalVirtualKeyboardRows({ isCompact: false, availableWidth: 515 })).toBe(
TERMINAL_VIRTUAL_KEYBOARD_ROWS,
);
expect(resolveTerminalVirtualKeyboardRows({ isCompact: false, availableWidth: 160 })).toBe(
TERMINAL_VIRTUAL_KEYBOARD_THREE_COLUMN_ROWS,
);
expect(resolveTerminalVirtualKeyboardRows({ isCompact: false, availableWidth: 698 })).toBe(
TERMINAL_VIRTUAL_KEYBOARD_WIDE_ROWS,
);
expect(resolveTerminalVirtualKeyboardRows({ isCompact: true, availableWidth: 700 })).toBe(
TERMINAL_VIRTUAL_KEYBOARD_ROWS,
);
});
it("does not expose redundant Space or Backspace controls", () => {
expect(controlIds()).toEqual([
"terminal-key-esc",
@@ -41,6 +41,68 @@ export const TERMINAL_VIRTUAL_KEYBOARD_ROWS = [
],
] as const satisfies readonly (readonly TerminalVirtualKeyboardControl[])[];
export const TERMINAL_VIRTUAL_KEYBOARD_WIDE_ROWS = [
[
{ type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.esc },
{ type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.tab },
{ type: "modifier", modifier: "ctrl" },
{ type: "modifier", modifier: "alt" },
{ type: "modifier", modifier: "shift" },
{ type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.left },
{ type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.down },
{ type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.up },
{ type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.right },
{ type: "paste" },
{ type: "keyboardToggle" },
{ type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.enter },
],
] as const satisfies readonly (readonly TerminalVirtualKeyboardControl[])[];
export const TERMINAL_VIRTUAL_KEYBOARD_THREE_COLUMN_ROWS = [
[
{ type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.esc },
{ type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.tab },
{ type: "modifier", modifier: "ctrl" },
],
[
{ type: "modifier", modifier: "alt" },
{ type: "modifier", modifier: "shift" },
{ type: "keyboardToggle" },
],
[
{ type: "paste" },
{ type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.up },
{ type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.enter },
],
[
{ type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.left },
{ type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.down },
{ type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.right },
],
] as const satisfies readonly (readonly TerminalVirtualKeyboardControl[])[];
const TERMINAL_KEY_BAR_SINGLE_ROW_MIN_WIDTH = 660;
const TERMINAL_KEY_BAR_THREE_COLUMN_MAX_WIDTH = 300;
export function resolveTerminalVirtualKeyboardRows(input: {
isCompact: boolean;
availableWidth: number;
}) {
if (input.availableWidth > 0 && input.availableWidth < TERMINAL_KEY_BAR_THREE_COLUMN_MAX_WIDTH) {
return TERMINAL_VIRTUAL_KEYBOARD_THREE_COLUMN_ROWS;
}
return !input.isCompact && input.availableWidth >= TERMINAL_KEY_BAR_SINGLE_ROW_MIN_WIDTH
? TERMINAL_VIRTUAL_KEYBOARD_WIDE_ROWS
: TERMINAL_VIRTUAL_KEYBOARD_ROWS;
}
export function shouldShowTerminalVirtualKeyBar(input: {
isNative: boolean;
isCompact: boolean;
}): boolean {
return input.isNative || input.isCompact;
}
export function getTerminalVirtualKeyboardControlId(
control: TerminalVirtualKeyboardControl,
): string {