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
56 changes: 56 additions & 0 deletions app/e2e/terminal-keys.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,62 @@ for (const renderer of ["ghostty", "xterm"] as const) {
});
}

// ⌘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("");
}
});
}
}

// ghostty-web makes the terminal's host contenteditable and focuses it, so
// without caret-color the browser blinks its own caret, the canvas's height,
// at the left edge beside the terminal's cursor.
Expand Down
53 changes: 40 additions & 13 deletions app/src/components/workspace/terminal-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -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);
};
Expand Down
19 changes: 18 additions & 1 deletion app/src/lib/terminal-keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Omit<TermKey, "key">> = {}): TermKey => ({
key: k,
Expand Down Expand Up @@ -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);
});
25 changes: 24 additions & 1 deletion app/src/lib/terminal-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<KeyboardEvent, "key" | "shiftKey" | "altKey" | "ctrlKey" | "metaKey" | "isComposing">;
export type TermKey = Pick<KeyboardEvent, "key" | "shiftKey" | "altKey" | "ctrlKey" | "metaKey" | "isComposing"> & Partial<Pick<KeyboardEvent, "code">>;

const SHIFT_ENTER = "\x1b[13;2u";

Expand All @@ -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;
}
13 changes: 13 additions & 0 deletions app/src/lib/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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<void> } }).selectionManager;
if (sm?.copyToClipboard) sm.copyToClipboard = async () => {};
// open() makes the host contenteditable and focus() focuses the host, so
// the browser blinked its own text caret there, as tall as the canvas, at
// the terminal's left edge. The terminal draws its own cursor.
Expand Down Expand Up @@ -574,6 +582,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();
Expand Down Expand Up @@ -905,6 +917,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: () => {
Expand Down
13 changes: 13 additions & 0 deletions docs/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ 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.24

- **Sidebar rows hover cleanly.** A row stays lit while the pointer is on its
Expand Down Expand Up @@ -107,6 +119,7 @@ again.
couldn't do that", because git can't clone a commit by its hash as if it
were a branch. Shipyard now fetches the kit at that commit.


## v0.3.22

- **Mac terminal keys work in terminals.** ⇧↵ starts a new line in Claude
Expand Down
7 changes: 5 additions & 2 deletions docs/guides/terminals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
Loading