Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 75 additions & 6 deletions apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -255,6 +257,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
const firstNonEmptyBufferLoggedRef = useRef(false);
const lastBufferReplayKeyRef = useRef<string | null>(null);
const sentInitialInputKeyRef = useRef<string | null>(null);
const activePasteTargetRef = useRef<string | null>(null);
const [readyBufferReplayKey, setReadyBufferReplayKey] = useState<string | null>(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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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) => {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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<MenuAction[]>(
() => [
{
id: "terminal-paste",
title: "Paste",
image: "doc.on.clipboard",
attributes: !isRunning ? { disabled: true } : undefined,
},
{
id: "text-size",
title: "Text size",
Expand Down Expand Up @@ -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(
Expand All @@ -975,6 +1020,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
handleDecreaseFontSize();
return;
}
if (id === "terminal-paste") {
handlePasteTerminal();
return;
}
if (id === "font-increase") {
handleIncreaseFontSize();
return;
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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));
Expand All @@ -1032,7 +1092,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
writeInput(action.data);
}
},
[handleClearTerminal, pendingModifier, terminalId, writeInput],
[handleClearTerminal, handlePasteTerminal, pendingModifier, terminalId, writeInput],
);

const handleDismissKeyboard = useCallback(() => {
Expand Down Expand Up @@ -1155,6 +1215,13 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
hasRunningSubprocess: terminal.hasRunningSubprocess,
})}
</NativeHeaderToolbar.Label>
<NativeHeaderToolbar.MenuAction
disabled={!isRunning}
icon="doc.on.clipboard"
onPress={handlePasteTerminal}
>
<NativeHeaderToolbar.Label>Paste</NativeHeaderToolbar.Label>
</NativeHeaderToolbar.MenuAction>
<NativeHeaderToolbar.Menu icon="textformat.size" inline title="Text size">
<NativeHeaderToolbar.Label>Text size</NativeHeaderToolbar.Label>
<NativeHeaderToolbar.MenuAction
Expand Down Expand Up @@ -1266,7 +1333,9 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
onPress={() => handleToolbarActionPress(action)}
showChevron={false}
textTransform={
action.kind === "modifier" || action.kind === "clear"
action.kind === "modifier" ||
action.kind === "clear" ||
action.kind === "paste"
? "uppercase"
: "none"
}
Expand Down
47 changes: 47 additions & 0 deletions apps/mobile/src/features/terminal/terminalClipboard.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
24 changes: 24 additions & 0 deletions apps/mobile/src/features/terminal/terminalClipboard.ts
Original file line number Diff line number Diff line change
@@ -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<TerminalClipboardReadResult> {
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 };
}
}
Loading