Improve mobile terminal input, selection, and resizing (#1607)

* Improve mobile terminal typing and selection

* Address mobile terminal review feedback

* Remove dead native terminal input branch

* Replay bracketed paste in terminal preamble

* Stop per-key terminal resize claims

* Stabilize terminal resize browser test

* Use tracked terminal mode for native paste

* Restore terminal toolbar focus handling

* Adapt native terminal to active visibility state

* Stabilize native mobile terminal interactions

* Add native terminal fallback boundary

* test(app): expect explicit terminal resize reclaim

* fix(app): stabilize terminal buffer coordinates

* fix(app): restrict terminal resize ownership

* fix(terminal): gate restored input modes

* fix(app): recover stalled terminal WebViews

* chore(app): remove obsolete terminal harnesses

* fix(app): make terminal resize claims explicit

* test(app): stabilize native terminal device flows

* test(app): track absolute scrollback coordinates

* fix(app): isolate terminal WebView streams

* fix(terminal): arbitrate size ownership across clients

The daemon now distinguishes explicit ownership claims from passive geometry updates. This keeps the active client authoritative while allowing its split, viewport, and keyboard changes to resize the PTY without idle clients stealing it.

* test(server): make worker resize assertion portable

Query the worker's authoritative terminal snapshot instead of invoking a Unix-only shell command, so the resize regression exercises the same boundary on Windows and Unix.
This commit is contained in:
Mohamed Boudra
2026-08-03 19:01:34 +08:00
committed by GitHub
parent 2948a435ed
commit 04fe30a3e2
119 changed files with 9843 additions and 1032 deletions
+325 -149
View File
@@ -1,11 +1,16 @@
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 Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { encodeTerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import { Keyboard as KeyboardIcon, KeyboardOff as KeyboardOffIcon } from "lucide-react-native";
import type { TerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
import {
DEFAULT_TERMINAL_INPUT_MODE_STATE,
type TerminalInputModeState,
} from "@getpaseo/protocol/terminal-input-mode";
import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
@@ -13,14 +18,28 @@ import { useAppActivelyVisible } from "@/hooks/use-app-visible";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
hasPendingTerminalModifiers,
normalizeTerminalTransportKey,
resolvePendingModifierDataInput,
} from "@/utils/terminal-keys";
import {
createTerminalKeyInput,
dispatchTerminalKeyInput,
EMPTY_TERMINAL_KEY_MODIFIERS,
type TerminalKeyModifierState,
} from "@/terminal/runtime/terminal-key-dispatch";
import {
getTerminalVirtualKeyboardControlId,
shouldShowTerminalFloatingCopyAction,
shouldShowTerminalPasteAction,
TERMINAL_VIRTUAL_KEYBOARD_ROWS,
type TerminalVirtualKeyboardControl,
} from "@/terminal/runtime/terminal-virtual-keyboard";
import { pasteTerminalClipboard } from "@/terminal/runtime/terminal-paste";
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
import {
EMPTY_FOCUS_CLAIM_STATE,
canRequestFocusClaim,
reconcileFocusClaim,
resolveTerminalResizeClaim,
settleFocusClaim,
} from "./terminal-pane-focus-claim";
import {
@@ -29,12 +48,16 @@ import {
} from "@/terminal/runtime/terminal-stream-controller";
import { resolveTerminalRestoreOptions } from "@/terminal/runtime/terminal-restore-options";
import { usePanelStore } from "@/stores/panel-store";
import { useBlockMobilePanelOpenGestures } from "@/mobile-panels/provider";
import { useSessionStore } from "@/stores/session-store";
import { toXtermTheme } from "@/utils/to-xterm-theme";
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
import { TerminalFloatingCopyAction, TerminalPasteAction } from "./terminal-copy-paste-actions";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import {
applyTerminalRendererReadyChange,
resolveTerminalStreamTarget,
shouldReplayTerminalSnapshotForRenderer,
shouldShowTerminalLoadingOverlay,
type TerminalRendererReadyChange,
@@ -69,23 +92,9 @@ const MODIFIER_LABELS = {
alt: "Alt",
} as const;
const KEY_BUTTONS = {
esc: { id: "esc", label: "Esc", key: "Escape" },
tab: { id: "tab", label: "Tab", key: "Tab" },
up: { id: "up", label: "↑", key: "ArrowUp" },
down: { id: "down", label: "↓", key: "ArrowDown" },
left: { id: "left", label: "←", key: "ArrowLeft" },
right: { id: "right", label: "→", key: "ArrowRight" },
enter: { id: "enter", label: "Enter", key: "Enter" },
backspace: { id: "backspace", label: "⌫", key: "Backspace" },
space: { id: "space", label: "Space", key: " " },
} as const;
const EMPTY_MODIFIERS = EMPTY_TERMINAL_KEY_MODIFIERS;
interface ModifierState {
ctrl: boolean;
shift: boolean;
alt: boolean;
}
type ModifierState = TerminalKeyModifierState;
type PendingTerminalInput =
| {
@@ -94,21 +103,9 @@ type PendingTerminalInput =
}
| {
type: "key";
input: {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta?: boolean;
};
input: TerminalKeyInput;
};
const EMPTY_MODIFIERS: ModifierState = {
ctrl: false,
shift: false,
alt: false,
};
function terminalScopeKey(input: { serverId: string; cwd: string }): string {
return `${input.serverId}:${input.cwd}`;
}
@@ -163,6 +160,40 @@ function VirtualKeyButton({ id, label, keyValue, onSend }: VirtualKeyButtonProps
);
}
interface KeyboardToggleButtonProps {
isKeyboardVisible: boolean;
iconColor: string;
onToggle: () => void;
}
function KeyboardToggleButton({
isKeyboardVisible,
iconColor,
onToggle,
}: KeyboardToggleButtonProps) {
const label = isKeyboardVisible ? "Hide keyboard" : "Show keyboard";
const Icon = isKeyboardVisible ? KeyboardOffIcon : KeyboardIcon;
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.keyButton,
(Boolean(hovered) || pressed) && styles.keyButtonHovered,
],
[],
);
return (
<Pressable
accessibilityLabel={label}
accessibilityRole="button"
testID="terminal-keyboard-toggle"
onPress={onToggle}
style={pressableStyle}
>
<Icon color={iconColor} size={16} />
</Pressable>
);
}
export function TerminalPane({
serverId,
cwd,
@@ -185,11 +216,12 @@ export function TerminalPane({
const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobilePanel.target);
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
const swipeGesturesEnabled = isMobile && mobileView === "agent";
const swipeGesturesEnabled = isMobile;
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({
mode: "padding",
enabled: isMobile,
});
const [keyboardInset, setKeyboardInset] = useState(0);
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -197,6 +229,13 @@ export function TerminalPane({
const supportsTerminalRestoreModes = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-restore-modes"] === true,
);
const supportsTerminalInputModeReplay = useSessionStore(
(state) =>
state.sessions[serverId]?.serverInfo?.features?.["terminal-input-mode-replay"] === true,
);
const supportsTerminalSizeOwnership = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-size-ownership"] === true,
);
const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId);
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
@@ -214,16 +253,17 @@ export function TerminalPane({
const [streamError, setStreamError] = useState<string | null>(null);
const [rendererReadyStreamKey, setRendererReadyStreamKey] = useState<string | null>(null);
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
const [hasSelection, setHasSelection] = useState(false);
const [hasClipboardText, setHasClipboardText] = useState(false);
const [isKeyboardToggleVisible, setIsKeyboardToggleVisible] = useState(false);
const [focusRequestToken, setFocusRequestToken] = useState(0);
const [resizeRequestToken, setResizeRequestToken] = useState(0);
useBlockMobilePanelOpenGestures(isMobile && isWorkspaceFocused && isPaneFocused && hasSelection);
const emulatorRef = useRef<TerminalEmulatorHandle>(null);
const terminalIdRef = useRef<string>(terminalId);
const terminalActiveRef = useRef(isTerminalActive);
terminalActiveRef.current = isTerminalActive;
const inputModeRef = useRef<TerminalInputModeState>({
kittyKeyboardFlags: 0,
win32InputMode: false,
});
const inputModeRef = useRef<TerminalInputModeState>(DEFAULT_TERMINAL_INPUT_MODE_STATE);
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const lastAutoFocusKeyRef = useRef<string | null>(null);
@@ -232,12 +272,39 @@ export function TerminalPane({
useEffect(() => {
terminalIdRef.current = terminalId;
inputModeRef.current = {
kittyKeyboardFlags: 0,
win32InputMode: false,
};
inputModeRef.current = DEFAULT_TERMINAL_INPUT_MODE_STATE;
setHasSelection(false);
}, [terminalId]);
const refreshClipboardAvailability = useCallback(async () => {
if (!isMobile) {
setHasClipboardText(false);
return;
}
try {
const hasText = await Clipboard.hasStringAsync();
setHasClipboardText(hasText);
} catch {
setHasClipboardText(false);
}
}, [isMobile]);
useEffect(() => {
void refreshClipboardAvailability();
}, [refreshClipboardAvailability, isAppActivelyVisible]);
useEffect(() => {
void refreshClipboardAvailability();
}, [keyboardInset, refreshClipboardAvailability]);
useEffect(() => {
setIsKeyboardToggleVisible(keyboardInset > 0);
}, [keyboardInset]);
const handleSelectionChange = useCallback((nextHasSelection: boolean) => {
setHasSelection(nextHasSelection);
}, []);
const requestTerminalFocus = useCallback(() => {
setFocusRequestToken((current) => current + 1);
}, []);
@@ -282,7 +349,8 @@ export function TerminalPane({
useEffect(() => {
const canRequest = canRequestFocusClaim({
isWorkspaceFocused,
isWorkspaceFocused: isTerminalActive,
isPaneFocused,
isAppActivelyVisible,
isClientReady: client !== null,
isConnected,
@@ -296,13 +364,14 @@ export function TerminalPane({
if (step.shouldRequest) {
lastSentTerminalSizeRef.current = null;
requestTerminalReflow();
emulatorRef.current?.claimSize();
}
}, [
client,
isAppActivelyVisible,
isConnected,
isPaneFocused,
isWorkspaceFocused,
isTerminalActive,
rendererReadyStreamKey,
requestTerminalReflow,
scopeKey,
@@ -316,6 +385,7 @@ export function TerminalPane({
}
lastSentTerminalSizeRef.current = null;
requestTerminalReflow();
emulatorRef.current?.claimSize();
}, [
isPaneFocused,
isWorkspaceFocused,
@@ -338,26 +408,37 @@ export function TerminalPane({
const pulseKeyboardRefits = useCallback(() => {
clearKeyboardRefitTimeouts();
requestTerminalReflow();
keyboardRefitTimeoutsRef.current = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
keyboardRefitTimeoutsRef.current = TERMINAL_REFIT_DELAYS_MS.map((delayMs, index) =>
setTimeout(() => {
requestTerminalReflow();
if (index === TERMINAL_REFIT_DELAYS_MS.length - 1) {
emulatorRef.current?.claimSize();
}
}, delayMs),
);
}, [clearKeyboardRefitTimeouts, requestTerminalReflow]);
const handleKeyboardInsetChange = useCallback(
(nextInset: number) => {
setKeyboardInset(nextInset);
pulseKeyboardRefits();
},
[pulseKeyboardRefits],
);
useEffect(() => {
return () => clearKeyboardRefitTimeouts();
}, [clearKeyboardRefitTimeouts]);
useAnimatedReaction(
() => isMobile && keyboardShift.value > 0,
() => (isMobile ? Math.round(keyboardShift.value) : 0),
(next, prev) => {
if (next === prev) {
return;
}
runOnJS(pulseKeyboardRefits)();
runOnJS(handleKeyboardInsetChange)(next);
},
[isMobile, pulseKeyboardRefits],
[isMobile, handleKeyboardInsetChange],
);
useEffect(() => {
@@ -408,7 +489,21 @@ export function TerminalPane({
const controller = new TerminalStreamController({
client,
getPreferredSize: () => lastSentTerminalSizeRef.current,
getPreferredSize: () => {
if (
!canRequestFocusClaim({
isWorkspaceFocused: terminalActiveRef.current,
isPaneFocused,
isAppActivelyVisible,
isClientReady: client !== null,
isConnected,
isRendererReady: rendererReadyStreamKey === terminalStreamKey,
})
) {
return null;
}
return measuredTerminalSizeRef.current;
},
onOutput: ({ terminalId: outputTerminalId, data }) => {
if (!terminalActiveRef.current || terminalIdRef.current !== outputTerminalId) {
return;
@@ -432,6 +527,14 @@ export function TerminalPane({
getRestoreOptions: () => {
return resolveTerminalRestoreOptions({
supportsTerminalRestoreModes,
canClaimSize: canRequestFocusClaim({
isWorkspaceFocused: terminalActiveRef.current,
isPaneFocused,
isAppActivelyVisible,
isClientReady: client !== null,
isConnected,
isRendererReady: rendererReadyStreamKey === terminalStreamKey,
}),
size: measuredTerminalSizeRef.current,
});
},
@@ -439,9 +542,6 @@ export function TerminalPane({
});
streamControllerRef.current = controller;
controller.setTerminal({
terminalId: terminalActiveRef.current ? terminalIdRef.current : null,
});
return () => {
controller.dispose();
@@ -453,17 +553,33 @@ export function TerminalPane({
client,
handleStreamControllerStatus,
isConnected,
isAppActivelyVisible,
isPaneFocused,
rendererReadyStreamKey,
supportsTerminalRestoreModes,
terminalStreamKey,
workspaceTerminalSession.snapshots,
]);
useEffect(() => {
pendingTerminalInputRef.current = [];
const nextTerminalId = isTerminalActive ? terminalId : null;
const nextTerminalId = resolveTerminalStreamTarget({
terminalId,
terminalStreamKey,
rendererReadyStreamKey,
isWorkspaceFocused: isTerminalActive,
});
streamControllerRef.current?.setTerminal({
terminalId: nextTerminalId,
});
}, [isTerminalActive, terminalId]);
}, [
client,
isConnected,
isTerminalActive,
rendererReadyStreamKey,
terminalId,
terminalStreamKey,
]);
const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => {
const queue = pendingTerminalInputRef.current;
@@ -492,15 +608,14 @@ export function TerminalPane({
return true;
}
const encoded = encodeTerminalKeyInput(entry.input, {
dispatchTerminalKeyInput({
keyInput: entry.input,
inputMode: inputModeRef.current,
});
if (encoded.length === 0) {
return true;
}
client.sendTerminalInput(currentTerminalId, {
type: "input",
data: encoded,
sendData: (data) =>
client.sendTerminalInput(currentTerminalId, {
type: "input",
data,
}),
});
return true;
},
@@ -552,26 +667,31 @@ export function TerminalPane({
enqueuePendingTerminalInput({
type: "key",
input: {
key: normalizeTerminalTransportKey(input.key),
ctrl: input.ctrl,
shift: input.shift,
alt: input.alt,
meta: input.meta,
...createTerminalKeyInput({
key: input.key,
modifiers: {
ctrl: input.ctrl,
shift: input.shift,
alt: input.alt,
},
meta: input.meta,
}),
},
});
return true;
}
const normalizedKey = normalizeTerminalTransportKey(input.key);
const pendingEntry: PendingTerminalInput = {
type: "key",
input: {
key: normalizedKey,
ctrl: input.ctrl,
shift: input.shift,
alt: input.alt,
input: createTerminalKeyInput({
key: input.key,
modifiers: {
ctrl: input.ctrl,
shift: input.shift,
alt: input.alt,
},
meta: input.meta,
},
}),
};
if (!dispatchTerminalInputEntry(pendingEntry)) {
enqueuePendingTerminalInput(pendingEntry);
@@ -638,7 +758,7 @@ export function TerminalPane({
);
const handleTerminalResize = useStableEvent(
(input: { rows: number; cols: number; shouldClaim: boolean }) => {
(input: { rows: number; cols: number; shouldClaim: boolean; forceClaim?: boolean }) => {
const { rows, cols } = input;
if (rows <= 0 || cols <= 0) {
return;
@@ -647,35 +767,34 @@ export function TerminalPane({
const normalizedCols = Math.floor(cols);
const nextSize = { rows: normalizedRows, cols: normalizedCols };
measuredTerminalSizeRef.current = nextSize;
if (!input.shouldClaim) {
return;
}
let sent = false;
const canSend = canRequestFocusClaim({
isWorkspaceFocused,
isAppActivelyVisible,
isClientReady: client !== null,
isConnected,
isRendererReady: true,
const claim = resolveTerminalResizeClaim({
size: nextSize,
previousSentSize: lastSentTerminalSizeRef.current,
shouldClaim: input.shouldClaim,
forceClaim: input.forceClaim ?? false,
supportsTerminalSizeOwnership,
readiness: {
isWorkspaceFocused: isTerminalActive,
isPaneFocused,
isAppActivelyVisible,
isClientReady: client !== null,
isConnected,
isRendererReady: rendererReadyStreamKey === terminalStreamKey,
},
});
if (client && terminalId && canSend) {
const previousSent = lastSentTerminalSizeRef.current;
if (
!previousSent ||
previousSent.rows !== normalizedRows ||
previousSent.cols !== normalizedCols
) {
lastSentTerminalSizeRef.current = nextSize;
client.sendTerminalInput(terminalId, {
type: "resize",
rows: normalizedRows,
cols: normalizedCols,
});
}
let sent = false;
if (client && terminalId && claim.shouldSend) {
lastSentTerminalSizeRef.current = nextSize;
client.sendTerminalInput(terminalId, {
type: "resize",
rows: normalizedRows,
cols: normalizedCols,
intent: claim.intent,
});
sent = true;
}
const requestedKey = paneFocusResizeClaimRef.current.requestedKey;
if (requestedKey) {
if (requestedKey && claim.intent === "claim") {
paneFocusResizeClaimRef.current = settleFocusClaim(paneFocusResizeClaimRef.current, {
key: requestedKey,
sent,
@@ -695,6 +814,38 @@ export function TerminalPane({
clearPendingModifiers();
}, [clearPendingModifiers]);
const handleTerminalPaste = useCallback(() => {
requestTerminalReflow();
void pasteTerminalClipboard({
clipboard: { readText: () => Clipboard.getStringAsync() },
terminal: { paste: (text) => emulatorRef.current?.paste(text) },
}).then(() => refreshClipboardAvailability());
}, [requestTerminalReflow, refreshClipboardAvailability]);
const handleTerminalCopy = useCallback(() => {
void emulatorRef.current
?.copySelection({
writeText: async (text: string) => {
await Clipboard.setStringAsync(text);
},
})
.then(() => refreshClipboardAvailability());
}, [refreshClipboardAvailability]);
const handleKeyboardToggle = useCallback(() => {
if (isKeyboardToggleVisible) {
setIsKeyboardToggleVisible(false);
emulatorRef.current?.blur();
requestTerminalReflow();
return;
}
setIsKeyboardToggleVisible(true);
emulatorRef.current?.showKeyboard();
requestTerminalFocus();
requestTerminalReflow();
}, [isKeyboardToggleVisible, requestTerminalFocus, requestTerminalReflow]);
const handleInputModeChange = useCallback((state: TerminalInputModeState) => {
inputModeRef.current = state;
}, []);
@@ -739,8 +890,9 @@ export function TerminalPane({
(modifier: keyof ModifierState) => {
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
requestTerminalFocus();
requestTerminalReflow();
},
[requestTerminalFocus],
[requestTerminalFocus, requestTerminalReflow],
);
const sendVirtualKey = useCallback(
@@ -754,6 +906,7 @@ export function TerminalPane({
});
clearPendingModifiers();
requestTerminalFocus();
requestTerminalReflow();
},
[
clearPendingModifiers,
@@ -761,6 +914,7 @@ export function TerminalPane({
modifiers.ctrl,
modifiers.shift,
requestTerminalFocus,
requestTerminalReflow,
sendTerminalKey,
],
);
@@ -781,6 +935,54 @@ export function TerminalPane({
emulatorRef.current?.blur();
onOpenFileExplorer();
}, [swipeGesturesEnabled, onOpenFileExplorer]);
const showPasteAction = shouldShowTerminalPasteAction({ isNative });
const showFloatingCopyAction = shouldShowTerminalFloatingCopyAction({
hasSelection,
isNative,
});
const keyboardToggleIconColor = theme.colors.foregroundMuted;
const renderVirtualKeyboardControl = (control: TerminalVirtualKeyboardControl) => {
const controlId = getTerminalVirtualKeyboardControlId(control);
switch (control.type) {
case "key":
return (
<VirtualKeyButton
key={controlId}
id={control.button.id}
label={control.button.label}
keyValue={control.button.key}
onSend={sendVirtualKey}
/>
);
case "modifier":
return (
<ModifierButton
key={controlId}
modifier={control.modifier}
active={modifiers[control.modifier]}
onToggle={toggleModifier}
/>
);
case "paste":
return showPasteAction ? (
<TerminalPasteAction
key={controlId}
hasClipboardText={hasClipboardText}
onPaste={handleTerminalPaste}
/>
) : null;
case "keyboardToggle":
return (
<KeyboardToggleButton
key={controlId}
iconColor={keyboardToggleIconColor}
isKeyboardVisible={isKeyboardToggleVisible}
onToggle={handleKeyboardToggle}
/>
);
}
};
const showLoadingOverlay = shouldShowTerminalLoadingOverlay({
isWorkspaceFocused: isTerminalActive,
hasStreamError: Boolean(streamError),
@@ -805,11 +1007,13 @@ export function TerminalPane({
ref={emulatorRef}
dom={TERMINAL_EMULATOR_DOM_PROPS}
streamKey={terminalStreamKey}
supportsTerminalInputModeReplay={supportsTerminalInputModeReplay}
testId="terminal-surface"
xtermTheme={xtermTheme}
scrollbackLines={settings.terminalScrollbackLines}
fontFamily={terminalFontFamily}
fontSize={settings.codeFontSize}
keyboardInset={keyboardInset}
swipeGesturesEnabled={swipeGesturesEnabled}
initialSnapshot={initialSnapshot}
onRendererReadyChange={handleRendererReadyChange}
@@ -820,6 +1024,7 @@ export function TerminalPane({
onResize={handleTerminalResize}
onTerminalKey={handleTerminalKey}
onInputModeChange={handleInputModeChange}
onSelectionChange={handleSelectionChange}
onResolveLocalFileLink={handleResolveLocalFileLink}
onOpenLocalFileLink={handleOpenLocalFileLink}
onPendingModifiersConsumed={handlePendingModifiersConsumed}
@@ -834,6 +1039,12 @@ export function TerminalPane({
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
</View>
) : null}
{showFloatingCopyAction ? (
<View pointerEvents="box-none" style={styles.floatingCopyContainer}>
<TerminalFloatingCopyAction hasSelection={hasSelection} onCopy={handleTerminalCopy} />
</View>
) : null}
</View>
{streamError ? (
@@ -847,55 +1058,14 @@ export function TerminalPane({
{isMobile ? (
<View style={styles.keyboardContainer} testID="terminal-virtual-keyboard">
<View style={styles.keyboardRows}>
<View style={styles.keyboardRow}>
{[KEY_BUTTONS.esc, KEY_BUTTONS.tab].map((button) => (
<VirtualKeyButton
key={button.id}
id={button.id}
label={button.label}
keyValue={button.key}
onSend={sendVirtualKey}
/>
))}
<ModifierButton modifier="ctrl" active={modifiers.ctrl} onToggle={toggleModifier} />
<VirtualKeyButton
id={KEY_BUTTONS.up.id}
label={KEY_BUTTONS.up.label}
keyValue={KEY_BUTTONS.up.key}
onSend={sendVirtualKey}
/>
<ModifierButton modifier="shift" active={modifiers.shift} onToggle={toggleModifier} />
<VirtualKeyButton
id={KEY_BUTTONS.backspace.id}
label={KEY_BUTTONS.backspace.label}
keyValue={KEY_BUTTONS.backspace.key}
onSend={sendVirtualKey}
/>
</View>
<View style={styles.keyboardRow}>
<ModifierButton modifier="alt" active={modifiers.alt} onToggle={toggleModifier} />
{[
KEY_BUTTONS.space,
KEY_BUTTONS.left,
KEY_BUTTONS.down,
KEY_BUTTONS.right,
KEY_BUTTONS.enter,
].map((button) => (
<VirtualKeyButton
key={button.id}
id={button.id}
label={button.label}
keyValue={button.key}
onSend={sendVirtualKey}
/>
))}
</View>
{TERMINAL_VIRTUAL_KEYBOARD_ROWS.map((row) => (
<View
key={row.map(getTerminalVirtualKeyboardControlId).join(":")}
style={styles.keyboardRow}
>
{row.map(renderVirtualKeyboardControl)}
</View>
))}
</View>
</View>
) : null}
@@ -925,6 +1095,12 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
backgroundColor: "rgba(0, 0, 0, 0.16)",
},
floatingCopyContainer: {
position: "absolute",
right: theme.spacing[3],
bottom: theme.spacing[12],
zIndex: 2,
},
errorRow: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],