From cbaa97cb784b3df4dc9592243534a56c882e1fad Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 17 Oct 2025 14:18:50 -0800 Subject: [PATCH] feat: add copy on ctrl/command+shift+c and selection to web terminal (#20129) Closes https://github.com/coder/coder/issues/20044 --- site/src/pages/TerminalPage/TerminalPage.tsx | 48 ++++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/site/src/pages/TerminalPage/TerminalPage.tsx b/site/src/pages/TerminalPage/TerminalPage.tsx index 6e45ed8dce..ed8f9ffa13 100644 --- a/site/src/pages/TerminalPage/TerminalPage.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.tsx @@ -14,6 +14,7 @@ import { } from "api/queries/workspaces"; import { useProxy } from "contexts/ProxyContext"; import { ThemeOverride } from "contexts/ThemeProvider"; +import { useClipboard } from "hooks/useClipboard"; import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; import { type FC, useCallback, useEffect, useRef, useState } from "react"; import { useQuery } from "react-query"; @@ -80,6 +81,8 @@ const TerminalPage: FC = () => { const config = useQuery(deploymentConfig()); const renderer = config.data?.config.web_terminal_renderer; + const { copyToClipboard } = useClipboard(); + // Periodically report workspace usage. useQuery( workspaceUsage({ @@ -147,12 +150,21 @@ const TerminalPage: FC = () => { }), ); - // Make shift+enter send ^[^M (escaped carriage return). Applications - // typically take this to mean to insert a literal newline. There is no way - // to remove this handler, so we must attach it once and rely on a ref to - // send it to the current socket. + const isMac = navigator.platform.match("Mac"); + + const copySelection = () => { + const selection = terminal.getSelection(); + if (selection) { + copyToClipboard(selection); + } + }; + + // There is no way to remove this handler, so we must attach it once and + // rely on a ref to send it to the current socket. const escapedCarriageReturn = "\x1b\r"; terminal.attachCustomKeyEventHandler((ev) => { + // Make shift+enter send ^[^M (escaped carriage return). Applications + // typically take this to mean to insert a literal newline. if (ev.shiftKey && ev.key === "Enter") { if (ev.type === "keydown") { websocketRef.current?.send( @@ -163,9 +175,36 @@ const TerminalPage: FC = () => { } return false; } + // Make ctrl+shift+c (command+shift+c on macOS) copy the selected text. + // By default this usually launches the browser dev tools, but users + // expect this keybinding to copy when in the context of the web terminal. + if ((isMac ? ev.metaKey : ev.ctrlKey) && ev.shiftKey && ev.key === "C") { + ev.preventDefault(); + if (ev.type === "keydown") { + copySelection(); + } + return false; + } return true; }); + // Copy using the clipboard API on selection. This selected text will go + // into the clipboard, not the primary selection, as the browser does not + // give us an API to set the primary selection (only relevant to systems + // that have this distinction, like X11). + // + // We could bind the middle mouse button to paste from the clipboard to + // compensate, but then we would break pasting selections from external + // applications into the web terminal. Not sure which tradeoff is worse; it + // probably varies between users. + // + // In other words, this copied text can be pasted with a keybinding + // (typically ctrl+v, ctrl+shift+v, or shift+insert), but *not* with the + // middle mouse button. + terminal.onSelectionChange(() => { + copySelection(); + }); + terminal.open(terminalWrapperRef.current); // We have to fit twice here. It's unknown why, but the first fit will @@ -189,6 +228,7 @@ const TerminalPage: FC = () => { renderer, theme.palette.background.default, currentTerminalFont, + copyToClipboard, ]); // Updates the reconnection token into the URL if necessary.