From d135ecc2d9043868c1d4b95a79fc29640485367e Mon Sep 17 00:00:00 2001 From: Bailey Pumfleet Date: Thu, 15 Oct 2026 13:46:30 -0500 Subject: [PATCH] fix(app): copying from a terminal no longer plays the mac alert sound, and copy on select is honoured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A terminal's selection is drawn by the terminal, not the page's, so ⌘C fell through to the menu bar's Copy, which found nothing and beeped. ⌘C now copies the terminal's selection itself (words selected on the page still copy as ever), and ⌘A selects all its text instead of typing "a". Settings → Terminal → Copy on select was never wired: ghostty-web copied every selection and xterm.js none. TerminalView now copies a pointer selection only when it's on (off by default), for both renderers. Co-Authored-By: Claude Opus 5.5 --- app/e2e/terminal-keys.spec.ts | 56 +++++++++++++++++++ .../components/workspace/terminal-view.tsx | 53 +++++++++++++----- app/src/lib/terminal-keys.test.ts | 19 ++++++- app/src/lib/terminal-keys.ts | 25 ++++++++- app/src/lib/terminal.ts | 13 +++++ docs/changelog.mdx | 12 +++- docs/guides/terminals.mdx | 7 ++- 7 files changed, 167 insertions(+), 18 deletions(-) diff --git a/app/e2e/terminal-keys.spec.ts b/app/e2e/terminal-keys.spec.ts index 174ac60f..ad2befe7 100644 --- a/app/e2e/terminal-keys.spec.ts +++ b/app/e2e/terminal-keys.spec.ts @@ -48,3 +48,59 @@ for (const renderer of ["ghostty", "xterm"] as const) { await expect.poll(() => sent(page)).toBe("\x1b[13;2u\x1bb\x1bf\x01\x05\x15\r"); }); } + +// ⌘A selects the terminal's text and ⌘C copies it. The selection is drawn +// by the terminal, not the page's, so the menu bar's Copy found nothing and +// macOS beeped; the terminal takes the keys now, and sends nothing for them. +for (const renderer of ["ghostty", "xterm"] as const) { + test(`${renderer}: ⌘A and ⌘C copy the terminal's text`, async ({ app }) => { + mockOnly("the shell is on the mock's gpu box"); + await app.open({ prefs: { terminal: { renderer } } }); + const page = app.page; + await openShell(page); + await page.evaluate(() => { + const w = window as { __berthSent?: string[]; __copied?: string[] }; + w.__berthSent = []; + w.__copied = []; + navigator.clipboard.writeText = async (text: string) => void w.__copied!.push(text); + }); + + await page.keyboard.press("Meta+KeyA"); + await page.keyboard.press("Meta+KeyC"); + + await expect.poll(() => page.evaluate(() => ((window as { __copied?: string[] }).__copied ?? []).join("\n"))).toContain("me@gpu:~$"); + expect(await sent(page)).toBe(""); + }); +} + +// Copy on select (Settings → Terminal) is off unless turned on: a selection +// made with the pointer leaves the clipboard alone, and turned on, it's +// copied once the pointer lets go. +for (const renderer of ["ghostty", "xterm"] as const) { + for (const copyOnSelect of [false, true]) { + test(`${renderer}: selecting with the pointer ${copyOnSelect ? "copies with" : "leaves the clipboard without"} Copy on select`, async ({ app }) => { + mockOnly("the shell is on the mock's gpu box"); + await app.open({ prefs: { copyOnSelect, terminal: { renderer } } }); + const page = app.page; + await openShell(page); + await page.evaluate(() => { + const w = window as { __copied?: string[] }; + w.__copied = []; + navigator.clipboard.writeText = async (text: string) => void w.__copied!.push(text); + }); + const box = await page.locator("[data-testid=pane]:visible [data-terminal]").locator(renderer === "ghostty" ? "canvas" : ".xterm-screen").first().boundingBox(); + if (!box) throw new Error("no terminal canvas"); + await page.mouse.move(box.x + 4, box.y + 6); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2, box.y + 30, { steps: 8 }); + await page.mouse.up(); + + const copied = () => page.evaluate(() => ((window as { __copied?: string[] }).__copied ?? []).join("\n")); + if (copyOnSelect) await expect.poll(copied).toContain("Last login"); + else { + await page.waitForTimeout(300); + expect(await copied()).toBe(""); + } + }); + } +} diff --git a/app/src/components/workspace/terminal-view.tsx b/app/src/components/workspace/terminal-view.tsx index 53dc47c9..54932326 100644 --- a/app/src/components/workspace/terminal-view.tsx +++ b/app/src/components/workspace/terminal-view.tsx @@ -9,7 +9,7 @@ import { useActiveTheme } from "@/hooks/use-theme"; import { ApiError, type TerminalConnection } from "@/lib/api"; import { attachable, localPaths, named, onThisComputer, pastedFiles, shrinkImage, uploadAttachment, uploadLocalFile } from "@/lib/attachments"; import { copyText } from "@/lib/clipboard"; -import { IS_LINUX, IS_MAC, platformKeys } from "@/lib/platform"; +import { IS_MAC, platformKeys } from "@/lib/platform"; import { usePrefs } from "@/lib/prefs"; import { tryNow } from "@/lib/reconnect"; import { useStore } from "@/lib/store"; @@ -18,7 +18,7 @@ import { findPaths, resolveIn } from "@/lib/editor-paths"; import { OVERLAYS } from "@/lib/overlays"; import { EchoPredictor } from "@/lib/predict-overlay"; import { createTerminal, type TermHandle } from "@/lib/terminal"; -import { terminalKeyInput } from "@/lib/terminal-keys"; +import { terminalClipboardKey, terminalKeyInput } from "@/lib/terminal-keys"; import { cn } from "@/lib/utils"; import { WheelBatcher, wheelPixels } from "@/lib/wheel"; @@ -259,31 +259,58 @@ export function TerminalView({ box, session, agent, command, wsKey, tab, pane, v e.preventDefault(); void take(fs); }; - // On Linux, Ctrl+Shift+C and Ctrl+Shift+V copy and paste, as in other - // Linux terminals; Ctrl+C and Ctrl+V are the shell's. + // Copies the terminal's selection without a word: copying is what the + // key or the pointer was for. + const copySelection = () => { + const text = term.selection(); + if (text) void navigator.clipboard.writeText(text).catch(() => copyText(text)); + }; + // Copy and select all are the terminal's, and on Linux paste too + // (lib/terminal-keys). A key the terminal takes is one macOS doesn't + // beep at. const onKey = (e: KeyboardEvent) => { - if (!IS_LINUX || !e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return; - if (e.code === "KeyC") { - e.preventDefault(); - e.stopPropagation(); - const text = term.selection(); - if (text) void navigator.clipboard.writeText(text).catch(() => copyText(text)); - } else if (e.code === "KeyV") { - e.preventDefault(); - e.stopPropagation(); + const k = terminalClipboardKey(e, IS_MAC); + if (!k) return; + // Words selected on the page (not in the terminal) copy as ever. + if (k === "copy" && !term.hasSelection() && window.getSelection()?.toString()) return; + e.preventDefault(); + e.stopPropagation(); + if (k === "select-all") term.selectAll(); + else if (k === "copy") copySelection(); + else { void navigator.clipboard.readText().then( (text) => text && term.paste(text), () => toastManager.add({ type: "error", title: "Couldn't paste", description: "The clipboard refused it." }), ); } }; + // Copy on select (Settings → Terminal): what the pointer selected is + // copied once it lets go, wherever that is, or on a double-click's word. + // Off, selecting leaves the clipboard as it was. + let pressed = false; + const onDown = () => { + pressed = true; + }; + const onUp = () => { + if (!pressed) return; + pressed = false; + if (usePrefs.getState().copyOnSelect && term.hasSelection()) copySelection(); + }; el.addEventListener("keydown", onKey, true); el.addEventListener("paste", onPaste, true); + el.addEventListener("mousedown", onDown, true); + el.addEventListener("dblclick", onDown, true); + el.addEventListener("dblclick", onUp); + document.addEventListener("mouseup", onUp); el.addEventListener("dragover", onDragOver); el.addEventListener("drop", onDrop); return () => { el.removeEventListener("keydown", onKey, true); el.removeEventListener("paste", onPaste, true); + el.removeEventListener("mousedown", onDown, true); + el.removeEventListener("dblclick", onDown, true); + el.removeEventListener("dblclick", onUp); + document.removeEventListener("mouseup", onUp); el.removeEventListener("dragover", onDragOver); el.removeEventListener("drop", onDrop); }; diff --git a/app/src/lib/terminal-keys.test.ts b/app/src/lib/terminal-keys.test.ts index a31fd5c7..14aaeb3c 100644 --- a/app/src/lib/terminal-keys.test.ts +++ b/app/src/lib/terminal-keys.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { type TermKey, terminalKeyInput } from "./terminal-keys.ts"; +import { type TermKey, terminalClipboardKey, terminalKeyInput } from "./terminal-keys.ts"; const key = (k: string, mods: Partial> = {}): TermKey => ({ key: k, @@ -41,3 +41,20 @@ test("other keys, the app's ⌘⌥ arrows and Linux's keys stay as they were", ( test("a key being composed (an input method) is left alone", () => { assert.equal(terminalKeyInput(key("Enter", { shiftKey: true, isComposing: true }), true), undefined); }); + +test("on a Mac, ⌘C copies and ⌘A selects all in a terminal; ⌘V is the menu bar's", () => { + assert.equal(terminalClipboardKey(key("c", { metaKey: true }), true), "copy"); + assert.equal(terminalClipboardKey(key("a", { metaKey: true }), true), "select-all"); + assert.equal(terminalClipboardKey(key("v", { metaKey: true }), true), undefined); + assert.equal(terminalClipboardKey(key("c", { metaKey: true, shiftKey: true }), true), undefined); + assert.equal(terminalClipboardKey(key("c", { ctrlKey: true }), true), undefined); + assert.equal(terminalClipboardKey(key("c", { metaKey: true, isComposing: true }), true), undefined); +}); + +test("on Linux, Ctrl+Shift+C and Ctrl+Shift+V copy and paste; Ctrl+C is the shell's", () => { + assert.equal(terminalClipboardKey(key("C", { code: "KeyC", ctrlKey: true, shiftKey: true }), false), "copy"); + assert.equal(terminalClipboardKey(key("V", { code: "KeyV", ctrlKey: true, shiftKey: true }), false), "paste"); + assert.equal(terminalClipboardKey(key("c", { code: "KeyC", ctrlKey: true }), false), undefined); + assert.equal(terminalClipboardKey(key("a", { code: "KeyA", ctrlKey: true, shiftKey: true }), false), undefined); + assert.equal(terminalClipboardKey(key("c", { code: "KeyC", metaKey: true }), false), undefined); +}); diff --git a/app/src/lib/terminal-keys.ts b/app/src/lib/terminal-keys.ts index 0da9307a..bc93dbd3 100644 --- a/app/src/lib/terminal-keys.ts +++ b/app/src/lib/terminal-keys.ts @@ -12,7 +12,7 @@ // deletes back to its start (Ctrl+U). Mac only: on Linux, Ctrl stands in // for ⌘ and its keys stay the shell's. -export type TermKey = Pick; +export type TermKey = Pick & Partial>; const SHIFT_ENTER = "\x1b[13;2u"; @@ -31,3 +31,26 @@ export function terminalKeyInput(e: TermKey, mac: boolean): string | undefined { if (only(false, true, false, false)) return MAC_OPTION[e.key]; return undefined; } + +export type ClipboardKey = "copy" | "paste" | "select-all"; + +// terminalClipboardKey is the copy, paste or select-all a keydown is in a +// terminal, or undefined to leave the key alone. +// +// - On a Mac, ⌘C copies the terminal's selection and ⌘A selects all its +// text. The selection is drawn by the terminal, not the page's, so the +// menu bar's Copy found nothing to copy and macOS played its alert +// sound. ⌘V stays the menu bar's Paste, which the terminal takes as the +// page's paste. +// - On Linux, Ctrl+Shift+C and Ctrl+Shift+V copy and paste, as in other +// Linux terminals; Ctrl+C and Ctrl+V are the shell's. +export function terminalClipboardKey(e: TermKey, mac: boolean): ClipboardKey | undefined { + if (e.isComposing) return undefined; + if (mac) { + if (!e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return undefined; + const k = e.key.toLowerCase(); + return k === "c" ? "copy" : k === "a" ? "select-all" : undefined; + } + if (!e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return undefined; + return e.code === "KeyC" ? "copy" : e.code === "KeyV" ? "paste" : undefined; +} diff --git a/app/src/lib/terminal.ts b/app/src/lib/terminal.ts index 0cfac1d5..6e897e20 100644 --- a/app/src/lib/terminal.ts +++ b/app/src/lib/terminal.ts @@ -57,6 +57,8 @@ export interface TermHandle { hasSelection(): boolean; // The selected text ("" with none). selection(): string; + // Selects all the text, scrollback included. + selectAll(): void; // Types text as a paste: bracketed when the program asked for that, as // Claude Code does, so it sees a pasted image's path as an image. paste(text: string): void; @@ -220,6 +222,7 @@ function gated(t: RawHandle, hiddenFlushMs = HIDDEN_FLUSH_MS): TermHandle { onResize: (fn) => t.onResize(fn), hasSelection: () => t.hasSelection(), selection: () => t.selection(), + selectAll: () => t.selectAll(), paste: (text) => t.paste(text), registerLinkFinder: (find) => t.registerLinkFinder(find), onLinkHint: (fn) => t.onLinkHint(fn), @@ -523,6 +526,11 @@ async function createGhostty(host: HTMLElement, colors: TerminalColors, prefs: T hung.stop(); delete (t as { focus?: unknown }).focus; } + // ghostty-web copies every selection the pointer makes. Whether to is + // Settings → Terminal's Copy on select, which TerminalView keeps for + // both renderers. + const sm = (t as unknown as { selectionManager?: { copyToClipboard?: (text: string) => Promise } }).selectionManager; + if (sm?.copyToClipboard) sm.copyToClipboard = async () => {}; keepLastColumn(t); oneReadPerFrame(t); const frames = drawOnDemand(t, host, prefs.cursorBlink); @@ -570,6 +578,10 @@ async function createGhostty(host: HTMLElement, colors: TerminalColors, prefs: T onResize: (fn) => void t.onResize(fn), hasSelection: () => t.hasSelection(), selection: () => t.getSelection(), + selectAll: () => { + t.selectAll(); + frames?.kick(); + }, paste: (text) => { t.paste(text); frames?.kick(); @@ -901,6 +913,7 @@ async function createXterm(host: HTMLElement, colors: TerminalColors, prefs: Ter onResize: (fn) => void t.onResize(fn), hasSelection: () => t.hasSelection(), selection: () => t.getSelection(), + selectAll: () => t.selectAll(), paste: (text) => t.paste(text), screen: () => xtermScreen(t), geometry: () => { diff --git a/docs/changelog.mdx b/docs/changelog.mdx index f9d50dc8..333a653c 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -9,7 +9,17 @@ upgrades with `berth upgrade BOX`, or by running the [install command](/getting-started/add-a-box#with-the-install-command) again. - +## Unreleased +- **Copying from a terminal is quiet.** ⌘C with text selected in a + terminal copied it but played the Mac's alert sound. It copies without a + sound now, and ⌘A selects all of a terminal's text instead of typing an + "a". + [Terminals](/guides/terminals#keys) +- **Copy on select does what it says.** The setting in Settings → Terminal + was ignored: with the Ghostty renderer every selection replaced what you'd + copied, and with xterm.js none did. Now it's off unless you turn it on, so + selecting text in a terminal leaves your clipboard alone until you press + ⌘C. ## v0.3.22 - **Mac terminal keys work in terminals.** ⇧↵ starts a new line in Claude diff --git a/docs/guides/terminals.mdx b/docs/guides/terminals.mdx index bd913d7c..769091f8 100644 --- a/docs/guides/terminals.mdx +++ b/docs/guides/terminals.mdx @@ -21,7 +21,7 @@ update as you change it: | Cursor | Block, bar or underline, blinking or not. | | Scrollback | Lines kept in the app. The box's tmux keeps 50,000 more, redrawn when you reattach. | | Predict typing | Adaptive, Always or Never: see below. | -| Copy on select | Selecting text copies it. | +| Copy on select | Selecting text with the pointer copies it. Off by default: select, then ⌘C. | | Renderer | Ghostty or xterm.js. If Ghostty can't start, the app says why here. | ## Keys @@ -34,9 +34,12 @@ Keys do what they do in a Mac terminal (Ghostty's and iTerm2's defaults): | ⌥← ⌥→ | Move back or forward a word. | | ⌘← ⌘→ | Go to the start or end of the line. | | ⌘⌫ | Delete back to the start of the line. | +| ⌘C | Copy the selected text. | +| ⌘A | Select all the text, scrollback included. | +| ⌘V | Paste. A pasted image or file goes up to the box and its path is typed. | In the Linux app, Ctrl stands in for ⌘ and its keys go to the shell, so -only ⇧↵ changes there. +only ⇧↵ changes there; Ctrl+Shift+C and Ctrl+Shift+V copy and paste. ## Typing on a slow link