feat(terminal): add "Copy on Select" clipboard setting (default off) (#862)

Adds an opt-in terminal setting that automatically copies the current
selection to the system clipboard as the user selects, mirroring X11 /
gnome-terminal behavior. xterm.js has no native option for this, so the
renderer hooks `onSelectionChange` per pane and writes via the existing
clipboard IPC. Defaults to false so existing users keep the explicit
Cmd/Ctrl+Shift+C copy flow.

Closes #860
This commit is contained in:
Neil 2026-04-20 00:28:29 -07:00 committed by GitHub
parent c47b651f2d
commit c6f6300bcf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 97 additions and 0 deletions

View file

@ -55,6 +55,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalDividerThicknessPx: 1,
terminalRightClickToPaste: false,
terminalFocusFollowsMouse: false,
terminalClipboardOnSelect: false,
setupScriptLaunchMode: 'split-vertical',
terminalScrollbackBytes: 10_000_000,
openLinksInApp: false,

View file

@ -49,6 +49,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalDividerThicknessPx: 1,
terminalRightClickToPaste: false,
terminalFocusFollowsMouse: false,
terminalClipboardOnSelect: false,
setupScriptLaunchMode: 'split-vertical',
terminalScrollbackBytes: 10_000_000,
openLinksInApp: false,

View file

@ -368,6 +368,50 @@ export function TerminalPane({
/>
</button>
</SearchableSetting>
<SearchableSetting
title="Copy on Select"
description="Automatically copy terminal selections to the clipboard as soon as a selection is made."
keywords={[
'clipboard',
'copy',
'select',
'selection',
'auto',
'automatic',
'x11',
'linux',
'gnome',
'paste'
]}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Copy on Select</Label>
<p className="text-xs text-muted-foreground">
Automatically copy terminal selections to the clipboard as soon as a selection is
made.
</p>
</div>
<button
role="switch"
aria-checked={settings.terminalClipboardOnSelect}
onClick={() =>
updateSettings({
terminalClipboardOnSelect: !settings.terminalClipboardOnSelect
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.terminalClipboardOnSelect ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.terminalClipboardOnSelect ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</SearchableSetting>
</section>
) : null,
matchesSettingsSearch(searchQuery, TERMINAL_DARK_THEME_SEARCH_ENTRIES) ? (

View file

@ -47,6 +47,23 @@ export const TERMINAL_PANE_STYLE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
description:
"Hovering a terminal pane activates it without needing to click. Mirrors Ghostty's focus-follows-mouse setting. Selections and window switching stay safe.",
keywords: ['focus', 'follows', 'mouse', 'hover', 'pane', 'ghostty', 'active']
},
{
title: 'Copy on Select',
description:
'Automatically copy terminal selections to the clipboard as soon as a selection is made.',
keywords: [
'clipboard',
'copy',
'select',
'selection',
'auto',
'automatic',
'x11',
'linux',
'gnome',
'paste'
]
}
]

View file

@ -160,6 +160,9 @@ export function useTerminalPaneLifecycle({
const systemPrefersDarkRef = useRef(systemPrefersDark)
systemPrefersDarkRef.current = systemPrefersDark
const linkProviderDisposablesRef = useRef(new Map<number, IDisposable>())
// Why: read settingsRef at fire time so toggling "copy on select" takes
// effect without recreating panes.
const selectionDisposablesRef = useRef(new Map<number, IDisposable>())
const applyAppearance = (manager: PaneManager): void => {
const currentSettings = settingsRef.current
@ -186,6 +189,7 @@ export function useTerminalPaneLifecycle({
const panePtyBindings = panePtyBindingsRef.current
const pendingWrites = pendingWritesRef.current
const linkDisposables = linkProviderDisposablesRef.current
const selectionDisposables = selectionDisposablesRef.current
const worktreePath =
useAppStore
.getState()
@ -273,6 +277,21 @@ export function useTerminalPaneLifecycle({
createFilePathLinkProvider(pane.id, linkDeps, pane.linkTooltip, fileOpenLinkHint)
)
linkProviderDisposablesRef.current.set(pane.id, linkProviderDisposable)
// Why: skip empty selections so clicking to deselect doesn't clobber
// whatever the user last copied elsewhere.
const selectionDisposable = pane.terminal.onSelectionChange(() => {
if (!settingsRef.current?.terminalClipboardOnSelect) {
return
}
const selection = pane.terminal.getSelection()
if (!selection) {
return
}
void window.api.ui.writeClipboardText(selection).catch(() => {
/* ignore clipboard write failures */
})
})
selectionDisposablesRef.current.set(pane.id, selectionDisposable)
pane.terminal.options.linkHandler = {
allowNonHttpProtocols: true,
activate: (event, text) => handleOscLink(text, event as MouseEvent | undefined, linkDeps),
@ -316,6 +335,11 @@ export function useTerminalPaneLifecycle({
linkProviderDisposable.dispose()
linkProviderDisposablesRef.current.delete(paneId)
}
const selectionDisposable = selectionDisposablesRef.current.get(paneId)
if (selectionDisposable) {
selectionDisposable.dispose()
selectionDisposablesRef.current.delete(paneId)
}
const transport = paneTransportsRef.current.get(paneId)
const panePtyBinding = panePtyBindings.get(paneId)
if (panePtyBinding) {
@ -555,6 +579,10 @@ export function useTerminalPaneLifecycle({
disposable.dispose()
}
linkDisposables.clear()
for (const disposable of selectionDisposables.values()) {
disposable.dispose()
}
selectionDisposables.clear()
for (const transport of paneTransports.values()) {
if (tabStillExists && transport.getPtyId()) {
// Why: moving a terminal tab between groups currently rehomes the

View file

@ -124,6 +124,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
// { ...defaults.settings, ...parsed.settings } merge, so enabling
// focus-follows-mouse never happens unexpectedly.
terminalFocusFollowsMouse: false,
terminalClipboardOnSelect: false,
setupScriptLaunchMode: 'new-tab',
terminalScrollbackBytes: 10_000_000,
openLinksInApp: true,

View file

@ -591,6 +591,11 @@ export type GlobalSettings = {
* menu behavior and users can still reach the menu with Ctrl+right-click. */
terminalRightClickToPaste: boolean
terminalFocusFollowsMouse: boolean
/** Why: mirrors X11 / gnome-terminal "copy on select" UX making a terminal
* selection copies it to the system clipboard automatically, so users can
* paste with Cmd/Ctrl+V without an intervening Cmd/Ctrl+Shift+C. Defaults
* to false so existing users keep the explicit-copy behavior. */
terminalClipboardOnSelect: boolean
/** Where the repo setup script runs on workspace create. Defaults to a
* background "Setup" tab so the user's main terminal stays immediately
* usable without the setup output crowding the initial pane. */