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
107 changes: 100 additions & 7 deletions apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import type { SearchAddon } from "@xterm/addon-search";
import { SerializeAddon } from "@xterm/addon-serialize";
import { Terminal as XTerm } from "@xterm/xterm";
import { resolveHotkeyFromEvent } from "renderer/hotkeys";
import {
shouldBubbleClipboardShortcut,
shouldSelectAllShortcut,
} from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts";
import { DEFAULT_TERMINAL_SCROLLBACK } from "shared/constants";
import type { TerminalAppearance } from "./appearance";
import { loadAddons } from "./terminal-addons";
Expand All @@ -14,12 +18,101 @@ const DIMS_KEY_PREFIX = "terminal-dims:";
const DEFAULT_COLS = 120;
const DEFAULT_ROWS = 32;

// xterm's _keyDown calls stopPropagation after processing, which kills the
// bubble to react-hotkeys-hook. Returning false from the custom handler makes
// xterm bail before that, so app hotkeys reach document. (VSCode pattern:
// terminalInstance.ts:1116-1175)
function isAppHotkey(event: KeyboardEvent): boolean {
return resolveHotkeyFromEvent(event) !== null;
// xterm's _keyDown calls stopPropagation after processing, so any chord we
// want the host (react-hotkeys-hook, Electron menu accelerators) or the shell
// (Ctrl+A/E/U escape sequences for line edit) to see must short-circuit xterm
// before it runs. (VSCode pattern: terminalInstance.ts:1116-1175.)
//
// Kitty keyboard protocol is enabled, which means every Mac Cmd chord xterm
// sees gets CSI-u encoded and leaks into TUIs as a literal char. Ghostty
// sidesteps this by suppressing all super/Cmd chords on macOS before the
// encoder runs (ghostty/src/input/key_encode.zig:534-545). We do the same via
// shouldBubbleClipboardShortcut's Mac branch.
function createKeyEventHandler(terminal: XTerm) {
const platform =
typeof navigator !== "undefined" ? navigator.platform.toLowerCase() : "";
const isMac = platform.includes("mac");
const isWindows = platform.includes("win");
Comment on lines +32 to +35
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 navigator.platform is deprecated

navigator.platform was deprecated in the Living Standard and may be removed in future browser/Electron versions. The recommended replacement is navigator.userAgentData?.platform (available in Chromium-based Electron builds), with a graceful fallback.

Suggested change
const platform =
typeof navigator !== "undefined" ? navigator.platform.toLowerCase() : "";
const isMac = platform.includes("mac");
const isWindows = platform.includes("win");
const platform =
typeof navigator !== "undefined"
? (navigator.userAgentData?.platform ?? navigator.platform).toLowerCase()
: "";
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts
Line: 32-35

Comment:
**`navigator.platform` is deprecated**

`navigator.platform` was deprecated in the Living Standard and may be removed in future browser/Electron versions. The recommended replacement is `navigator.userAgentData?.platform` (available in Chromium-based Electron builds), with a graceful fallback.

```suggestion
	const platform =
		typeof navigator !== "undefined"
			? (navigator.userAgentData?.platform ?? navigator.platform).toLowerCase()
			: "";
```

How can I resolve this? If you propose a fix, please make it concise.


return (event: KeyboardEvent): boolean => {
if (resolveHotkeyFromEvent(event) !== null) return false;

const translation = translateLineEditChord(event, { isMac, isWindows });
if (translation !== null) {
if (event.type === "keydown") {
event.preventDefault();
terminal.input(translation, true);
}
return false;
}
Comment on lines +40 to +47
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 translateLineEditChord suppresses keyup for matched chords

When a chord (e.g. Cmd+Left) is matched, the handler returns false for every event type, including keyup. Only keydown sends terminal.input, but xterm never sees the keyup for that chord. In practice xterm's custom key handler is primarily meaningful for keydown, so this is unlikely to cause problems, but a comment clarifying the intent would improve readability.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts
Line: 40-47

Comment:
**`translateLineEditChord` suppresses `keyup` for matched chords**

When a chord (e.g. Cmd+Left) is matched, the handler returns `false` for every event type, including `keyup`. Only `keydown` sends `terminal.input`, but xterm never sees the `keyup` for that chord. In practice xterm's custom key handler is primarily meaningful for `keydown`, so this is unlikely to cause problems, but a comment clarifying the intent would improve readability.

How can I resolve this? If you propose a fix, please make it concise.


if (shouldSelectAllShortcut(event, isMac)) {
if (event.type === "keydown") {
event.preventDefault();
terminal.selectAll();
}
return false;
}

if (
shouldBubbleClipboardShortcut(event, {
isMac,
isWindows,
hasSelection: terminal.hasSelection(),
})
) {
// Do NOT preventDefault — the browser's keydown → paste-command pipeline
// is what fires the `paste` event on xterm's textarea. VS Code and Tabby
// preventDefault here only because they implement paste themselves via
// the command system / ClipboardAddon; we rely on xterm's built-in paste
// listener, so the default must run.
return false;
}

return true;
};
}

/** True when `mod` is the only non-shift modifier held. */
function onlyMod(event: KeyboardEvent, mod: "meta" | "alt" | "ctrl"): boolean {
return (
event.metaKey === (mod === "meta") &&
event.altKey === (mod === "alt") &&
event.ctrlKey === (mod === "ctrl") &&
!event.shiftKey
);
}

/**
* Translate Mac Cmd+/Option+ and Windows Ctrl+ arrow / backspace chords into
* the escape sequences shells expect. Returns the bytes to send, or null if
* this chord isn't a line-edit translation.
*
* Mirrors v1 helpers.ts:319-427. These translations only exist because xterm's
* default encoding (with kitty on) would send a CSI-u sequence that most
* shells don't map to line-edit commands.
*/
function translateLineEditChord(
event: KeyboardEvent,
options: { isMac: boolean; isWindows: boolean },
): string | null {
const { isMac, isWindows } = options;
const { key } = event;

if (isMac && onlyMod(event, "meta")) {
if (key === "Backspace") return "\x15\x1b[D";
if (key === "ArrowLeft") return "\x01";
if (key === "ArrowRight") return "\x05";
}
if (isMac && onlyMod(event, "alt")) {
if (key === "ArrowLeft") return "\x1bb";
if (key === "ArrowRight") return "\x1bf";
}
if (isWindows && onlyMod(event, "ctrl")) {
if (key === "ArrowLeft") return "\x1bb";
if (key === "ArrowRight") return "\x1bf";
}
return null;
}

export interface TerminalRuntime {
Expand Down Expand Up @@ -178,7 +271,7 @@ export function createRuntime(
wrapper.style.height = "100%";
terminal.open(wrapper);

terminal.attachCustomKeyEventHandler((event) => !isAppHotkey(event));
terminal.attachCustomKeyEventHandler(createKeyEventHandler(terminal));

// Activate Unicode 11 widths (inside loadAddons) before restoring the buffer,
// else CJK/emoji/ZWJ widths get baked wrong into the replay. (#3572)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,92 +24,122 @@ function makeEvent(
}

describe("shouldBubbleClipboardShortcut", () => {
it("matches the VS Code terminal clipboard bindings", () => {
it("bubbles every Mac Cmd chord, Ghostty-style", () => {
const cases = [
{
name: "macOS Cmd+V",
event: makeEvent({ code: "KeyV", metaKey: true }),
options: { isMac: true, isWindows: false, hasSelection: false },
expected: true,
name: "Cmd+C (no selection)",
event: makeEvent({ code: "KeyC", metaKey: true }),
},
{ name: "Cmd+V", event: makeEvent({ code: "KeyV", metaKey: true }) },
{ name: "Cmd+Enter", event: makeEvent({ code: "Enter", metaKey: true }) },
{ name: "Cmd+W", event: makeEvent({ code: "KeyW", metaKey: true }) },
{
name: "macOS Cmd+C with selection",
event: makeEvent({ code: "KeyC", metaKey: true }),
options: { isMac: true, isWindows: false, hasSelection: true },
expected: true,
name: "Cmd+Shift+K",
event: makeEvent({ code: "KeyK", metaKey: true, shiftKey: true }),
},
{
name: "Cmd+Alt+Left",
event: makeEvent({ code: "ArrowLeft", metaKey: true, altKey: true }),
},
];

for (const { name, event } of cases) {
expect(
shouldBubbleClipboardShortcut(event, {
isMac: true,
isWindows: false,
hasSelection: false,
}),
name,
).toBe(true);
}
});

it("does not bubble non-Cmd chords on Mac", () => {
const cases = [
{ name: "plain c", event: makeEvent({ code: "KeyC" }) },
{
name: "Ctrl+C (not a Mac idiom)",
event: makeEvent({ code: "KeyC", ctrlKey: true }),
},
{
name: "windows Ctrl+V",
name: "Shift+Insert",
event: makeEvent({ code: "Insert", shiftKey: true }),
},
{
name: "Ctrl+Shift+V (linux chord on mac)",
event: makeEvent({ code: "KeyV", ctrlKey: true, shiftKey: true }),
},
];

for (const { name, event } of cases) {
expect(
shouldBubbleClipboardShortcut(event, {
isMac: true,
isWindows: false,
hasSelection: false,
}),
name,
).toBe(false);
}
});

it("matches standard Windows / Linux clipboard bindings", () => {
const cases = [
{
name: "Windows Ctrl+V",
event: makeEvent({ code: "KeyV", ctrlKey: true }),
options: { isMac: false, isWindows: true, hasSelection: false },
expected: true,
},
{
name: "windows Ctrl+Shift+V",
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",
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",
name: "Windows Ctrl+C without selection stays with PTY (SIGINT)",
event: makeEvent({ code: "KeyC", ctrlKey: true }),
options: { isMac: false, isWindows: true, hasSelection: false },
expected: false,
},
{
name: "Windows Ctrl+Shift+C without selection still bubbles",
event: makeEvent({ code: "KeyC", ctrlKey: true, shiftKey: true }),
options: { isMac: false, isWindows: false, hasSelection: true },
options: { isMac: false, isWindows: true, hasSelection: false },
expected: true,
},
{
name: "linux Ctrl+Shift+V",
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",
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",
name: "Linux Ctrl+Shift+C without selection still bubbles",
event: makeEvent({ code: "KeyC", ctrlKey: true, shiftKey: true }),
options: { isMac: false, isWindows: false, hasSelection: false },
expected: false,
expected: true,
},
{
name: "linux Ctrl+Insert stays with the PTY",
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,47 +28,45 @@ export function shouldSelectAllShortcut(
}

/**
* 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.
* Decide whether a chord should bubble to the host (Electron menu accelerators,
* OS clipboard handlers, etc.) instead of reaching xterm's kitty encoder and
* leaking into the PTY as a CSI-u sequence.
*
* On macOS we follow Ghostty's rule (ghostty/src/input/key_encode.zig:534-545:
* "on macOS, command+keys do not encode text"): every Cmd chord bubbles. Specific
* chords the terminal wants to intercept (Cmd+Left/Right/Backspace, Cmd+A, etc.)
* must run before this check in the caller.
*
* Windows/Linux have standard copy/paste keybinds that bubble selectively:
* Ctrl+C only bubbles with a selection because it doubles as SIGINT.
*/
export function shouldBubbleClipboardShortcut(
event: ClipboardShortcutEvent,
options: ClipboardShortcutOptions,
): boolean {
const { isMac, isWindows, hasSelection } = options;

const onlyMeta =
event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey;
if (isMac) {
return event.metaKey;
}

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;
}

if (event.code === "KeyV" && (onlyCtrl || ctrlShiftOnly)) return true;
if (event.code === "KeyC" && ctrlShiftOnly) return true;
if (event.code === "KeyC" && onlyCtrl && hasSelection) return true;
return false;
}

if (!isMac) {
return (
(event.code === "KeyV" && ctrlShiftOnly) ||
(event.code === "Insert" && onlyShift) ||
(hasSelection && event.code === "KeyC" && ctrlShiftOnly)
);
}

return false;
return (
(event.code === "KeyV" && ctrlShiftOnly) ||
(event.code === "Insert" && onlyShift) ||
(event.code === "KeyC" && ctrlShiftOnly)
);
}
Loading