Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import {
type PendingModifier,
} from "./terminalInput";
import { createTerminalPasteSession } from "./terminalPaste";
import { useTerminalClipboard } from "./useTerminalClipboard";
import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState";

const DEFAULT_TERMINAL_COLS = 80;
Expand Down Expand Up @@ -335,6 +336,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
const terminalKey = selectedThread
? `${selectedThread.environmentId}:${selectedThread.id}:${terminalId}`
: terminalId;
useTerminalClipboard(selectedThread?.environmentId ?? null, terminalAttachInput);
const bufferReplayKey = useMemo(
() => getTerminalBufferReplayKey({ terminalKey, fontSize }),
[fontSize, terminalKey],
Expand Down
65 changes: 65 additions & 0 deletions apps/mobile/src/features/terminal/useTerminalClipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { useFocusEffect } from "@react-navigation/native";
import { environmentRpcKey } from "@t3tools/client-runtime/state/runtime";
import {
createTerminalClipboardSession,
createTerminalClipboardWriter,
} from "@t3tools/client-runtime/terminal-clipboard";
import type { EnvironmentId, TerminalAttachInput } from "@t3tools/contracts";
import * as Clipboard from "expo-clipboard";
import { useCallback, useLayoutEffect, useMemo, useRef } from "react";
import { AppState, Platform } from "react-native";
import { terminalEnvironment } from "../../state/terminal";

const writeClipboard = createTerminalClipboardWriter((text) => Clipboard.setStringAsync(text));

/** Route focus, not keyboard visibility, owns touch-driven TUI copies on iOS and Android. */
export function useTerminalClipboard(
environmentId: EnvironmentId | null,
input: TerminalAttachInput | null,
) {
const target = useMemo(
() => (environmentId !== null && input !== null ? { environmentId, input } : null),
[environmentId, input],
);
const targetKey = useMemo(() => (target === null ? null : environmentRpcKey(target)), [target]);
const latestTarget = useRef(target);
useLayoutEffect(() => {
latestTarget.current = target;
}, [target]);

useFocusEffect(
useCallback(() => {
// The key, not the object, restarts the session: equal attach values
// recreated by a render must not drop a copy that is mid-sequence.
const target = targetKey === null ? null : latestTarget.current;
if (target === null) return;
let active = false;
const session = createTerminalClipboardSession({
isEligible: () => active,
onCopy: (text, canWrite) => void writeClipboard(text, canWrite),
});
const setActive = (next: boolean) => {
active = next;
if (!next) session.invalidate();
};
const stop = terminalEnvironment.observeAttach(target, session.update);
const activate = () => setActive(AppState.currentState === "active");
activate();
const change = AppState.addEventListener("change", activate);
// Android can lose interaction focus without changing AppState (e.g. its notification drawer).
const blur =
Platform.OS === "android"
? AppState.addEventListener("blur", () => setActive(false))
: undefined;
const focus =
Platform.OS === "android" ? AppState.addEventListener("focus", activate) : undefined;
return () => {
setActive(false);
stop();
change.remove();
blur?.remove();
focus?.remove();
};
}, [targetKey]),
);
}
89 changes: 59 additions & 30 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ import { terminalEnvironment } from "../state/terminal";
import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview";
import { useAtomCommand } from "../state/use-atom-command";
import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut";
import {
copyTerminalClipboardFromGesture,
writeTerminalClipboard,
} from "../terminal/ghostty/clipboard";
import {
resolveTerminalFontPreference,
resolveTerminalFontSizePreference,
Expand Down Expand Up @@ -141,9 +145,9 @@ function parseTerminalColor(value: string, fallback: GhosttyColor): GhosttyColor
};
}

function runtimeEnvSignature(runtimeEnv: Record<string, string> | undefined): string {
if (!runtimeEnv) return "";
return JSON.stringify(
function normalizeRuntimeEnv(runtimeEnv: Record<string, string> | undefined) {
if (!runtimeEnv) return undefined;
return Object.fromEntries(
Object.entries(runtimeEnv)
.filter(([key, value]) => key.length > 0 && typeof value === "string")
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)),
Expand Down Expand Up @@ -377,7 +381,10 @@ export function TerminalViewport({
// cannot be mistaken for the active flow.
const openSelectionMenuRequestIdRef = useRef<number | null>(null);
const keybindingsRef = useRef(keybindings);
const runtimeEnvKey = useMemo(() => runtimeEnvSignature(runtimeEnv), [runtimeEnv]);
const [terminalEnv, runtimeEnvKey] = useMemo(() => {
const env = normalizeRuntimeEnv(runtimeEnv);
return [env, env ? JSON.stringify(env) : ""] as const;
}, [runtimeEnv]);
const handleSessionExited = useEffectEvent(() => {
onSessionExited();
});
Expand All @@ -401,16 +408,17 @@ export function TerminalViewport({
}),
);
const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize });
const terminalAttachInput = {
threadId,
terminalId,
cwd,
...(worktreePath !== undefined ? { worktreePath } : {}),
...(terminalEnv ? { env: terminalEnv } : {}),
...(providerInstanceId ? { providerInstanceId } : {}),
};
const terminalSession = useAttachedTerminalSession({
environmentId,
terminal: {
threadId,
terminalId,
cwd,
...(worktreePath !== undefined ? { worktreePath } : {}),
...(runtimeEnv ? { env: runtimeEnv } : {}),
...(providerInstanceId ? { providerInstanceId } : {}),
},
terminal: terminalAttachInput,
});
const writeTerminal = useEffectEvent((data: string) =>
runTerminalWrite({
Expand Down Expand Up @@ -490,6 +498,8 @@ export function TerminalViewport({

const setup = async (): Promise<(() => void) | null> => {
const setupFont = terminalFontRef.current;
const clipboardToastId = `terminal-copy:${environmentId}:${threadId}:${terminalId}`;
let clipboardRequest = 0;
const terminalOptions: GhosttyTerminalSurfaceOptions = {
theme: terminalThemeFromApp(mount),
font: terminalFontOptions(setupFont.family, setupFont.size),
Expand All @@ -499,6 +509,32 @@ export function TerminalViewport({
onData: (data) => handleData(data),
onResize: (cols, rows) => void resizeTerminal(cols, rows),
onSelectionChange: () => handleSelectionChange(),
onClipboardWrite: async (text, canWrite) => {
const request = ++clipboardRequest;
const result = await writeTerminalClipboard(text, canWrite);
if (request !== clipboardRequest || !canWrite()) return;
if (result === "written") toastManager.close(clipboardToastId);
else if (result === "failed") {
toastManager.add({
id: clipboardToastId,
type: "info",
title: text === "" ? "Clear clipboard?" : "Terminal text ready to copy",
description: "Your browser needs a click to allow this clipboard write.",
timeout: 10_000,
actionProps: {
children: text === "" ? "Clear" : "Copy",
onClick: () => {
if (copyTerminalClipboardFromGesture(text)) toastManager.close(clipboardToastId);
else
toastManager.update(clipboardToastId, {
type: "error",
title: "Clipboard write was blocked",
});
},
Comment thread
callmemorgan marked this conversation as resolved.
},
});
}
},
beforeKey: (event) => handleBeforeKey(event),
onLinkActivate: (text, event) => handleLinkActivate(text, event),
// The surface listens from construction, so a right-click can land
Expand All @@ -513,6 +549,7 @@ export function TerminalViewport({
terminal.dispose();
return null;
}
setupCleanups.push(() => toastManager.close(clipboardToastId));
terminal.setVisible(visibleRef.current);
// The theme observer is not installed yet, so re-read the theme in case
// the app toggled light/dark while the WASM surface was loading.
Expand Down Expand Up @@ -548,6 +585,12 @@ export function TerminalViewport({
terminal.focus();
}

setupCleanups.push(
Comment thread
callmemorgan marked this conversation as resolved.
terminalEnvironment.observeAttach({ environmentId, input: terminalAttachInput }, (event) =>
terminal.observeClipboard(event),
),
);

const dismissSelectionAction = (supersede = false) => {
const ownsMenu =
openSelectionMenuRequestIdRef.current === selectionActionRequestIdRef.current;
Expand Down Expand Up @@ -729,19 +772,6 @@ export function TerminalViewport({
}
};

const sendTerminalInput = async (data: string, fallbackError: string) => {
const activeTerminal = terminalRef.current;
if (!activeTerminal) return;
const result = await writeTerminal(data);
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
writeSystemMessage(
activeTerminal,
error instanceof Error ? error.message : fallbackError,
);
}
};

function handleBeforeKey(event: KeyboardEvent): boolean {
const currentKeybindings = keybindingsRef.current;
const options = { context: { terminalFocus: true, terminalOpen: true } };
Expand All @@ -762,22 +792,22 @@ export function TerminalViewport({
if (navigationData !== null) {
event.preventDefault();
event.stopPropagation();
void sendTerminalInput(navigationData, "Failed to move cursor");
terminalRef.current?.sendUserInput(navigationData);
return false;
}

const deleteData = terminalDeleteShortcutData(event);
if (deleteData !== null) {
event.preventDefault();
event.stopPropagation();
void sendTerminalInput(deleteData, "Failed to delete terminal input");
terminalRef.current?.sendUserInput(deleteData);
return false;
}

if (!isTerminalClearShortcut(event)) return true;
event.preventDefault();
event.stopPropagation();
void sendTerminalInput("\u000c", "Failed to clear terminal");
terminalRef.current?.sendUserInput("\u000c");
return false;
}

Expand Down Expand Up @@ -920,7 +950,7 @@ export function TerminalViewport({
teardown?.();
if (hadFocus && mount.isConnected) mount.focus({ preventScroll: true });
};
}, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]);
}, [cwd, environmentId, providerInstanceId, runtimeEnvKey, terminalId, threadId, worktreePath]);

useEffect(() => {
const terminal = terminalRef.current;
Expand All @@ -944,7 +974,6 @@ export function TerminalViewport({
const outputUpdate = readTerminalOutputUpdate(current.output, outputCursorRef.current);
writeTerminalOutputUpdate(terminal, outputUpdate);
outputCursorRef.current = outputUpdate.cursor;
terminal.clearSelection();

if (current.error !== null && current.error !== previous.error) {
writeSystemMessage(terminal, current.error);
Expand Down
89 changes: 89 additions & 0 deletions apps/web/src/terminal/ghostty/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
import { copyTerminalClipboardFromGesture, writeTerminalClipboard } from "./clipboard";

describe("application clipboard writes", () => {
afterEach(() => vi.unstubAllGlobals());

it.each(["text", ""])("copies %j during a gesture and removes its copy handler", (text) => {
const setData = vi.fn();
const document = Object.assign(new EventTarget(), {
execCommand: () =>
document.dispatchEvent(
Object.assign(new Event("copy", { cancelable: true }), { clipboardData: { setData } }),
),
});
vi.stubGlobal("document", document);
expect(setData).not.toHaveBeenCalled();
expect(copyTerminalClipboardFromGesture(text)).toBe(true);
expect(setData.mock.calls).toEqual([["text/plain", text]]);
document.execCommand();
expect(setData).toHaveBeenCalledTimes(1);
});

it("reports a blocked gesture without leaving a copy handler installed", () => {
const document = Object.assign(new EventTarget(), {
execCommand: () => {
throw new Error("denied");
},
});
vi.stubGlobal("document", document);
expect(copyTerminalClipboardFromGesture("blocked")).toBe(false);
const setData = vi.fn();
document.dispatchEvent(Object.assign(new Event("copy"), { clipboardData: { setData } }));
expect(setData).not.toHaveBeenCalled();
});

it.each(["success", "denied"])(
"serializes writes and keeps only the newest pending text after %s",
async (result) => {
let finishFirst!: () => void;
const first = new Promise<void>((resolve) => {
finishFirst = resolve;
});
let clipboard = "";
const writeText = vi.fn(async (text: string) => {
if (text === "first") {
await first;
if (result === "denied") throw new Error("NotAllowedError");
}
clipboard = text;
});
vi.stubGlobal("navigator", { clipboard: { writeText } });
const writes = [
writeTerminalClipboard("first"),
writeTerminalClipboard("superseded"),
writeTerminalClipboard("last"),
];
const callsWhilePending = [...writeText.mock.calls];
finishFirst();
expect(await Promise.all(writes)).toEqual([
result === "denied" ? "failed" : "written",
"skipped",
"written",
]);
expect(callsWhilePending).toEqual([["first"]]);
expect(writeText.mock.calls).toEqual([["first"], ["last"]]);
expect(clipboard).toBe("last");
},
);

it.each(["success", "denied", "unavailable"])(
"keeps browser focus and consumes failures when clipboard access is %s",
async (result) => {
const writeText = vi.fn(() =>
result === "denied" ? Promise.reject(new Error("NotAllowedError")) : Promise.resolve(),
);
const createElement = vi.fn();
const execCommand = vi.fn();
vi.stubGlobal("navigator", result === "unavailable" ? {} : { clipboard: { writeText } });
vi.stubGlobal("document", { createElement, execCommand });
await expect(writeTerminalClipboard("application text")).resolves.toBe(
result === "success" ? "written" : "failed",
);
await expect(writeTerminalClipboard("inactive", () => false)).resolves.toBe("skipped");
expect(writeText.mock.calls).toEqual(result === "unavailable" ? [] : [["application text"]]);
expect(createElement).not.toHaveBeenCalled();
expect(execCommand).not.toHaveBeenCalled();
},
);
});
27 changes: 27 additions & 0 deletions apps/web/src/terminal/ghostty/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { createTerminalClipboardWriter } from "@t3tools/client-runtime/terminal-clipboard";

/** Application output cannot use the focus-stealing, user-gesture copy fallback. */
export const writeTerminalClipboard = createTerminalClipboardWriter((text) =>
navigator.clipboard?.writeText(text),
);

/** A click can authorize copying on HTTP pages, including clearing with empty text. */
export function copyTerminalClipboardFromGesture(text: string): boolean {
let copied = false;
const onCopy = (event: ClipboardEvent) => {
if (!event.clipboardData) return;
event.clipboardData.setData("text/plain", text);
event.preventDefault();
event.stopPropagation();
copied = true;
};
document.addEventListener("copy", onCopy, { capture: true });
try {
document.execCommand("copy");
return copied;
} catch {
return false;
} finally {
document.removeEventListener("copy", onCopy, { capture: true });
}
}
Loading
Loading