fix(terminal): translate mac cmd/option editing shortcuts

xterm ignores meta+arrows, and 6.x dropped the macOS conversion of
option+arrow to ESC b/f, so cmd+arrow did nothing and option+arrow
leaked ESC[1;3C-style sequences the shell rendered as a stray "C"/"D"
plus a bell. Map the chords the way Terminal.app and iTerm2 (Natural
Text Editing) do: cmd+left/right -> ^A/^E, option+left/right ->
ESC b/f, cmd+backspace -> ^U.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Gustavo Ambrozio
2026-10-03 13:06:04 +02:00
committed by Mohamed Boudra
co-authored by Claude Fable 5
parent 5375f43a05
commit 7dc2a3568c
5 changed files with 252 additions and 13 deletions
@@ -45,6 +45,11 @@ interface MountedTerminal {
const mountedTerminals: MountedTerminal[] = [];
const MAC_DESKTOP_UA =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
const LINUX_DESKTOP_UA =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
function nextFrame(): Promise<void> {
return new Promise((resolve) => {
requestAnimationFrame(() => {
@@ -179,6 +184,7 @@ function getBrowserTerminal(): BrowserTerminal {
function dispatchTerminalKey(input: {
host: HTMLElement;
key: string;
keyCode?: number;
shiftKey?: boolean;
ctrlKey?: boolean;
altKey?: boolean;
@@ -189,17 +195,50 @@ function dispatchTerminalKey(input: {
throw new Error("Expected xterm textarea to be mounted");
}
textarea.focus();
return textarea.dispatchEvent(
new KeyboardEvent("keydown", {
key: input.key,
shiftKey: input.shiftKey ?? false,
ctrlKey: input.ctrlKey ?? false,
altKey: input.altKey ?? false,
metaKey: input.metaKey ?? false,
bubbles: true,
cancelable: true,
}),
);
const event = new KeyboardEvent("keydown", {
key: input.key,
shiftKey: input.shiftKey ?? false,
ctrlKey: input.ctrlKey ?? false,
altKey: input.altKey ?? false,
metaKey: input.metaKey ?? false,
bubbles: true,
cancelable: true,
});
if (input.keyCode !== undefined) {
// Synthetic KeyboardEvents always report keyCode 0; xterm's fallback encoder
// switches on keyCode, so hardware-like events need it filled in.
Object.defineProperty(event, "keyCode", { value: input.keyCode });
}
return textarea.dispatchEvent(event);
}
// The runtime reads the platform per keydown, so shadowing the instance properties
// makes mac-only key handling testable from any CI host.
function withNavigatorPlatform(
input: { userAgent: string; platform: string },
run: () => void,
): void {
const restores = (["userAgent", "platform"] as const).map((property) => {
const original = Object.getOwnPropertyDescriptor(navigator, property);
Object.defineProperty(navigator, property, {
value: input[property],
configurable: true,
});
return () => {
if (original) {
Object.defineProperty(navigator, property, original);
} else {
Reflect.deleteProperty(navigator, property);
}
};
});
try {
run();
} finally {
for (const restore of restores) {
restore();
}
}
}
/** An OSC 8 hyperlink, the escape sequence CLIs such as gh and ls --hyperlink print. */
@@ -514,6 +553,70 @@ describe("terminal emulator runtime in a real browser", () => {
});
});
it("translates mac editing shortcuts into shell editing sequences", async () => {
await page.viewport(900, 600);
const mounted = createTerminalHost({ width: 720, height: 360 });
await waitFor({ predicate: () => mounted.sizes.length > 0 });
withNavigatorPlatform({ userAgent: MAC_DESKTOP_UA, platform: "MacIntel" }, () => {
dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39 });
dispatchTerminalKey({ host: mounted.host, key: "ArrowLeft", keyCode: 37, metaKey: true });
dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, metaKey: true });
dispatchTerminalKey({ host: mounted.host, key: "ArrowLeft", keyCode: 37, altKey: true });
dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, altKey: true });
dispatchTerminalKey({ host: mounted.host, key: "Backspace", keyCode: 8, metaKey: true });
});
await nextFrame();
expect(mounted.inputs).toEqual([
"\x1b[C", // plain arrow keeps xterm's default encoding
"\x01", // cmd+left -> line start
"\x05", // cmd+right -> line end
"\x1bb", // option+left -> word back
"\x1bf", // option+right -> word forward
"\x15", // cmd+backspace -> kill line
]);
expect(mounted.terminalKeys).toEqual([]);
});
it("keeps cmd+shift+arrow free for app-level shortcuts", async () => {
await page.viewport(900, 600);
const mounted = createTerminalHost({ width: 720, height: 360 });
await waitFor({ predicate: () => mounted.sizes.length > 0 });
withNavigatorPlatform({ userAgent: MAC_DESKTOP_UA, platform: "MacIntel" }, () => {
dispatchTerminalKey({
host: mounted.host,
key: "ArrowRight",
keyCode: 39,
metaKey: true,
shiftKey: true,
});
});
await nextFrame();
expect(mounted.inputs).toEqual([]);
expect(mounted.terminalKeys).toEqual([]);
});
it("does not remap modified arrows on non-mac platforms", async () => {
await page.viewport(900, 600);
const mounted = createTerminalHost({ width: 720, height: 360 });
await waitFor({ predicate: () => mounted.sizes.length > 0 });
withNavigatorPlatform({ userAgent: LINUX_DESKTOP_UA, platform: "Linux x86_64" }, () => {
dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, metaKey: true });
dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, altKey: true });
});
await nextFrame();
// Meta+arrow is ignored by xterm; alt+arrow takes xterm's generic encoding.
expect(mounted.inputs).toEqual(["\x1b[1;3C"]);
});
it.each([
{ name: "DA1", bytes: "\x1b[c" },
{ name: "DA1-zero", bytes: "\x1b[0c" },
@@ -15,11 +15,14 @@ import {
} from "@getpaseo/protocol/terminal-input-mode";
import {
type PendingTerminalModifiers,
hasPendingTerminalModifiers,
isAppleHandheldPlatform,
isMacLikePlatform,
isTerminalModifierDomKey,
mergeTerminalModifiers,
normalizeDomTerminalKey,
normalizeTerminalTransportKey,
resolveMacTerminalEditingShortcut,
shouldInterceptDomTerminalKey,
} from "@/utils/terminal-keys";
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot";
@@ -142,6 +145,17 @@ declare global {
}
}
// Read at call time, not module load: keydown handling consults it per event.
function detectMacLikePlatform(): boolean {
return (
typeof navigator !== "undefined" &&
isMacLikePlatform({
userAgent: navigator.userAgent,
platform: (navigator as Navigator & { platform?: string }).platform,
})
);
}
const isAppleHandheld =
typeof navigator !== "undefined" &&
isAppleHandheldPlatform({
@@ -299,7 +313,12 @@ export class TerminalEmulatorRuntime {
attachKeyEventHandler(
terminal: Pick<
Terminal,
"attachCustomKeyEventHandler" | "hasSelection" | "getSelection" | "paste"
| "attachCustomKeyEventHandler"
| "hasSelection"
| "getSelection"
| "paste"
| "input"
| "scrollToBottom"
>,
): void {
terminal.attachCustomKeyEventHandler((event) => {
@@ -339,6 +358,10 @@ export class TerminalEmulatorRuntime {
return true;
}
if (this.maybeSendMacEditingShortcut(terminal, event)) {
return false;
}
const normalizedKey = normalizeDomTerminalKey(event.key);
if (!normalizedKey || isTerminalModifierDomKey(event.key)) {
return true;
@@ -426,6 +449,26 @@ export class TerminalEmulatorRuntime {
return this.inputModeTracker.getState();
}
private maybeSendMacEditingShortcut(
terminal: Pick<Terminal, "input" | "scrollToBottom">,
event: KeyboardEvent,
): boolean {
if (!detectMacLikePlatform() || hasPendingTerminalModifiers(this.pendingModifiers)) {
return false;
}
const editingShortcutData = resolveMacTerminalEditingShortcut(event);
if (editingShortcutData === null) {
return false;
}
// Routed through terminal.input() so the data takes the same path as typed
// keys: onData -> callbacks.onInput, selection clearing included.
terminal.input(editingShortcutData, true);
terminal.scrollToBottom();
event.preventDefault();
event.stopPropagation();
return true;
}
mount(input: TerminalEmulatorRuntimeMountInput): void {
this.unmount();
File diff suppressed because one or more lines are too long
@@ -7,6 +7,7 @@ import {
mergeTerminalModifiers,
normalizeDomTerminalKey,
normalizeTerminalTransportKey,
resolveMacTerminalEditingShortcut,
resolvePendingModifierDataInput,
shouldInterceptDomTerminalKey,
} from "./terminal-keys";
@@ -351,3 +352,45 @@ describe("terminal key helpers", () => {
});
});
});
describe("resolveMacTerminalEditingShortcut", () => {
function shortcut(
key: string,
modifiers: Partial<Record<"ctrl" | "shift" | "alt" | "meta", boolean>>,
) {
return resolveMacTerminalEditingShortcut({
key,
ctrlKey: modifiers.ctrl ?? false,
shiftKey: modifiers.shift ?? false,
altKey: modifiers.alt ?? false,
metaKey: modifiers.meta ?? false,
});
}
it("maps cmd+arrow to line start/end", () => {
expect(shortcut("ArrowLeft", { meta: true })).toBe("\x01");
expect(shortcut("ArrowRight", { meta: true })).toBe("\x05");
});
it("maps option+arrow to word movement", () => {
expect(shortcut("ArrowLeft", { alt: true })).toBe("\x1bb");
expect(shortcut("ArrowRight", { alt: true })).toBe("\x1bf");
});
it("maps cmd+backspace to kill-line", () => {
expect(shortcut("Backspace", { meta: true })).toBe("\x15");
});
it("leaves other combinations to xterm", () => {
expect(shortcut("ArrowLeft", {})).toBeNull();
expect(shortcut("ArrowRight", { meta: true, shift: true })).toBeNull();
expect(shortcut("ArrowRight", { meta: true, ctrl: true })).toBeNull();
expect(shortcut("ArrowRight", { meta: true, alt: true })).toBeNull();
expect(shortcut("ArrowLeft", { alt: true, shift: true })).toBeNull();
expect(shortcut("ArrowUp", { meta: true })).toBeNull();
expect(shortcut("ArrowDown", { alt: true })).toBeNull();
// Option+Backspace already works natively in xterm (ESC DEL).
expect(shortcut("Backspace", { alt: true })).toBeNull();
expect(shortcut("k", { meta: true })).toBeNull();
});
});
+50
View File
@@ -88,6 +88,15 @@ interface AppleHandheldDetectionInput {
maxTouchPoints: number | null | undefined;
}
// Matches macOS and iPadOS (which reports a Mac user agent in WKWebView); both
// follow the same cmd/option editing conventions on hardware keyboards.
export function isMacLikePlatform(input: {
userAgent: string | null | undefined;
platform: string | null | undefined;
}): boolean {
return /Macintosh|Mac OS/i.test(input.userAgent ?? "") || /Mac/i.test(input.platform ?? "");
}
// iPadOS 13+ WKWebView reports navigator.platform="MacIntel" and a Mac UA string. Distinguish
// iPad/iPhone from real macOS via maxTouchPoints, which is 0 on macOS and >1 on iPadOS/iOS.
export function isAppleHandheldPlatform(input: AppleHandheldDetectionInput): boolean {
@@ -135,6 +144,47 @@ export function shouldInterceptDomTerminalKey(args: {
return false;
}
// macOS terminals translate these editing chords instead of forwarding raw modified
// keys: Terminal.app and iTerm2 ("Natural Text Editing") send ^A/^E for cmd+arrows,
// ESC b / ESC f for option+arrows, and ^U for cmd+backspace. xterm ignores meta+arrow
// entirely, and 6.x dropped the 5.x macOS conversion of option+arrow to ESC b/f, so
// without this the shell receives nothing or an `ESC[1;3C`-style sequence it can't
// parse (which zsh renders as a stray "C"/"D" plus a bell).
export function resolveMacTerminalEditingShortcut(args: {
key: string;
ctrlKey: boolean;
shiftKey: boolean;
altKey: boolean;
metaKey: boolean;
}): string | null {
if (args.ctrlKey || args.shiftKey) {
return null;
}
if (args.metaKey && !args.altKey) {
switch (args.key) {
case "ArrowLeft":
return "\x01";
case "ArrowRight":
return "\x05";
case "Backspace":
return "\x15";
default:
return null;
}
}
if (args.altKey && !args.metaKey) {
switch (args.key) {
case "ArrowLeft":
return "\x1bb";
case "ArrowRight":
return "\x1bf";
default:
return null;
}
}
return null;
}
export function mergeTerminalModifiers(args: {
pendingModifiers: PendingTerminalModifiers;
ctrlKey: boolean;