Skip to content

Commit 7ebecc8

Browse files
tarik02juliusmarminge
authored andcommitted
Add terminal selection copy action
1 parent 60b546c commit 7ebecc8

2 files changed

Lines changed: 64 additions & 6 deletions

File tree

‎apps/web/src/components/ThreadTerminalDrawer.test.ts‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,35 @@
1-
import { describe, expect, it } from "vite-plus/test";
1+
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
22

33
import {
4+
TERMINAL_SELECTION_ACTION_MENU_ITEMS,
5+
copyTerminalSelectionTextToClipboard,
46
resolveTerminalSelectionActionPosition,
57
shouldHandleTerminalSelectionMouseUp,
68
terminalSelectionActionDelayForClickCount,
79
} from "./ThreadTerminalDrawer";
810

11+
afterEach(() => {
12+
vi.unstubAllGlobals();
13+
});
14+
15+
describe("terminal selection action menu", () => {
16+
it("places copy after add to chat", () => {
17+
expect(TERMINAL_SELECTION_ACTION_MENU_ITEMS).toEqual([
18+
{ id: "add-to-chat", label: "Add to chat" },
19+
{ id: "copy", label: "Copy" },
20+
]);
21+
});
22+
23+
it("copies selected terminal text to the clipboard", async () => {
24+
const writeText = vi.fn(async () => undefined);
25+
vi.stubGlobal("navigator", { clipboard: { writeText } });
26+
27+
await copyTerminalSelectionTextToClipboard("first line\nsecond line");
28+
29+
expect(writeText).toHaveBeenCalledWith("first line\nsecond line");
30+
});
31+
});
32+
933
describe("resolveTerminalSelectionActionPosition", () => {
1034
it("prefers the selection rect over the last pointer position", () => {
1135
expect(

‎apps/web/src/components/ThreadTerminalDrawer.tsx‎

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
XIcon,
1010
} from "lucide-react";
1111
import {
12+
type ContextMenuItem,
1213
type ResolvedKeybindingsConfig,
1314
type ScopedThreadRef,
1415
type TerminalAttachStreamEvent,
@@ -66,6 +67,14 @@ const MIN_DRAWER_HEIGHT = 180;
6667
const MAX_DRAWER_HEIGHT_RATIO = 0.75;
6768
const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260;
6869

70+
type TerminalSelectionActionId = "add-to-chat" | "copy";
71+
72+
export const TERMINAL_SELECTION_ACTION_MENU_ITEMS: readonly ContextMenuItem<TerminalSelectionActionId>[] =
73+
[
74+
{ id: "add-to-chat", label: "Add to chat" },
75+
{ id: "copy", label: "Copy" },
76+
];
77+
6978
function maxDrawerHeight(): number {
7079
if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT;
7180
return Math.max(MIN_DRAWER_HEIGHT, Math.floor(window.innerHeight * MAX_DRAWER_HEIGHT_RATIO));
@@ -264,6 +273,13 @@ export function shouldHandleTerminalSelectionMouseUp(
264273
return selectionGestureActive && button === 0;
265274
}
266275

276+
export async function copyTerminalSelectionTextToClipboard(text: string): Promise<void> {
277+
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
278+
throw new Error("Clipboard API unavailable.");
279+
}
280+
await navigator.clipboard.writeText(text);
281+
}
282+
267283
interface TerminalViewportProps {
268284
threadRef: ScopedThreadRef;
269285
threadId: ThreadId;
@@ -418,15 +434,33 @@ export function TerminalViewport({
418434
selectionActionOpenRef.current = true;
419435
try {
420436
const clicked = await localApi.contextMenu.show(
421-
[{ id: "add-to-chat", label: "Add to chat" }],
437+
TERMINAL_SELECTION_ACTION_MENU_ITEMS,
422438
nextAction.position,
423439
);
424-
if (requestId !== selectionActionRequestIdRef.current || clicked !== "add-to-chat") {
440+
if (requestId !== selectionActionRequestIdRef.current || clicked === null) {
425441
return;
426442
}
427-
handleAddTerminalContext(nextAction.selection);
428-
terminalRef.current?.clearSelection();
429-
terminalRef.current?.focus();
443+
switch (clicked) {
444+
case "add-to-chat":
445+
handleAddTerminalContext(nextAction.selection);
446+
terminalRef.current?.clearSelection();
447+
terminalRef.current?.focus();
448+
return;
449+
case "copy":
450+
try {
451+
await copyTerminalSelectionTextToClipboard(nextAction.selection.text);
452+
} catch (error) {
453+
const activeTerminal = terminalRef.current;
454+
if (activeTerminal) {
455+
writeSystemMessage(
456+
activeTerminal,
457+
error instanceof Error ? error.message : "Unable to copy terminal selection",
458+
);
459+
}
460+
}
461+
terminalRef.current?.focus();
462+
return;
463+
}
430464
} finally {
431465
selectionActionOpenRef.current = false;
432466
}

0 commit comments

Comments
 (0)