Skip to content

Commit eb0e487

Browse files
feat(web): copy terminal selections automatically
1 parent b883fc0 commit eb0e487

3 files changed

Lines changed: 43 additions & 0 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,7 @@ export function TerminalViewport({
827827
if (!shouldHandle) {
828828
return;
829829
}
830+
terminalRef.current?.copySelectionToClipboard();
830831
selectionPointerRef.current = { x: event.clientX, y: event.clientY };
831832
const delay = terminalSelectionActionDelayForClickCount(event.detail);
832833
selectionActionTimerRef.current = window.setTimeout(() => {

‎apps/web/src/terminal/ghostty/surface.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
advanceTerminalSelectionClickSequence,
88
applyTerminalCopyEvent,
99
clearPrimedTerminalCopyInput,
10+
copyTerminalSelection,
1011
ghosttyMouseButton,
1112
isTerminalAltGraphText,
1213
isTerminalCompositionCommitInput,
@@ -283,6 +284,33 @@ describe("applyTerminalCopyEvent", () => {
283284
});
284285
});
285286

287+
describe("copyTerminalSelection", () => {
288+
it("primes and copies a non-empty terminal selection", () => {
289+
const input = {
290+
value: "",
291+
select: vi.fn(),
292+
};
293+
const copy = vi.fn(() => true);
294+
295+
expect(copyTerminalSelection(input, "git status", copy)).toBe(true);
296+
expect(input.value).toBe("git status");
297+
expect(input.select).toHaveBeenCalledOnce();
298+
expect(copy).toHaveBeenCalledOnce();
299+
});
300+
301+
it("does not copy an empty terminal selection", () => {
302+
const input = {
303+
value: "",
304+
select: vi.fn(),
305+
};
306+
const copy = vi.fn(() => true);
307+
308+
expect(copyTerminalSelection(input, "", copy)).toBe(false);
309+
expect(input.select).not.toHaveBeenCalled();
310+
expect(copy).not.toHaveBeenCalled();
311+
});
312+
});
313+
286314
describe("primeTerminalCopyInput", () => {
287315
it("selects the Ghostty selection in the hidden textarea so native copy has text", () => {
288316
const input = {

‎apps/web/src/terminal/ghostty/surface.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,16 @@ export function primeTerminalCopyInput(
350350
input.select();
351351
}
352352

353+
export function copyTerminalSelection(
354+
input: Pick<HTMLTextAreaElement, "value" | "select">,
355+
selection: string,
356+
copy = () => document.execCommand("copy"),
357+
): boolean {
358+
if (selection.length === 0) return false;
359+
primeTerminalCopyInput(input, selection);
360+
return copy();
361+
}
362+
353363
export function clearPrimedTerminalCopyInput(
354364
input: Pick<HTMLTextAreaElement, "value">,
355365
primedSelection: string,
@@ -915,6 +925,10 @@ export class GhosttyTerminalSurface {
915925
return this.core.selectionText();
916926
}
917927

928+
copySelectionToClipboard(): boolean {
929+
return copyTerminalSelection(this.input, this.getSelection());
930+
}
931+
918932
getSelectionPosition(): GhosttySelectionPosition | null {
919933
if (!this.selectionAnchorScreen || !this.selectionEndScreen || !this.hasSelection())
920934
return null;

0 commit comments

Comments
 (0)