diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-wide-keybar.android.ad b/packages/app/e2e/mobile/agent-device/native-terminal-wide-keybar.android.ad new file mode 100644 index 0000000000..af7e25995e --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-terminal-wide-keybar.android.ad @@ -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 diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-wide-keybar.ios.ad b/packages/app/e2e/mobile/agent-device/native-terminal-wide-keybar.ios.ad new file mode 100644 index 0000000000..31c4bba393 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-terminal-wide-keybar.ios.ad @@ -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 diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-wide-paste.ios.yaml b/packages/app/e2e/mobile/agent-device/native-terminal-wide-paste.ios.yaml new file mode 100644 index 0000000000..2cf92faf3c --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-terminal-wide-paste.ios.yaml @@ -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 diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-wide-preconditions.md b/packages/app/e2e/mobile/agent-device/native-terminal-wide-preconditions.md new file mode 100644 index 0000000000..c19cedaaf9 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-terminal-wide-preconditions.md @@ -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. diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 9249463197..31c36b607c 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -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 ( - + {MODIFIER_LABELS[modifier]} ); @@ -162,7 +178,13 @@ function VirtualKeyButton({ id, label, keyValue, onSend }: VirtualKeyButtonProps [], ); return ( - + {label} ); @@ -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({ ) : null} - {isMobile ? ( + {showVirtualKeyBar ? ( - - {TERMINAL_VIRTUAL_KEYBOARD_ROWS.map((row) => ( + + {virtualKeyboardRows.map((row) => ( ({ }, keyboardRows: { gap: theme.spacing[1], + width: "100%", + maxWidth: TERMINAL_KEY_BAR_MAX_WIDTH, + alignSelf: "center", }, keyboardRow: { flexDirection: "row", diff --git a/packages/app/src/terminal/runtime/terminal-virtual-keyboard.test.ts b/packages/app/src/terminal/runtime/terminal-virtual-keyboard.test.ts index b8541bcfe5..d2ef76fefd 100644 --- a/packages/app/src/terminal/runtime/terminal-virtual-keyboard.test.ts +++ b/packages/app/src/terminal/runtime/terminal-virtual-keyboard.test.ts @@ -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", diff --git a/packages/app/src/terminal/runtime/terminal-virtual-keyboard.ts b/packages/app/src/terminal/runtime/terminal-virtual-keyboard.ts index f931881bce..3dd244fa95 100644 --- a/packages/app/src/terminal/runtime/terminal-virtual-keyboard.ts +++ b/packages/app/src/terminal/runtime/terminal-virtual-keyboard.ts @@ -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 {