diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index d9e544ab94d..d74ba49a080 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -18,7 +18,10 @@ import { preferredTerminalEditor, resolvePathLinkTarget, } from "../terminal-links"; -import { isTerminalClearShortcut } from "../keybindings"; +import { + isTerminalClearShortcut, + terminalNavigationShortcutData, +} from "../keybindings"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, @@ -152,24 +155,32 @@ function TerminalViewport({ terminalRef.current = terminal; fitAddonRef.current = fitAddon; - const sendClearShortcut = async () => { + const sendTerminalInput = async (data: string, fallbackError: string) => { const activeTerminal = terminalRef.current; if (!activeTerminal) return; try { - await api.terminal.write({ threadId, terminalId, data: "\u000c" }); + await api.terminal.write({ threadId, terminalId, data }); } catch (error) { writeSystemMessage( activeTerminal, - error instanceof Error ? error.message : "Failed to clear terminal", + error instanceof Error ? error.message : fallbackError, ); } }; terminal.attachCustomKeyEventHandler((event) => { + const navigationData = terminalNavigationShortcutData(event); + if (navigationData !== null) { + event.preventDefault(); + event.stopPropagation(); + void sendTerminalInput(navigationData, "Failed to move cursor"); + return false; + } + if (!isTerminalClearShortcut(event)) return true; event.preventDefault(); event.stopPropagation(); - void sendClearShortcut(); + void sendTerminalInput("\u000c", "Failed to clear terminal"); return false; }); diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 622f2dea078..4ceb1b920ac 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -15,6 +15,7 @@ import { isTerminalSplitShortcut, isTerminalToggleShortcut, shortcutLabelForCommand, + terminalNavigationShortcutData, type ShortcutEventLike, } from "./keybindings"; @@ -324,6 +325,65 @@ describe("isTerminalClearShortcut", () => { it("matches Cmd+K on macOS", () => { assert.isTrue(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "MacIntel")); }); + + it("ignores non-keydown events", () => { + assert.isFalse(isTerminalClearShortcut(event({ type: "keyup", key: "l", ctrlKey: true }), "Linux")); + }); +}); + +describe("terminalNavigationShortcutData", () => { + it("maps Option+Arrow on macOS to word movement", () => { + assert.strictEqual( + terminalNavigationShortcutData(event({ key: "ArrowLeft", altKey: true }), "MacIntel"), + "\u001bb", + ); + assert.strictEqual( + terminalNavigationShortcutData(event({ key: "ArrowRight", altKey: true }), "MacIntel"), + "\u001bf", + ); + }); + + it("maps Cmd+Arrow on macOS to line movement", () => { + assert.strictEqual( + terminalNavigationShortcutData(event({ key: "ArrowLeft", metaKey: true }), "MacIntel"), + "\u0001", + ); + assert.strictEqual( + terminalNavigationShortcutData(event({ key: "ArrowRight", metaKey: true }), "MacIntel"), + "\u0005", + ); + }); + + it("maps Ctrl+Arrow on non-macOS to word movement", () => { + assert.strictEqual( + terminalNavigationShortcutData(event({ key: "ArrowLeft", ctrlKey: true }), "Win32"), + "\u001bb", + ); + assert.strictEqual( + terminalNavigationShortcutData(event({ key: "ArrowRight", ctrlKey: true }), "Linux"), + "\u001bf", + ); + }); + + it("rejects unsupported combinations", () => { + assert.isNull( + terminalNavigationShortcutData( + event({ key: "ArrowLeft", shiftKey: true, altKey: true }), + "MacIntel", + ), + ); + assert.isNull(terminalNavigationShortcutData(event({ key: "ArrowLeft", metaKey: true }), "Linux")); + assert.isNull(terminalNavigationShortcutData(event({ key: "a", altKey: true }), "MacIntel")); + }); + + it("ignores non-keydown events", () => { + assert.isNull( + terminalNavigationShortcutData( + event({ type: "keyup", key: "ArrowLeft", altKey: true }), + "MacIntel", + ), + ); + }); }); describe("plus key parsing", () => { diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 5b908344b88..4efcd6fee39 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -7,6 +7,7 @@ import { import { isMacPlatform } from "./lib/utils"; export interface ShortcutEventLike { + type?: string; key: string; metaKey: boolean; ctrlKey: boolean; @@ -25,6 +26,11 @@ interface ShortcutMatchOptions { context?: Partial; } +const TERMINAL_WORD_BACKWARD = "\u001bb"; +const TERMINAL_WORD_FORWARD = "\u001bf"; +const TERMINAL_LINE_START = "\u0001"; +const TERMINAL_LINE_END = "\u0005"; + function normalizeEventKey(key: string): string { const normalized = key.toLowerCase(); if (normalized === "esc") return "escape"; @@ -204,6 +210,10 @@ export function isTerminalClearShortcut( event: ShortcutEventLike, platform = navigator.platform, ): boolean { + if (event.type !== undefined && event.type !== "keydown") { + return false; + } + const key = event.key.toLowerCase(); if (key === "l" && event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) { @@ -219,3 +229,42 @@ export function isTerminalClearShortcut( !event.shiftKey ); } + +export function terminalNavigationShortcutData( + event: ShortcutEventLike, + platform = navigator.platform, +): string | null { + if (event.type !== undefined && event.type !== "keydown") { + return null; + } + + if (event.shiftKey) return null; + + const key = normalizeEventKey(event.key); + if (key !== "arrowleft" && key !== "arrowright") { + return null; + } + + const moveWord = key === "arrowleft" ? TERMINAL_WORD_BACKWARD : TERMINAL_WORD_FORWARD; + const moveLine = key === "arrowleft" ? TERMINAL_LINE_START : TERMINAL_LINE_END; + + if (isMacPlatform(platform)) { + if (event.altKey && !event.metaKey && !event.ctrlKey) { + return moveWord; + } + if (event.metaKey && !event.altKey && !event.ctrlKey) { + return moveLine; + } + return null; + } + + if (event.ctrlKey && !event.metaKey && !event.altKey) { + return moveWord; + } + + if (event.altKey && !event.metaKey && !event.ctrlKey) { + return moveWord; + } + + return null; +}