diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.test.ts new file mode 100644 index 00000000000..ba7e5469b70 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "bun:test"; +import { + shouldBubbleClipboardShortcut, + shouldSelectAllShortcut, +} from "./clipboardShortcuts"; + +function makeEvent( + overrides: Partial<{ + code: string; + metaKey: boolean; + ctrlKey: boolean; + altKey: boolean; + shiftKey: boolean; + }>, +) { + return { + code: "KeyC", + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + ...overrides, + }; +} + +describe("shouldBubbleClipboardShortcut", () => { + it("matches the VS Code terminal clipboard bindings", () => { + const cases = [ + { + name: "macOS Cmd+V", + event: makeEvent({ code: "KeyV", metaKey: true }), + options: { isMac: true, isWindows: false, hasSelection: false }, + expected: true, + }, + { + name: "macOS Cmd+C with selection", + event: makeEvent({ code: "KeyC", metaKey: true }), + options: { isMac: true, isWindows: false, hasSelection: true }, + expected: true, + }, + { + name: "windows Ctrl+V", + event: makeEvent({ code: "KeyV", ctrlKey: true }), + options: { isMac: false, isWindows: true, hasSelection: false }, + expected: true, + }, + { + name: "windows Ctrl+Shift+V", + event: makeEvent({ code: "KeyV", ctrlKey: true, shiftKey: true }), + options: { isMac: false, isWindows: true, hasSelection: false }, + expected: true, + }, + { + name: "windows Ctrl+C with selection", + event: makeEvent({ code: "KeyC", ctrlKey: true }), + options: { isMac: false, isWindows: true, hasSelection: true }, + expected: true, + }, + { + name: "linux Ctrl+Shift+C with selection", + event: makeEvent({ code: "KeyC", ctrlKey: true, shiftKey: true }), + options: { isMac: false, isWindows: false, hasSelection: true }, + expected: true, + }, + { + name: "linux Ctrl+Shift+V", + event: makeEvent({ code: "KeyV", ctrlKey: true, shiftKey: true }), + options: { isMac: false, isWindows: false, hasSelection: false }, + expected: true, + }, + { + name: "linux Shift+Insert", + event: makeEvent({ code: "Insert", shiftKey: true }), + options: { isMac: false, isWindows: false, hasSelection: false }, + expected: true, + }, + { + name: "macOS Cmd+C without selection", + event: makeEvent({ code: "KeyC", metaKey: true }), + options: { isMac: true, isWindows: false, hasSelection: false }, + expected: false, + }, + { + name: "windows Ctrl+C without selection", + event: makeEvent({ code: "KeyC", ctrlKey: true }), + options: { isMac: false, isWindows: true, hasSelection: false }, + expected: false, + }, + { + name: "linux Ctrl+Shift+C without selection", + event: makeEvent({ code: "KeyC", ctrlKey: true, shiftKey: true }), + options: { isMac: false, isWindows: false, hasSelection: false }, + expected: false, + }, + { + name: "linux Ctrl+Insert stays with the PTY", + event: makeEvent({ code: "Insert", ctrlKey: true }), + options: { isMac: false, isWindows: false, hasSelection: false }, + expected: false, + }, + { + name: "macOS does not inherit linux fallback chords", + event: makeEvent({ code: "KeyV", ctrlKey: true, shiftKey: true }), + options: { isMac: true, isWindows: false, hasSelection: false }, + expected: false, + }, + { + name: "macOS Shift+Insert stays with the PTY", + event: makeEvent({ code: "Insert", shiftKey: true }), + options: { isMac: true, isWindows: false, hasSelection: false }, + expected: false, + }, + ]; + + for (const { name, event, options, expected } of cases) { + expect(shouldBubbleClipboardShortcut(event, options), name).toBe( + expected, + ); + } + }); +}); + +describe("shouldSelectAllShortcut", () => { + it("matches only the VS Code macOS terminal select-all binding", () => { + const cases = [ + { + name: "macOS Cmd+A", + event: makeEvent({ code: "KeyA", metaKey: true }), + isMac: true, + expected: true, + }, + { + name: "windows Ctrl+A is not intercepted", + event: makeEvent({ code: "KeyA", ctrlKey: true }), + isMac: false, + expected: false, + }, + { + name: "macOS Cmd+Shift+A is not intercepted", + event: makeEvent({ code: "KeyA", metaKey: true, shiftKey: true }), + isMac: true, + expected: false, + }, + ]; + + for (const { name, event, isMac, expected } of cases) { + expect(shouldSelectAllShortcut(event, isMac), name).toBe(expected); + } + }); +}); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.ts new file mode 100644 index 00000000000..d819afc1252 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.ts @@ -0,0 +1,74 @@ +export interface ClipboardShortcutEvent { + code: string; + metaKey: boolean; + ctrlKey: boolean; + altKey: boolean; + shiftKey: boolean; +} + +export interface ClipboardShortcutOptions { + isMac: boolean; + isWindows: boolean; + hasSelection: boolean; +} + +/** Match VS Code's macOS terminal `Cmd+A` binding. */ +export function shouldSelectAllShortcut( + event: ClipboardShortcutEvent, + isMac: boolean, +): boolean { + return ( + isMac && + event.code === "KeyA" && + event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey + ); +} + +/** + * Mirror VS Code terminal clipboard bindings so host copy/paste can run before + * xterm's kitty keyboard handler turns the chord into CSI-u input. + */ +export function shouldBubbleClipboardShortcut( + event: ClipboardShortcutEvent, + options: ClipboardShortcutOptions, +): boolean { + const { isMac, isWindows, hasSelection } = options; + + const onlyMeta = + event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey; + const onlyCtrl = + event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey; + const ctrlShiftOnly = + event.ctrlKey && event.shiftKey && !event.metaKey && !event.altKey; + const onlyShift = + event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey; + + if (isMac && onlyMeta) { + return event.code === "KeyV" || (hasSelection && event.code === "KeyC"); + } + + if (isWindows) { + if (event.code === "KeyV" && (onlyCtrl || ctrlShiftOnly)) { + return true; + } + + if (hasSelection && event.code === "KeyC" && (onlyCtrl || ctrlShiftOnly)) { + return true; + } + + return false; + } + + if (!isMac) { + return ( + (event.code === "KeyV" && ctrlShiftOnly) || + (event.code === "Insert" && onlyShift) || + (hasSelection && event.code === "KeyC" && ctrlShiftOnly) + ); + } + + return false; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index a89141003ec..898c30b184c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -23,6 +23,10 @@ import { DEFAULT_THEME_ID, getTerminalColors, } from "shared/themes"; +import { + shouldBubbleClipboardShortcut, + shouldSelectAllShortcut, +} from "./clipboardShortcuts"; import { TERMINAL_OPTIONS } from "./config"; import { suppressQueryResponses } from "./suppressQueryResponses"; @@ -574,6 +578,26 @@ export function setupKeyboardHandler( return false; } + if (shouldSelectAllShortcut(event, isMac)) { + if (event.type === "keydown") { + event.preventDefault(); + xterm.selectAll(); + } + return false; + } + + // Mirror VS Code terminal clipboard bindings so host copy/paste happens + // before kitty CSI-u handling in xterm consumes the command chord. + if ( + shouldBubbleClipboardShortcut(event, { + isMac, + isWindows, + hasSelection: xterm.hasSelection(), + }) + ) { + return false; + } + // Terminal-reserved chords (ctrl+c/d/z/s/q) always go to xterm if (isTerminalReservedEvent(event)) return true; diff --git a/bun.lock b/bun.lock index 9279337ecb0..7b1ddd00d7e 100644 --- a/bun.lock +++ b/bun.lock @@ -110,7 +110,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "1.5.0", + "version": "1.5.1", "dependencies": { "@ai-sdk/anthropic": "^3.0.43", "@ai-sdk/openai": "3.0.36",