From e4e72bdc0dbccf104f502265602938d4fc4f5900 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 13 Apr 2026 08:35:48 -0700 Subject: [PATCH 1/3] Fix code TUI copy --- .../ContentView/TabsContent/Terminal/helpers.ts | 16 ++++++++++++++++ bun.lock | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) 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..bbc835445c9 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 @@ -574,6 +574,22 @@ export function setupKeyboardHandler( return false; } + // Copy/paste (Cmd+C/V on Mac, Ctrl+V elsewhere): let the browser fire + // the native copy/paste events into setupCopyHandler/setupPasteHandler. + // When a TUI enables the Kitty keyboard protocol (opencode, helix, …), + // xterm encodes these chords as CSI u and calls preventDefault(), which + // would otherwise suppress the browser events our handlers rely on. + // Ctrl+C on non-Mac is interrupt (handled by isTerminalReservedEvent below). + const isPlainModCombo = + !event.altKey && + !event.shiftKey && + (isMac + ? event.metaKey && !event.ctrlKey + : event.ctrlKey && !event.metaKey); + const isCopyShortcut = isMac && isPlainModCombo && event.key === "c"; + const isPasteShortcut = isPlainModCombo && event.key === "v"; + if (isCopyShortcut || isPasteShortcut) 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", From 9b21b49a0bc35484e7f38f2a01988cf840d9391e Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 13 Apr 2026 08:44:12 -0700 Subject: [PATCH 2/3] Handle more keyboard shortcuts --- .../Terminal/clipboardShortcuts.test.ts | 189 ++++++++++++++++++ .../Terminal/clipboardShortcuts.ts | 55 +++++ .../TabsContent/Terminal/helpers.ts | 27 ++- 3 files changed, 256 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.test.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.ts 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..b1e49cf2464 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "bun:test"; +import { shouldBubbleClipboardShortcut } 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 VS Code macOS terminal paste", () => { + expect( + shouldBubbleClipboardShortcut( + makeEvent({ code: "KeyV", metaKey: true }), + { + isMac: true, + isWindows: false, + hasSelection: false, + }, + ), + ).toBe(true); + }); + + it("matches VS Code macOS terminal copy only when selection exists", () => { + expect( + shouldBubbleClipboardShortcut( + makeEvent({ code: "KeyC", metaKey: true }), + { + isMac: true, + isWindows: false, + hasSelection: true, + }, + ), + ).toBe(true); + + expect( + shouldBubbleClipboardShortcut( + makeEvent({ code: "KeyC", metaKey: true }), + { + isMac: true, + isWindows: false, + hasSelection: false, + }, + ), + ).toBe(false); + }); + + it("matches VS Code Windows terminal copy and paste bindings", () => { + expect( + shouldBubbleClipboardShortcut( + makeEvent({ code: "KeyV", ctrlKey: true }), + { + isMac: false, + isWindows: true, + hasSelection: false, + }, + ), + ).toBe(true); + + expect( + shouldBubbleClipboardShortcut( + makeEvent({ code: "KeyV", ctrlKey: true, shiftKey: true }), + { + isMac: false, + isWindows: true, + hasSelection: false, + }, + ), + ).toBe(true); + + expect( + shouldBubbleClipboardShortcut( + makeEvent({ code: "KeyC", ctrlKey: true }), + { + isMac: false, + isWindows: true, + hasSelection: true, + }, + ), + ).toBe(true); + + expect( + shouldBubbleClipboardShortcut( + makeEvent({ code: "KeyC", ctrlKey: true, shiftKey: true }), + { + isMac: false, + isWindows: true, + hasSelection: true, + }, + ), + ).toBe(true); + }); + + it("keeps Windows Ctrl+C going to the PTY when nothing is selected", () => { + expect( + shouldBubbleClipboardShortcut( + makeEvent({ code: "KeyC", ctrlKey: true }), + { + isMac: false, + isWindows: true, + hasSelection: false, + }, + ), + ).toBe(false); + }); + + it("matches VS Code Linux terminal copy and paste bindings", () => { + expect( + shouldBubbleClipboardShortcut( + makeEvent({ code: "KeyC", ctrlKey: true, shiftKey: true }), + { + isMac: false, + isWindows: false, + hasSelection: true, + }, + ), + ).toBe(true); + + expect( + shouldBubbleClipboardShortcut( + makeEvent({ code: "KeyV", ctrlKey: true, shiftKey: true }), + { + isMac: false, + isWindows: false, + hasSelection: false, + }, + ), + ).toBe(true); + + expect( + shouldBubbleClipboardShortcut( + makeEvent({ code: "Insert", shiftKey: true }), + { + isMac: false, + isWindows: false, + hasSelection: false, + }, + ), + ).toBe(true); + }); + + it("does not widen clipboard bubbling beyond VS Code's bindings", () => { + expect( + shouldBubbleClipboardShortcut( + makeEvent({ code: "KeyA", metaKey: true }), + { + isMac: true, + isWindows: false, + hasSelection: true, + }, + ), + ).toBe(false); + + expect( + shouldBubbleClipboardShortcut( + makeEvent({ code: "KeyV", ctrlKey: true }), + { + isMac: false, + isWindows: false, + hasSelection: false, + }, + ), + ).toBe(false); + + expect( + shouldBubbleClipboardShortcut( + makeEvent({ code: "Insert", ctrlKey: true }), + { + isMac: false, + isWindows: false, + hasSelection: false, + }, + ), + ).toBe(false); + }); +}); 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..4af82fb0633 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.ts @@ -0,0 +1,55 @@ +export interface ClipboardShortcutEvent { + code: string; + metaKey: boolean; + ctrlKey: boolean; + altKey: boolean; + shiftKey: boolean; +} + +export interface ClipboardShortcutOptions { + isMac: boolean; + isWindows: boolean; + hasSelection: boolean; +} + +/** + * 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; + } + + return ( + (event.code === "KeyV" && ctrlShiftOnly) || + (event.code === "Insert" && onlyShift) || + (hasSelection && event.code === "KeyC" && ctrlShiftOnly) + ); +} 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 bbc835445c9..f9e65b4c734 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,7 @@ import { DEFAULT_THEME_ID, getTerminalColors, } from "shared/themes"; +import { shouldBubbleClipboardShortcut } from "./clipboardShortcuts"; import { TERMINAL_OPTIONS } from "./config"; import { suppressQueryResponses } from "./suppressQueryResponses"; @@ -574,21 +575,17 @@ export function setupKeyboardHandler( return false; } - // Copy/paste (Cmd+C/V on Mac, Ctrl+V elsewhere): let the browser fire - // the native copy/paste events into setupCopyHandler/setupPasteHandler. - // When a TUI enables the Kitty keyboard protocol (opencode, helix, …), - // xterm encodes these chords as CSI u and calls preventDefault(), which - // would otherwise suppress the browser events our handlers rely on. - // Ctrl+C on non-Mac is interrupt (handled by isTerminalReservedEvent below). - const isPlainModCombo = - !event.altKey && - !event.shiftKey && - (isMac - ? event.metaKey && !event.ctrlKey - : event.ctrlKey && !event.metaKey); - const isCopyShortcut = isMac && isPlainModCombo && event.key === "c"; - const isPasteShortcut = isPlainModCombo && event.key === "v"; - if (isCopyShortcut || isPasteShortcut) 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; From 91177e8089bff6213b088391ef61ea96dfc0b8a3 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 13 Apr 2026 08:54:36 -0700 Subject: [PATCH 3/3] fix(desktop): match VS Code terminal clipboard handling --- .../Terminal/clipboardShortcuts.test.ts | 283 ++++++++---------- .../Terminal/clipboardShortcuts.ts | 29 +- .../TabsContent/Terminal/helpers.ts | 13 +- 3 files changed, 158 insertions(+), 167 deletions(-) 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 index b1e49cf2464..ba7e5469b70 100644 --- 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 @@ -1,5 +1,8 @@ import { describe, expect, it } from "bun:test"; -import { shouldBubbleClipboardShortcut } from "./clipboardShortcuts"; +import { + shouldBubbleClipboardShortcut, + shouldSelectAllShortcut, +} from "./clipboardShortcuts"; function makeEvent( overrides: Partial<{ @@ -21,169 +24,127 @@ function makeEvent( } describe("shouldBubbleClipboardShortcut", () => { - it("matches VS Code macOS terminal paste", () => { - expect( - shouldBubbleClipboardShortcut( - makeEvent({ code: "KeyV", metaKey: true }), - { - isMac: true, - isWindows: false, - hasSelection: false, - }, - ), - ).toBe(true); - }); - - it("matches VS Code macOS terminal copy only when selection exists", () => { - expect( - shouldBubbleClipboardShortcut( - makeEvent({ code: "KeyC", metaKey: true }), - { - isMac: true, - isWindows: false, - hasSelection: true, - }, - ), - ).toBe(true); - - expect( - shouldBubbleClipboardShortcut( - makeEvent({ code: "KeyC", metaKey: true }), - { - isMac: true, - isWindows: false, - hasSelection: false, - }, - ), - ).toBe(false); - }); + 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, + }, + ]; - it("matches VS Code Windows terminal copy and paste bindings", () => { - expect( - shouldBubbleClipboardShortcut( - makeEvent({ code: "KeyV", ctrlKey: true }), - { - isMac: false, - isWindows: true, - hasSelection: false, - }, - ), - ).toBe(true); - - expect( - shouldBubbleClipboardShortcut( - makeEvent({ code: "KeyV", ctrlKey: true, shiftKey: true }), - { - isMac: false, - isWindows: true, - hasSelection: false, - }, - ), - ).toBe(true); - - expect( - shouldBubbleClipboardShortcut( - makeEvent({ code: "KeyC", ctrlKey: true }), - { - isMac: false, - isWindows: true, - hasSelection: true, - }, - ), - ).toBe(true); - - expect( - shouldBubbleClipboardShortcut( - makeEvent({ code: "KeyC", ctrlKey: true, shiftKey: true }), - { - isMac: false, - isWindows: true, - hasSelection: true, - }, - ), - ).toBe(true); + for (const { name, event, options, expected } of cases) { + expect(shouldBubbleClipboardShortcut(event, options), name).toBe( + expected, + ); + } }); +}); - it("keeps Windows Ctrl+C going to the PTY when nothing is selected", () => { - expect( - shouldBubbleClipboardShortcut( - makeEvent({ code: "KeyC", ctrlKey: true }), - { - isMac: false, - isWindows: true, - hasSelection: false, - }, - ), - ).toBe(false); - }); - - it("matches VS Code Linux terminal copy and paste bindings", () => { - expect( - shouldBubbleClipboardShortcut( - makeEvent({ code: "KeyC", ctrlKey: true, shiftKey: true }), - { - isMac: false, - isWindows: false, - hasSelection: true, - }, - ), - ).toBe(true); - - expect( - shouldBubbleClipboardShortcut( - makeEvent({ code: "KeyV", ctrlKey: true, shiftKey: true }), - { - isMac: false, - isWindows: false, - hasSelection: false, - }, - ), - ).toBe(true); - - expect( - shouldBubbleClipboardShortcut( - makeEvent({ code: "Insert", shiftKey: true }), - { - isMac: false, - isWindows: false, - hasSelection: false, - }, - ), - ).toBe(true); - }); - - it("does not widen clipboard bubbling beyond VS Code's bindings", () => { - expect( - shouldBubbleClipboardShortcut( - makeEvent({ code: "KeyA", metaKey: true }), - { - isMac: true, - isWindows: false, - hasSelection: true, - }, - ), - ).toBe(false); - - expect( - shouldBubbleClipboardShortcut( - makeEvent({ code: "KeyV", ctrlKey: true }), - { - isMac: false, - isWindows: false, - hasSelection: false, - }, - ), - ).toBe(false); +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, + }, + ]; - expect( - shouldBubbleClipboardShortcut( - makeEvent({ code: "Insert", ctrlKey: true }), - { - isMac: false, - isWindows: false, - hasSelection: false, - }, - ), - ).toBe(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 index 4af82fb0633..d819afc1252 100644 --- 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 @@ -12,6 +12,21 @@ export interface ClipboardShortcutOptions { 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. @@ -47,9 +62,13 @@ export function shouldBubbleClipboardShortcut( return false; } - return ( - (event.code === "KeyV" && ctrlShiftOnly) || - (event.code === "Insert" && onlyShift) || - (hasSelection && event.code === "KeyC" && ctrlShiftOnly) - ); + 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 f9e65b4c734..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,7 +23,10 @@ import { DEFAULT_THEME_ID, getTerminalColors, } from "shared/themes"; -import { shouldBubbleClipboardShortcut } from "./clipboardShortcuts"; +import { + shouldBubbleClipboardShortcut, + shouldSelectAllShortcut, +} from "./clipboardShortcuts"; import { TERMINAL_OPTIONS } from "./config"; import { suppressQueryResponses } from "./suppressQueryResponses"; @@ -575,6 +578,14 @@ 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 (