fix(app): fall back to native terminal copy/paste without the Clipboard API (#8230)

Browsers omit navigator.clipboard outside secure contexts, such as the daemon web UI served over plain http on a Tailscale or LAN address. The non-Mac Ctrl+C/Ctrl+V terminal shortcuts then threw synchronously, and Ctrl+V had already prevented the native paste, so terminal copy/paste broke. When the Clipboard API is unavailable, the shortcuts now leave the default action alone so xterm's native copy/paste event handlers do the work, and the browser clipboard rejects instead of throwing.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
QTom
2026-10-31 17:01:27 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent eab9ac3b03
commit 58eafd81e5
2 changed files with 140 additions and 19 deletions
@@ -82,6 +82,7 @@ import {
createTerminalResizeEvent,
encodeTerminalOutput,
TerminalEmulatorRuntime,
type TerminalClipboard,
} from "./terminal-emulator-runtime";
interface StubTerminal {
@@ -638,3 +639,97 @@ describe("terminal-emulator-runtime", () => {
expect(fitAndEmitResize).not.toHaveBeenCalled();
});
});
describe("terminal-emulator-runtime clipboard shortcuts", () => {
type KeyHandler = (event: KeyboardEvent) => boolean;
function createClipboard(available: boolean) {
return {
isAvailable: () => available,
readText: vi.fn(async () => "clipboard text"),
read: vi.fn(async () => []),
writeText: vi.fn(async (_text: string) => undefined),
} satisfies TerminalClipboard;
}
function attachKeyHandler(input: { clipboard: TerminalClipboard; selection: string }) {
const runtime = new TerminalEmulatorRuntime({ isMac: false }, input.clipboard);
let handler: KeyHandler | null = null;
const paste = vi.fn();
runtime.attachKeyEventHandler({
attachCustomKeyEventHandler: (next: KeyHandler) => {
handler = next;
},
hasSelection: () => input.selection.length > 0,
getSelection: () => input.selection,
paste,
input: vi.fn(),
scrollToBottom: vi.fn(),
});
if (!handler) throw new Error("key handler was not attached");
return { handler: handler as KeyHandler, paste };
}
function ctrlKey(key: string) {
const preventDefault = vi.fn();
const event = {
type: "keydown",
key,
isComposing: false,
ctrlKey: true,
shiftKey: false,
altKey: false,
metaKey: false,
preventDefault,
stopPropagation: vi.fn(),
} as unknown as KeyboardEvent;
return { event, preventDefault };
}
it("leaves Ctrl+C with a selection to the native copy event without the Clipboard API", () => {
const clipboard = createClipboard(false);
const { handler } = attachKeyHandler({ clipboard, selection: "selected text" });
const { event, preventDefault } = ctrlKey("c");
expect(handler(event)).toBe(false);
expect(clipboard.writeText).not.toHaveBeenCalled();
expect(preventDefault).not.toHaveBeenCalled();
});
it("leaves Ctrl+V to the native paste event without the Clipboard API", () => {
const clipboard = createClipboard(false);
const { handler, paste } = attachKeyHandler({ clipboard, selection: "" });
const { event, preventDefault } = ctrlKey("v");
expect(handler(event)).toBe(false);
expect(clipboard.readText).not.toHaveBeenCalled();
expect(preventDefault).not.toHaveBeenCalled();
expect(paste).not.toHaveBeenCalled();
});
it("lets xterm send ^C for Ctrl+C without a selection when the Clipboard API is missing", () => {
const clipboard = createClipboard(false);
const { handler } = attachKeyHandler({ clipboard, selection: "" });
const { event, preventDefault } = ctrlKey("c");
expect(handler(event)).toBe(true);
expect(clipboard.writeText).not.toHaveBeenCalled();
expect(preventDefault).not.toHaveBeenCalled();
});
it("copies and pastes through the Clipboard API when it is available", async () => {
const clipboard = createClipboard(true);
const { handler, paste } = attachKeyHandler({ clipboard, selection: "selected text" });
const copy = ctrlKey("c");
expect(handler(copy.event)).toBe(false);
expect(clipboard.writeText).toHaveBeenCalledWith("selected text");
expect(copy.preventDefault).not.toHaveBeenCalled();
const pasteKey = ctrlKey("v");
expect(handler(pasteKey.event)).toBe(false);
expect(pasteKey.preventDefault).toHaveBeenCalledTimes(1);
expect(clipboard.readText).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(paste).toHaveBeenCalledWith("clipboard text"));
});
});
@@ -182,15 +182,26 @@ function withOverviewRulerBorderHidden(theme: ITheme): ITheme {
}
export interface TerminalClipboard {
/** Whether the async Clipboard API is usable. Browsers omit it outside secure contexts. */
isAvailable?(): boolean;
readText(): Promise<string>;
read(): Promise<readonly Pick<ClipboardItem, "types" | "getType">[]>;
writeText(text: string): Promise<void>;
}
function getBrowserClipboard(): Clipboard | undefined {
return typeof navigator !== "undefined" ? navigator.clipboard : undefined;
}
function rejectUnavailableClipboard(): Promise<never> {
return Promise.reject(new Error("Clipboard API is unavailable"));
}
const browserClipboard: TerminalClipboard = {
readText: () => navigator.clipboard.readText(),
read: () => navigator.clipboard.read(),
writeText: (text) => navigator.clipboard.writeText(text),
isAvailable: () => !!getBrowserClipboard(),
readText: () => getBrowserClipboard()?.readText() ?? rejectUnavailableClipboard(),
read: () => getBrowserClipboard()?.read() ?? rejectUnavailableClipboard(),
writeText: (text) => getBrowserClipboard()?.writeText(text) ?? rejectUnavailableClipboard(),
};
async function readClipboardImage(clipboard: TerminalClipboard): Promise<TerminalFile | null> {
@@ -368,22 +379,7 @@ export class TerminalEmulatorRuntime {
!event.altKey &&
!event.metaKey
) {
const key = event.key.toLowerCase();
// Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT
if (key === "c" && terminal.hasSelection()) {
void this.clipboard.writeText(terminal.getSelection());
return false;
}
// Ctrl+V: paste clipboard text, or the clipboard image when there is no text
if (key === "v") {
event.preventDefault();
this.pasteClipboard(terminal);
return false;
}
return true;
return !this.handleCtrlClipboardShortcut(terminal, event);
}
if (this.maybeSendMacEditingShortcut(terminal, event)) {
@@ -432,6 +428,36 @@ export class TerminalEmulatorRuntime {
});
}
/** Returns true when a non-Mac Ctrl+C / Ctrl+V clipboard shortcut was handled. */
private handleCtrlClipboardShortcut(
terminal: Pick<Terminal, "hasSelection" | "getSelection" | "paste">,
event: KeyboardEvent,
): boolean {
const key = event.key.toLowerCase();
// Without the Clipboard API (e.g. plain http on a LAN address), the browser's native
// copy/paste events still fire and xterm handles them, so leave the default action.
const clipboardAvailable = this.clipboard.isAvailable?.() ?? true;
// Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT
if (key === "c" && terminal.hasSelection()) {
if (clipboardAvailable) {
void this.clipboard.writeText(terminal.getSelection());
}
return true;
}
// Ctrl+V: paste clipboard text, or the clipboard image when there is no text
if (key === "v") {
if (clipboardAvailable) {
event.preventDefault();
this.pasteClipboard(terminal);
}
return true;
}
return false;
}
private handleFindShortcut(event: KeyboardEvent): boolean {
if (!isFindShortcut(event, this.options) || !this.callbacks.onFindRequest) return false;
event.preventDefault();