diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index f370401e8ecc..21402fd9ad6c 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -4,8 +4,8 @@ import type { MenuAction } from "@react-native-menu/menu"; import { SymbolView } from "../../components/AppSymbol"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Platform, Pressable, View } from "react-native"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { Alert, Platform, Pressable, View } from "react-native"; import { KeyboardController, KeyboardEvents, @@ -44,6 +44,7 @@ import { useSelectedThreadDetail } from "../../state/use-thread-detail"; import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { TerminalSurface } from "./NativeTerminalSurface"; +import { readTerminalClipboardText } from "./terminalClipboard"; import { getMobileTerminalTheme } from "./terminalTheme"; import { terminalDebugLog } from "./terminalDebugLog"; import { @@ -78,6 +79,7 @@ type HostPlatform = "mac" | "linux" | "windows" | "unknown"; type TerminalToolbarAction = | { readonly kind: "send"; readonly key: string; readonly label: string; readonly data: string } | { readonly kind: "clear"; readonly key: string; readonly label: string } + | { readonly kind: "paste"; readonly key: string; readonly label: string } | { readonly kind: "modifier"; readonly key: string; @@ -255,6 +257,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) const firstNonEmptyBufferLoggedRef = useRef(false); const lastBufferReplayKeyRef = useRef(null); const sentInitialInputKeyRef = useRef(null); + const activePasteTargetRef = useRef(null); const [readyBufferReplayKey, setReadyBufferReplayKey] = useState(null); /** Default grid is always valid for attach; onResize refines cols/rows. Requiring a cached size blocked bootstrap for new terminal routes. */ const [hasMeasuredSurface, setHasMeasuredSurface] = useState(true); @@ -354,6 +357,15 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) }); const isRunning = terminal.status === "running" || terminal.status === "starting"; + useLayoutEffect(() => { + activePasteTargetRef.current = isRunning ? terminalKey : null; + return () => { + if (activePasteTargetRef.current === terminalKey) { + activePasteTargetRef.current = null; + } + }; + }, [isRunning, terminalKey]); + // When the process ends while this screen is attached (e.g. typing `exit`), // close the session and leave the screen, mirroring the web drawer's // onSessionExited flow. Only react to a running -> exited transition @@ -488,6 +500,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) { kind: "send", key: "esc", label: "esc", data: "\u001b" }, ...modifierActions, { kind: "send", key: "tab", label: "tab", data: "\t" }, + { kind: "paste", key: "paste", label: "paste" }, { kind: "clear", key: "clear", label: "clear" }, { kind: "send", key: "up", label: "↑", data: "\u001b[A" }, { kind: "send", key: "down", label: "↓", data: "\u001b[B" }, @@ -927,11 +940,43 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) setTerminalFontSize(stepTerminalFontSize(fontSize, 1)); }, [fontSize, setTerminalFontSize]); + const handlePasteTerminal = useCallback(() => { + if (!isRunning) { + return; + } + + const pasteTargetKey = terminalKey; + void readTerminalClipboardText().then((result) => { + if (activePasteTargetRef.current !== pasteTargetKey) { + return; + } + switch (result._tag) { + case "text": + setPendingModifierState({ terminalId, value: null }); + writeInput(result.text); + return; + case "empty": + Alert.alert("Nothing to paste", "The clipboard does not contain text."); + return; + case "unavailable": + console.warn("[terminal] clipboard paste failed", result.cause); + Alert.alert("Could not paste", "The clipboard is unavailable right now."); + return; + } + }); + }, [isRunning, terminalId, terminalKey, writeInput]); + // Android mirror of the iOS NativeHeaderToolbar terminal menu below: text // size, session switching, and "Open new terminal", rendered through the // token-styled anchored menu (the native header items are iOS-only). const androidTerminalMenuActions = useMemo( () => [ + { + id: "terminal-paste", + title: "Paste", + image: "doc.on.clipboard", + attributes: !isRunning ? { disabled: true } : undefined, + }, { id: "text-size", title: "Text size", @@ -965,7 +1010,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) subtitle: `Start another shell in ${basename(selectedThreadProject?.workspaceRoot ?? null) ?? "this workspace"}`, }, ], - [fontSize, selectedThreadProject?.workspaceRoot, terminalId, terminalMenuSessions], + [fontSize, isRunning, selectedThreadProject?.workspaceRoot, terminalId, terminalMenuSessions], ); const handleAndroidTerminalMenuAction = useCallback( @@ -975,6 +1020,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) handleDecreaseFontSize(); return; } + if (id === "terminal-paste") { + handlePasteTerminal(); + return; + } if (id === "font-increase") { handleIncreaseFontSize(); return; @@ -987,7 +1036,13 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) handleSelectTerminal(id.slice("terminal-session:".length)); } }, - [handleDecreaseFontSize, handleIncreaseFontSize, handleOpenNewTerminal, handleSelectTerminal], + [ + handleDecreaseFontSize, + handleIncreaseFontSize, + handleOpenNewTerminal, + handlePasteTerminal, + handleSelectTerminal, + ], ); const handleClearTerminal = useCallback(() => { @@ -1023,6 +1078,11 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) return; } + if (action.kind === "paste") { + handlePasteTerminal(); + return; + } + setPendingModifierState({ terminalId, value: null }); if (pendingModifier === "ctrl") { writeInput(applyCtrlModifier(action.data)); @@ -1032,7 +1092,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) writeInput(action.data); } }, - [handleClearTerminal, pendingModifier, terminalId, writeInput], + [handleClearTerminal, handlePasteTerminal, pendingModifier, terminalId, writeInput], ); const handleDismissKeyboard = useCallback(() => { @@ -1155,6 +1215,13 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) hasRunningSubprocess: terminal.hasRunningSubprocess, })} + + Paste + Text size handleToolbarActionPress(action)} showChevron={false} textTransform={ - action.kind === "modifier" || action.kind === "clear" + action.kind === "modifier" || + action.kind === "clear" || + action.kind === "paste" ? "uppercase" : "none" } diff --git a/apps/mobile/src/features/terminal/terminalClipboard.test.ts b/apps/mobile/src/features/terminal/terminalClipboard.test.ts new file mode 100644 index 000000000000..c8366bcc203b --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalClipboard.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + hasStringAsync: vi.fn(), + getStringAsync: vi.fn(), +})); + +vi.mock("expo-clipboard", () => ({ + hasStringAsync: mocks.hasStringAsync, + getStringAsync: mocks.getStringAsync, +})); + +import { readTerminalClipboardText } from "./terminalClipboard"; + +describe("terminal clipboard", () => { + beforeEach(() => { + mocks.hasStringAsync.mockReset(); + mocks.getStringAsync.mockReset(); + }); + + it("returns clipboard text", async () => { + mocks.hasStringAsync.mockResolvedValue(true); + mocks.getStringAsync.mockResolvedValue("pnpm test\n"); + + await expect(readTerminalClipboardText()).resolves.toEqual({ + _tag: "text", + text: "pnpm test\n", + }); + }); + + it("does not read non-text clipboard content", async () => { + mocks.hasStringAsync.mockResolvedValue(false); + + await expect(readTerminalClipboardText()).resolves.toEqual({ _tag: "empty" }); + expect(mocks.getStringAsync).not.toHaveBeenCalled(); + }); + + it("reports clipboard failures without throwing from the terminal action", async () => { + const cause = new Error("clipboard unavailable"); + mocks.hasStringAsync.mockRejectedValue(cause); + + await expect(readTerminalClipboardText()).resolves.toEqual({ + _tag: "unavailable", + cause, + }); + }); +}); diff --git a/apps/mobile/src/features/terminal/terminalClipboard.ts b/apps/mobile/src/features/terminal/terminalClipboard.ts new file mode 100644 index 000000000000..bf8b105ec269 --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalClipboard.ts @@ -0,0 +1,24 @@ +import * as Clipboard from "expo-clipboard"; + +export type TerminalClipboardReadResult = + | { readonly _tag: "text"; readonly text: string } + | { readonly _tag: "empty" } + | { readonly _tag: "unavailable"; readonly cause: unknown }; + +/** + * Reads text only after an explicit terminal paste action. Keeping clipboard + * access behind the button avoids surprising iOS paste prompts while still + * giving both native terminal surfaces the same behavior. + */ +export async function readTerminalClipboardText(): Promise { + try { + if (!(await Clipboard.hasStringAsync())) { + return { _tag: "empty" }; + } + + const text = await Clipboard.getStringAsync(); + return text.length === 0 ? { _tag: "empty" } : { _tag: "text", text }; + } catch (cause) { + return { _tag: "unavailable", cause }; + } +}