diff --git a/ui-tui/src/__tests__/inputSelectionClipboard.test.ts b/ui-tui/src/__tests__/inputSelectionClipboard.test.ts new file mode 100644 index 0000000000..5167971420 --- /dev/null +++ b/ui-tui/src/__tests__/inputSelectionClipboard.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest' + +import { handleInputSelectionClipboard } from '../app/useInputHandlers.js' + +const selection = (start = 1, end = 4) => ({ + clear: vi.fn(), + collapseToEnd: vi.fn(), + copy: vi.fn(), + cut: vi.fn(), + end, + start, + value: 'hello' +}) + +describe('handleInputSelectionClipboard', () => { + it('copies an active composer selection', () => { + const active = selection() + + expect(handleInputSelectionClipboard(active, 'copy')).toBe(true) + expect(active.copy).toHaveBeenCalledOnce() + expect(active.cut).not.toHaveBeenCalled() + }) + + it('cuts an active composer selection', () => { + const active = selection() + + expect(handleInputSelectionClipboard(active, 'cut')).toBe(true) + expect(active.cut).toHaveBeenCalledOnce() + expect(active.copy).not.toHaveBeenCalled() + }) + + it('leaves shortcuts available when there is no active selection', () => { + expect(handleInputSelectionClipboard(null, 'copy')).toBe(false) + expect(handleInputSelectionClipboard(selection(2, 2), 'cut')).toBe(false) + }) +}) diff --git a/ui-tui/src/app/inputSelectionStore.ts b/ui-tui/src/app/inputSelectionStore.ts index c01e11861f..76268ba766 100644 --- a/ui-tui/src/app/inputSelectionStore.ts +++ b/ui-tui/src/app/inputSelectionStore.ts @@ -3,6 +3,8 @@ import { atom } from 'nanostores' export interface InputSelection { clear: () => void collapseToEnd: () => void + copy: () => void + cut: () => void end: number start: number value: string diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 97cb704b0b..67ad4c17bc 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -35,6 +35,19 @@ const DASHBOARD_NEW_SESSION_MESSAGE = 'starting a fresh dashboard chat...' export const shouldAllowIdleHotkeyExit = (dashboardTuiMode = DASHBOARD_TUI_MODE) => !dashboardTuiMode +export function handleInputSelectionClipboard( + selection: ReturnType, + action: 'copy' | 'cut' +): boolean { + if (!selection || selection.end <= selection.start) { + return false + } + + selection[action]() + + return true +} + export function handleIdleHotkeyExit( actions: Pick, dashboardTuiMode = DASHBOARD_TUI_MODE, @@ -562,9 +575,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { const inputSel = getInputSelection() - if (inputSel && inputSel.end > inputSel.start) { - inputSel.clear() - + if (handleInputSelectionClipboard(inputSel, 'copy')) { return } @@ -575,6 +586,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { } } + if (isCtrl(key, ch, 'x') && handleInputSelectionClipboard(getInputSelection(), 'cut')) { + return + } + if (isCtrl(key, ch, 'x') && cState.queueEditIdx !== null) { cActions.removeQueue(cState.queueEditIdx) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 86b05e07e8..5727428c3a 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -810,6 +810,23 @@ export function TextInput({ setCur(vRef.current.length) curRef.current = vRef.current.length }, + copy: () => { + const range = selRange() + + if (range) { + void writeClipboardText(vRef.current.slice(range.start, range.end)) + } + }, + cut: () => { + const range = selRange() + + if (!range) { + return + } + + void writeClipboardText(vRef.current.slice(range.start, range.end)) + commit(vRef.current.slice(0, range.start) + vRef.current.slice(range.end), range.start) + }, end: selected?.end ?? curRef.current, start: selected?.start ?? curRef.current, value: vRef.current diff --git a/web/src/lib/pty-keyboard-shortcuts.test.ts b/web/src/lib/pty-keyboard-shortcuts.test.ts new file mode 100644 index 0000000000..be6f52be06 --- /dev/null +++ b/web/src/lib/pty-keyboard-shortcuts.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' + +import { resolvePtyKeyboardShortcut } from './pty-keyboard-shortcuts' + +const key = (overrides: Partial = {}) => + ({ + altKey: false, + ctrlKey: false, + key: '', + metaKey: false, + shiftKey: false, + ...overrides, + }) as KeyboardEvent + +describe('resolvePtyKeyboardShortcut', () => { + it('copies terminal selection with bare Ctrl+C on non-macOS', () => { + expect( + resolvePtyKeyboardShortcut(key({ ctrlKey: true, key: 'c' }), false, true), + ).toBe('copy') + }) + + it('preserves Ctrl+C interrupt when nothing is selected', () => { + expect( + resolvePtyKeyboardShortcut(key({ ctrlKey: true, key: 'c' }), false, false), + ).toBe('pass') + }) + + it('maps Ctrl+Backspace to backward word deletion', () => { + expect( + resolvePtyKeyboardShortcut( + key({ ctrlKey: true, key: 'Backspace' }), + false, + false, + ), + ).toBe('delete-word-backward') + }) + + it('maps Ctrl+Delete to forward word deletion', () => { + expect( + resolvePtyKeyboardShortcut(key({ ctrlKey: true, key: 'Delete' }), false, false), + ).toBe('delete-word-forward') + }) +}) diff --git a/web/src/lib/pty-keyboard-shortcuts.ts b/web/src/lib/pty-keyboard-shortcuts.ts new file mode 100644 index 0000000000..db501d0e83 --- /dev/null +++ b/web/src/lib/pty-keyboard-shortcuts.ts @@ -0,0 +1,42 @@ +export type PtyKeyboardShortcut = + | "copy" + | "delete-word-backward" + | "delete-word-forward" + | "pass"; + +export function resolvePtyKeyboardShortcut( + ev: Pick, + isMac: boolean, + hasTerminalSelection: boolean, +): PtyKeyboardShortcut { + const key = ev.key.toLowerCase(); + const copyPressed = isMac + ? ev.metaKey && !ev.ctrlKey + : ev.ctrlKey && !ev.altKey && !ev.metaKey; + + if (copyPressed && key === "c" && hasTerminalSelection) { + return "copy"; + } + + if ( + ev.ctrlKey && + !ev.shiftKey && + !ev.altKey && + !ev.metaKey && + ev.key === "Backspace" + ) { + return "delete-word-backward"; + } + + if ( + ev.ctrlKey && + !ev.shiftKey && + !ev.altKey && + !ev.metaKey && + ev.key === "Delete" + ) { + return "delete-word-forward"; + } + + return "pass"; +} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 3fd98bbd22..ffc90771ec 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -60,6 +60,7 @@ import { normalizePtyMobileInput, shouldTreatInputAsMobileReplacement, } from "@/lib/pty-mobile-input"; +import { resolvePtyKeyboardShortcut } from "@/lib/pty-keyboard-shortcuts"; import { isViewportPinnedToBottom, shouldFollowPtyOutput, @@ -663,13 +664,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { term.attachCustomKeyEventHandler((ev) => { if (ev.type !== "keydown") return true; - // Copy: Cmd+C on macOS, Ctrl+Shift+C on other platforms. Bare Ctrl+C - // is reserved for SIGINT to the TUI child — matches xterm / gnome-terminal / - // konsole / Windows Terminal. Ctrl+Shift+C only copies if a selection exists; - // without a selection it passes through to the TUI so agents can still - // react to the keypress. + // Copy: Cmd+C on macOS, Ctrl+C or Ctrl+Shift+C elsewhere. Copy only + // when xterm has a selection; without one Ctrl+C still reaches the TUI + // as SIGINT. // Paste: Cmd+Shift+V on macOS, Ctrl+Shift+V on others. - const copyModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey; + const copyModifier = isMac ? ev.metaKey : ev.ctrlKey; // Paste on BARE Ctrl+V too (not only Ctrl+Shift+V). Bare Ctrl+V otherwise // falls through to the TUI, whose server-side clipboard read can't see the // browser/OS clipboard → "No image found in clipboard". Routing Ctrl+V @@ -677,22 +676,28 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // image-or-text correctly, like Ctrl+Shift+V. const pasteModifier = isMac ? ev.metaKey : ev.ctrlKey; - if (copyModifier && ev.key.toLowerCase() === "c") { - const sel = term.getSelection(); - if (sel) { - // Direct writeText inside the keydown handler preserves the user - // gesture — async round-trips through OSC 52 can lose activation - // and fail with "Document is not focused". - navigator.clipboard.writeText(sel).catch((err) => { - console.warn("[dashboard clipboard] direct copy failed:", err.message); - }); - // Clear xterm.js's highlight after copy (matches gnome-terminal). - term.clearSelection(); - ev.preventDefault(); - return false; - } - // No selection → fall through so the TUI receives Ctrl+Shift+C - // (or the bare ev if the user used a different modifier). + const terminalSelection = term.getSelection(); + const shortcut = resolvePtyKeyboardShortcut( + ev, + isMac, + Boolean(terminalSelection), + ); + + if ( + (shortcut === "copy" || + (copyModifier && ev.shiftKey && ev.key.toLowerCase() === "c")) && + terminalSelection + ) { + // Direct writeText inside the keydown handler preserves the user + // gesture — async round-trips through OSC 52 can lose activation + // and fail with "Document is not focused". + navigator.clipboard.writeText(terminalSelection).catch((err) => { + console.warn("[dashboard clipboard] direct copy failed:", err.message); + }); + // Clear xterm.js's highlight after copy (matches gnome-terminal). + term.clearSelection(); + ev.preventDefault(); + return false; } // Ctrl+Backspace → delete previous word. xterm.js sends bare DEL @@ -701,13 +706,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // delete-word-backward. (Ctrl+W can't be used in a browser tab — it's a // reserved shortcut that closes the tab and preventDefault has no effect; // for Ctrl+W muscle memory use the Electron desktop app.) - if ( - ev.ctrlKey && - !ev.shiftKey && - !ev.altKey && - !ev.metaKey && - ev.key === "Backspace" - ) { + if (shortcut === "delete-word-backward") { ev.preventDefault(); const ws = wsRef.current; if (ws && ws.readyState === WebSocket.OPEN) ws.send("\x17"); @@ -716,13 +715,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // Ctrl+Delete → delete next word. Mirror of Ctrl+Backspace; sends Alt+d // (ESC d), the readline / prompt_toolkit kill-word-forward binding. - if ( - ev.ctrlKey && - !ev.shiftKey && - !ev.altKey && - !ev.metaKey && - ev.key === "Delete" - ) { + if (shortcut === "delete-word-forward") { ev.preventDefault(); const ws = wsRef.current; if (ws && ws.readyState === WebSocket.OPEN) ws.send("\x1bd");