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
8 changes: 8 additions & 0 deletions src/client/TerminalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions src/client/terminal-keybindings.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>

/**
* 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
}
71 changes: 71 additions & 0 deletions tests/terminal-keybindings.spec.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<KeyboardEvent>]>)('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()
})
})