Skip to content
Merged
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion docs/learnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
103 changes: 103 additions & 0 deletions ui/src/lib/terminalClipboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, expect, it, vi } from 'vitest';

import {
consumeTerminalClipboardKey,
terminalClipboardAction,
type TerminalClipboardKeyEvent,
type TerminalClipboardTerminal,
} from './terminalClipboard';

function event(
partial: Partial<TerminalClipboardKeyEvent> & Pick<TerminalClipboardKeyEvent, 'key'>,
): 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> = {}): 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();
});
});
79 changes: 79 additions & 0 deletions ui/src/lib/terminalClipboard.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
}

/** 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<TerminalClipboardKeyEvent, 'type' | 'key' | 'code' | 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'>,
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<string> {
if (!navigator.clipboard?.readText) return Promise.resolve('');
return navigator.clipboard.readText().catch((error) => {
console.warn('clipboard read failed', error);
return '';
});
}
9 changes: 9 additions & 0 deletions ui/src/views/Work.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 7 additions & 3 deletions website/docs/keyboard-and-palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 7 additions & 4 deletions website/docs/work.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading