diff --git a/src/client/TerminalView.tsx b/src/client/TerminalView.tsx index 5c476047..34c8b57e 100644 --- a/src/client/TerminalView.tsx +++ b/src/client/TerminalView.tsx @@ -43,6 +43,7 @@ import { api, type SessionScope, type TerminalDepsStatus } from './api.ts' import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts' import { isDarkScheme, subscribeColorScheme, effectiveTokenValue, tokenValue } from './theme.ts' import { resolveTerminalFont } from './terminal-font.ts' +import { handleTerminalCopyKeyEvent } from './terminal-keybindings.ts' import { buildTerminalLinks, shouldActivateTerminalLink, @@ -135,6 +136,13 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store: }) const fit = new FitAddon() term.loadAddon(fit) + // Match native terminal ergonomics on Windows/Linux: Ctrl+C copies an + // active selection, but remains ETX/SIGINT when nothing is selected. + // Ctrl+Shift+C cannot serve as the browser fallback because Chromium + // reserves it for DevTools before xterm receives the event. + term.attachCustomKeyEventHandler(event => ( + handleTerminalCopyKeyEvent(event, term, writeClipboard) + )) // Ctrl+Click (Cmd+Click on mac) opens http(s) URLs printed in the // pty stream — a plain click is left for xterm's text-selection // gesture. Only http(s) is dispatched; file:// / mailto: / etc. are diff --git a/src/client/terminal-keybindings.ts b/src/client/terminal-keybindings.ts new file mode 100644 index 00000000..28336e0d --- /dev/null +++ b/src/client/terminal-keybindings.ts @@ -0,0 +1,41 @@ +/** + * Terminal keyboard overrides kept outside TerminalView so their modifier + * gates and side effects can be unit-tested without mounting xterm. + */ + +/** The subset of xterm used by the selection-copy shortcut. */ +export interface TerminalSelectionSource { + hasSelection(): boolean + getSelection(): string +} + +/** Clipboard writer shape shared with dsh-client-ui-primitives. */ +export type ClipboardWriter = (text: string) => Promise + +/** + * Copy an active terminal selection on plain Ctrl+C. + * + * Returning false tells xterm not to translate the key into ETX/SIGINT. When + * there is no selection (or another modifier is present), returning true + * preserves the terminal's normal key handling. + */ +export function handleTerminalCopyKeyEvent( + event: KeyboardEvent, + terminal: TerminalSelectionSource, + writeClipboard: ClipboardWriter, +): boolean { + const isCopy = event.type === 'keydown' + && event.ctrlKey + && !event.shiftKey + && !event.altKey + && !event.metaKey + && event.key.toLowerCase() === 'c' + && terminal.hasSelection() + + if (!isCopy) return true + + event.preventDefault() + event.stopPropagation() + void writeClipboard(terminal.getSelection()) + return false +} diff --git a/tests/terminal-keybindings.spec.ts b/tests/terminal-keybindings.spec.ts new file mode 100644 index 00000000..c54fa3c0 --- /dev/null +++ b/tests/terminal-keybindings.spec.ts @@ -0,0 +1,71 @@ +/** + * Terminal Ctrl+C selection-copy regression tests (issue #465): copying an + * active selection must not send ETX to the shell, while every non-matching + * key path remains under xterm's normal handling. + */ +import { describe, expect, it, vi } from 'vitest' +import { + handleTerminalCopyKeyEvent, + type TerminalSelectionSource, +} from '../src/client/terminal-keybindings.ts' + +function keyboardEvent(overrides: Partial = {}): KeyboardEvent { + return { + type: 'keydown', + key: 'c', + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + ...overrides, + } as unknown as KeyboardEvent +} + +function terminal(selected: boolean, text = 'selected output'): TerminalSelectionSource { + return { + hasSelection: vi.fn(() => selected), + getSelection: vi.fn(() => text), + } +} + +describe('handleTerminalCopyKeyEvent', () => { + it.each(['c', 'C'])('copies an active selection for Ctrl+%s and prevents xterm from sending ETX', (key) => { + const event = keyboardEvent({ key }) + const source = terminal(true) + const writeClipboard = vi.fn(async () => true) + + expect(handleTerminalCopyKeyEvent(event, source, writeClipboard)).toBe(false) + expect(writeClipboard).toHaveBeenCalledWith('selected output') + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(event.stopPropagation).toHaveBeenCalledOnce() + }) + + it('leaves Ctrl+C to xterm when there is no selection', () => { + const event = keyboardEvent() + const source = terminal(false) + const writeClipboard = vi.fn(async () => true) + + expect(handleTerminalCopyKeyEvent(event, source, writeClipboard)).toBe(true) + expect(writeClipboard).not.toHaveBeenCalled() + expect(event.preventDefault).not.toHaveBeenCalled() + expect(event.stopPropagation).not.toHaveBeenCalled() + }) + + it.each([ + ['keyup', { type: 'keyup' }], + ['missing Ctrl', { ctrlKey: false }], + ['Shift', { shiftKey: true }], + ['Alt', { altKey: true }], + ['Meta', { metaKey: true }], + ['another key', { key: 'v' }], + ] satisfies Array<[string, Partial]>)('does not intercept %s', (_name, overrides) => { + const event = keyboardEvent(overrides) + const source = terminal(true) + const writeClipboard = vi.fn(async () => true) + + expect(handleTerminalCopyKeyEvent(event, source, writeClipboard)).toBe(true) + expect(writeClipboard).not.toHaveBeenCalled() + }) +})