From cdd244847945b86693dfe1e03ae5549f9ea0d056 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 10:57:30 +0000 Subject: [PATCH] fix(work): copy/paste with Ctrl+C/V in Windows terminals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+C copies the xterm selection on Windows/Linux and still sends SIGINT when nothing is selected. Ctrl+V pastes through Terminal.paste. macOS ⌘C/⌘V and Ctrl+Shift+C/V stay on existing paths. Co-authored-by: Daniels --- README.md | 3 +- ROADMAP.md | 8 +++ TASKS.md | 2 + docs/learnings.md | 7 +- ui/src/App.tsx | 6 +- ui/src/lib/terminalClipboard.test.ts | 103 +++++++++++++++++++++++++++ ui/src/lib/terminalClipboard.ts | 79 ++++++++++++++++++++ ui/src/views/Work.tsx | 9 +++ website/docs/keyboard-and-palette.md | 10 ++- website/docs/work.md | 11 +-- 10 files changed, 227 insertions(+), 11 deletions(-) create mode 100644 ui/src/lib/terminalClipboard.test.ts create mode 100644 ui/src/lib/terminalClipboard.ts diff --git a/README.md b/README.md index 0d7be2be..81c383f9 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,8 @@ the resolved app appearance automatically. Multiple terminals run at the repository root and keep output, scrollback, and selection across view, repository, and workspace switches, pane splits, and resizes, and full-screen terminal apps receive the fitted PTY - grid. Claude Code starts with its complete dashboard and alternate-screen + grid. On Windows and Linux, Ctrl+C copies a terminal selection (else interrupt) + and Ctrl+V pastes; macOS keeps ⌘C/⌘V. Claude Code starts with its complete dashboard and alternate-screen renderer in a configurable terminal font and size. Work tabs keep their width in a wheel-scrollable strip with an overflow selector and tree-matched file icons, and middle-click closes a tab. Only descriptors restore after relaunch; selecting one starts a fresh process. The New Terminal split button diff --git a/ROADMAP.md b/ROADMAP.md index a1f08ce2..75a9cfae 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2352,6 +2352,14 @@ resolved theme tokens. PowerShell and colorized CLI output remain legible on the light cream surface instead of inheriting bright colors tuned for dark backgrounds. +**DAN-71 Windows/Linux terminal clipboard shipped (2026-09-16):** Embedded Work +terminals copy on Ctrl+C when xterm has a selection and otherwise still send +SIGINT; Ctrl+V pastes through `Terminal.paste`. The handler is +`attachCustomKeyEventHandler` in `TerminalPane` (`terminalClipboardAction`). +Ctrl+Shift+C/V, right-click Edit menu Copy/Paste, and macOS ⌘C/⌘V stay on +their existing paths. App-level shortcuts still do not reclaim Ctrl+C/Ctrl+V +inside `.work-terminal-host`. + **Terminal repository overrides and Windows PowerShell follow-up (2026-07-20):** Settings → Terminal now pairs a repository selector with a shell selector so every open repository family remains configurable without a long override diff --git a/TASKS.md b/TASKS.md index 86625e80..bf14dbb7 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1336,6 +1336,8 @@ community plugins, performance and platform certification from Git feature gaps. - ☑ xterm.js + Fit renderer layer with 5,000-line scrollback, roving tab focus, F6 escape, fast peer cycling, fitted startup/resize PTY synchronization, shell-control ownership, exited transcript, and Relaunch (`Work.tsx`). + Windows/Linux Ctrl+C copies the xterm selection else SIGINT; Ctrl+V pastes + (`attachCustomKeyEventHandler`, `terminalClipboardAction`; DAN-71). Fixed-width tabs wheel-scroll, auto-reveal, expose an overflow selector, and render `TreeFileIcon` symbols. Renderers survive view/repository/workspace switches. Configurable terminal font/10–32px sizing with a live Settings diff --git a/docs/learnings.md b/docs/learnings.md index 9d0ffd37..f7b99c73 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -2212,7 +2212,12 @@ final repository close must confirm first. While xterm owns focus, preserve shell controls, keep Command shortcuts app-owned on macOS and only numbered view navigation plus the fixed Work `Ctrl+PageUp`/`Ctrl+PageDown` peer cycle app-owned on Windows/Linux, with `F6` returning focus to the peer Work tab -strip. Fit xterm before creating the PTY and explicitly synchronize the native +strip. On Windows and Linux, `attachCustomKeyEventHandler` copies with Ctrl+C +when `hasSelection()` is true and otherwise forwards so the PTY still receives +SIGINT; Ctrl+V pastes via `Terminal.paste` and the existing clipboard read +path. Do not reclaim Ctrl+C/Ctrl+V inside `.work-terminal-host` for unrelated +app shortcuts, and leave Ctrl+Shift+C/V plus macOS ⌘C/⌘V on xterm/native Edit +menu paths. Fit xterm before creating the PTY and explicitly synchronize the native grid again once its runtime ID exists; an observer can fire during async startup and otherwise leave a full-screen alternate-screen app on the default 80x24 grid. Claude Code deliberately replaces its complete welcome dashboard with a diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 0ab73ce8..e8a99c1f 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1529,8 +1529,10 @@ export function App() { if (!binding) return; const cmd = keyMapRef.current.byBinding.get(binding); if (!cmd) return; - // The shell owns Ctrl+C/Ctrl+R/Ctrl+P and peers. macOS keeps Command - // shortcuts app-owned; Windows/Linux keep only numbered view navigation. + // The shell owns Ctrl+C/Ctrl+R/Ctrl+P and peers. TerminalPane copies + // Ctrl+C when xterm has a selection and pastes Ctrl+V on Windows/Linux. + // macOS keeps Command shortcuts app-owned; Windows/Linux keep only + // numbered view navigation. if (inEmbeddedTerminal) { if (osType() === 'macos' && !e.metaKey) return; if (osType() !== 'macos' && !/^Mod\+[1-8]$/.test(binding)) return; diff --git a/ui/src/lib/terminalClipboard.test.ts b/ui/src/lib/terminalClipboard.test.ts new file mode 100644 index 00000000..e7912c48 --- /dev/null +++ b/ui/src/lib/terminalClipboard.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + consumeTerminalClipboardKey, + terminalClipboardAction, + type TerminalClipboardKeyEvent, + type TerminalClipboardTerminal, +} from './terminalClipboard'; + +function event( + partial: Partial & Pick, +): TerminalClipboardKeyEvent { + return { + type: 'keydown', + code: partial.key.length === 1 ? `Key${partial.key.toUpperCase()}` : undefined, + ctrlKey: true, + metaKey: false, + altKey: false, + shiftKey: false, + preventDefault: vi.fn(), + ...partial, + }; +} + +function terminal(partial: Partial = {}): TerminalClipboardTerminal { + return { + hasSelection: () => false, + getSelection: () => '', + paste: vi.fn(), + ...partial, + }; +} + +describe('terminalClipboardAction', () => { + it('copies Ctrl+C on Windows when xterm has a selection', () => { + expect(terminalClipboardAction(event({ key: 'c' }), true, 'windows')).toBe('copy'); + }); + + it('forwards Ctrl+C on Windows when there is no selection', () => { + expect(terminalClipboardAction(event({ key: 'c' }), false, 'windows')).toBe('forward'); + }); + + it('pastes Ctrl+V on Windows and Linux', () => { + expect(terminalClipboardAction(event({ key: 'v' }), false, 'windows')).toBe('paste'); + expect(terminalClipboardAction(event({ key: 'v' }), true, 'linux')).toBe('paste'); + }); + + it('leaves Ctrl+Shift+C/V to xterm', () => { + expect(terminalClipboardAction(event({ key: 'c', shiftKey: true }), true, 'windows')).toBe('forward'); + expect(terminalClipboardAction(event({ key: 'v', shiftKey: true }), false, 'windows')).toBe('forward'); + }); + + it('does not claim macOS Ctrl or Command chords', () => { + expect(terminalClipboardAction(event({ key: 'c' }), true, 'macos')).toBe('forward'); + expect(terminalClipboardAction(event({ key: 'v' }), false, 'macos')).toBe('forward'); + expect(terminalClipboardAction(event({ key: 'c', ctrlKey: false, metaKey: true }), true, 'macos')).toBe('forward'); + expect(terminalClipboardAction(event({ key: 'v', ctrlKey: false, metaKey: true }), false, 'macos')).toBe('forward'); + }); + + it('ignores keyup and Alt chords', () => { + expect(terminalClipboardAction(event({ type: 'keyup', key: 'c' }), true, 'windows')).toBe('forward'); + expect(terminalClipboardAction(event({ key: 'c', altKey: true }), true, 'linux')).toBe('forward'); + }); + + it('matches physical KeyC/KeyV when the layout reports a non-Latin key', () => { + expect(terminalClipboardAction(event({ key: 'с', code: 'KeyC' }), true, 'windows')).toBe('copy'); + expect(terminalClipboardAction(event({ key: 'м', code: 'KeyV' }), false, 'windows')).toBe('paste'); + }); +}); + +describe('consumeTerminalClipboardKey', () => { + it('copies the selection and stops xterm from sending SIGINT', () => { + const copy = vi.fn(); + const host = terminal({ hasSelection: () => true, getSelection: () => 'ls -la' }); + const key = event({ key: 'c' }); + expect(consumeTerminalClipboardKey(key, host, 'windows', { copy, read: async () => '' })).toBe(false); + expect(key.preventDefault).toHaveBeenCalled(); + expect(copy).toHaveBeenCalledWith('ls -la'); + }); + + it('forwards a bare Ctrl+C so the PTY still receives interrupt', () => { + const copy = vi.fn(); + const key = event({ key: 'c' }); + expect(consumeTerminalClipboardKey(key, terminal(), 'windows', { copy, read: async () => '' })).toBe(true); + expect(key.preventDefault).not.toHaveBeenCalled(); + expect(copy).not.toHaveBeenCalled(); + }); + + it('pastes clipboard text through xterm and does not forward Ctrl+V', async () => { + const host = terminal(); + const key = event({ key: 'v' }); + expect(consumeTerminalClipboardKey(key, host, 'linux', { copy: vi.fn(), read: async () => 'echo hi' })).toBe(false); + expect(key.preventDefault).toHaveBeenCalled(); + await vi.waitFor(() => expect(host.paste).toHaveBeenCalledWith('echo hi')); + }); + + it('does not paste an empty clipboard', async () => { + const host = terminal(); + consumeTerminalClipboardKey(event({ key: 'v' }), host, 'windows', { copy: vi.fn(), read: async () => '' }); + await Promise.resolve(); + expect(host.paste).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/lib/terminalClipboard.ts b/ui/src/lib/terminalClipboard.ts new file mode 100644 index 00000000..dc305158 --- /dev/null +++ b/ui/src/lib/terminalClipboard.ts @@ -0,0 +1,79 @@ +import type { OsType } from './integrations'; + +export type TerminalClipboardAction = 'copy' | 'paste' | 'forward'; + +export interface TerminalClipboardKeyEvent { + type: string; + key: string; + code?: string; + ctrlKey: boolean; + metaKey: boolean; + altKey: boolean; + shiftKey: boolean; + preventDefault(): void; +} + +export interface TerminalClipboardTerminal { + hasSelection(): boolean; + getSelection(): string; + paste(data: string): void; +} + +export interface TerminalClipboard { + copy(text: string): void; + read(): Promise; +} + +/** VS Code / Windows Terminal: Ctrl+C copies a selection, otherwise SIGINT; + * Ctrl+V pastes. Shift variants and macOS stay on xterm / ⌘ paths. */ +export function terminalClipboardAction( + event: Pick, + hasSelection: boolean, + platform: OsType, +): TerminalClipboardAction { + if (event.type !== 'keydown') return 'forward'; + if (platform === 'macos') return 'forward'; + if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return 'forward'; + const key = event.key.length === 1 ? event.key.toLowerCase() : event.key; + const isC = key === 'c' || event.code === 'KeyC'; + const isV = key === 'v' || event.code === 'KeyV'; + if (isC) return hasSelection ? 'copy' : 'forward'; + if (isV) return 'paste'; + return 'forward'; +} + +/** Returns whether xterm should keep handling the key (`true` = forward to PTY). */ +export function consumeTerminalClipboardKey( + event: TerminalClipboardKeyEvent, + terminal: TerminalClipboardTerminal, + platform: OsType, + clipboard: TerminalClipboard, +): boolean { + const action = terminalClipboardAction(event, terminal.hasSelection(), platform); + switch (action) { + case 'copy': + event.preventDefault(); + clipboard.copy(terminal.getSelection()); + return false; + case 'paste': + event.preventDefault(); + void clipboard.read().then((text) => { + if (text) terminal.paste(text); + }); + return false; + case 'forward': + return true; + default: { + const _exhaustive: never = action; + return _exhaustive; + } + } +} + +export function readClipboardText(): Promise { + if (!navigator.clipboard?.readText) return Promise.resolve(''); + return navigator.clipboard.readText().catch((error) => { + console.warn('clipboard read failed', error); + return ''; + }); +} diff --git a/ui/src/views/Work.tsx b/ui/src/views/Work.tsx index 5693bbab..e6afbc08 100644 --- a/ui/src/views/Work.tsx +++ b/ui/src/views/Work.tsx @@ -16,12 +16,15 @@ import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import '@xterm/xterm/css/xterm.css'; import { Icon } from '../components/Icon'; +import { copyToClipboard } from '../components/PierreTree'; import { TreeFileIcon, TreeIconSprite } from '../components/TreeFileIcon'; import { repoEmbeddedShell } from '../lib/db'; import { embeddedShellOptions } from '../lib/embeddedShell'; import { t } from '../lib/i18n'; +import { osType } from '../lib/integrations'; import { repoFamilyName } from '../lib/repoIdentity'; import { errMessage, tauri } from '../lib/tauri'; +import { consumeTerminalClipboardKey, readClipboardText } from '../lib/terminalClipboard'; import { terminalTheme } from '../lib/terminalTheme'; import type { EmbeddedShellChoice, TerminalEvent } from '../lib/types'; import { @@ -1154,6 +1157,12 @@ function TerminalPane({ pendingInput.current = (pendingInput.current + value).slice(-64 * 1024); } }); + instance.attachCustomKeyEventHandler((event) => consumeTerminalClipboardKey( + event, + instance, + osType(), + { copy: copyToClipboard, read: readClipboardText }, + )); if (visible && tab.lifecycle === 'dormant') void start(); }; if (document.fonts) void document.fonts.load(fontSpec).finally(createRenderer); diff --git a/website/docs/keyboard-and-palette.md b/website/docs/keyboard-and-palette.md index 796112ea..2ebd19a4 100644 --- a/website/docs/keyboard-and-palette.md +++ b/website/docs/keyboard-and-palette.md @@ -129,9 +129,13 @@ the current tab to the previous or next pane, or create a new pane to the left, right, above, or below—the keyboard equivalents of dragging a Work tab. When an embedded terminal owns focus, shell controls such as `Ctrl+C` and -`Ctrl+R` go to the shell. Command shortcuts remain app-owned on macOS; on -Windows and Linux the numbered view shortcuts and the fixed Work-tab -`Ctrl+PageUp`/`Ctrl+PageDown` pair remain app-owned. +`Ctrl+R` go to the shell. On Windows and Linux, `Ctrl+C` copies the current +xterm selection to the clipboard instead of interrupting, and `Ctrl+V` pastes; +a bare `Ctrl+C` with no selection still sends SIGINT. `Ctrl+Shift+C` / +`Ctrl+Shift+V` and the native Edit menu Copy/Paste (including right-click) +keep working. Command shortcuts remain app-owned on macOS (`⌘C` / `⌘V` copy +and paste); on Windows and Linux the numbered view shortcuts and the fixed +Work-tab `Ctrl+PageUp`/`Ctrl+PageDown` pair remain app-owned. ### Composed Workbench diff --git a/website/docs/work.md b/website/docs/work.md index 8202ad68..5832515b 100644 --- a/website/docs/work.md +++ b/website/docs/work.md @@ -126,10 +126,13 @@ one of several owning workspaces, hiding a workspace, or switching away does not stop them. App exit drains every terminal without prompting. While a terminal is focused, shell controls such as Ctrl+C, Ctrl+R, and Ctrl+P -remain shell-owned. On macOS, Command shortcuts remain app-owned. On Windows -and Linux, numbered view navigation and the fixed Work-tab -`Ctrl+PageUp`/`Ctrl+PageDown` shortcuts remain app-owned. Press `F6` to return -focus to the Work tab strip. +remain shell-owned. On Windows and Linux, Ctrl+C copies the xterm selection +when one exists (otherwise it still interrupts the foreground process) and +Ctrl+V pastes from the clipboard; Ctrl+Shift+C / Ctrl+Shift+V and the native +Edit menu Copy/Paste continue to work. On macOS, Command shortcuts remain +app-owned (`⌘C` / `⌘V` copy and paste). On Windows and Linux, numbered view +navigation and the fixed Work-tab `Ctrl+PageUp`/`Ctrl+PageDown` shortcuts +remain app-owned. Press `F6` to return focus to the Work tab strip. ## Shell settings