mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 10:42:45 +00:00
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:
co-authored by
Claude Fable 5
parent
5375f43a05
commit
7dc2a3568c
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user