diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.test.ts index 71bf3c3f198..86531fc43a4 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.test.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.test.ts @@ -1,3 +1,4 @@ +import type { Terminal as XTerm } from "@xterm/xterm"; import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; // Mock localStorage for Node.js test environment @@ -30,9 +31,11 @@ mock.module("renderer/lib/trpc-client", () => ({ })); // Import after mocks are set up -const { getDefaultTerminalBg, getDefaultTerminalTheme } = await import( - "./helpers" -); +const { + getDefaultTerminalBg, + getDefaultTerminalTheme, + setupKeyboardHandler, +} = await import("./helpers"); describe("getDefaultTerminalTheme", () => { beforeEach(() => { @@ -108,3 +111,83 @@ describe("getDefaultTerminalBg", () => { expect(getDefaultTerminalBg()).toBe("#1a1a1a"); }); }); + +describe("setupKeyboardHandler", () => { + const originalNavigator = globalThis.navigator; + + afterEach(() => { + // Restore navigator between tests + // @ts-expect-error - resetting global navigator for tests + globalThis.navigator = originalNavigator; + }); + + it("maps Option+Left/Right to Meta+B/F on macOS", () => { + // @ts-expect-error - mocking navigator for tests + globalThis.navigator = { platform: "MacIntel" }; + + let handler: ((event: KeyboardEvent) => boolean) | null = null; + const xterm = { + attachCustomKeyEventHandler: (next: (event: KeyboardEvent) => boolean) => { + handler = next; + }, + }; + + const onWrite = mock(() => {}); + setupKeyboardHandler(xterm as unknown as XTerm, { onWrite }); + + handler?.({ + type: "keydown", + key: "ArrowLeft", + altKey: true, + metaKey: false, + ctrlKey: false, + shiftKey: false, + } as KeyboardEvent); + handler?.({ + type: "keydown", + key: "ArrowRight", + altKey: true, + metaKey: false, + ctrlKey: false, + shiftKey: false, + } as KeyboardEvent); + + expect(onWrite).toHaveBeenCalledWith("\x1bb"); + expect(onWrite).toHaveBeenCalledWith("\x1bf"); + }); + + it("maps Ctrl+Left/Right to Meta+B/F on Windows", () => { + // @ts-expect-error - mocking navigator for tests + globalThis.navigator = { platform: "Win32" }; + + let handler: ((event: KeyboardEvent) => boolean) | null = null; + const xterm = { + attachCustomKeyEventHandler: (next: (event: KeyboardEvent) => boolean) => { + handler = next; + }, + }; + + const onWrite = mock(() => {}); + setupKeyboardHandler(xterm as unknown as XTerm, { onWrite }); + + handler?.({ + type: "keydown", + key: "ArrowLeft", + altKey: false, + metaKey: false, + ctrlKey: true, + shiftKey: false, + } as KeyboardEvent); + handler?.({ + type: "keydown", + key: "ArrowRight", + altKey: false, + metaKey: false, + ctrlKey: true, + shiftKey: false, + } as KeyboardEvent); + + expect(onWrite).toHaveBeenCalledWith("\x1bb"); + expect(onWrite).toHaveBeenCalledWith("\x1bf"); + }); +}); 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 9fd216c641c..8b1dd1a8a11 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 @@ -484,6 +484,11 @@ export function setupKeyboardHandler( xterm: XTerm, options: KeyboardHandlerOptions = {}, ): () => void { + const platform = + typeof navigator !== "undefined" ? navigator.platform.toLowerCase() : ""; + const isMac = platform.includes("mac"); + const isWindows = platform.includes("win"); + const handler = (event: KeyboardEvent): boolean => { const isShiftEnter = event.key === "Enter" && @@ -543,6 +548,69 @@ export function setupKeyboardHandler( return false; } + // Option+Left/Right (macOS): word navigation (Meta+B / Meta+F) + const isOptionLeft = + event.key === "ArrowLeft" && + event.altKey && + isMac && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey; + + if (isOptionLeft) { + if (event.type === "keydown" && options.onWrite) { + options.onWrite("\x1bb"); // Meta+B - backward word + } + return false; + } + + // Option+Right: Move cursor forward by word (Meta+F) + const isOptionRight = + event.key === "ArrowRight" && + event.altKey && + isMac && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey; + + if (isOptionRight) { + if (event.type === "keydown" && options.onWrite) { + options.onWrite("\x1bf"); // Meta+F - forward word + } + return false; + } + + // Ctrl+Left/Right (Windows): word navigation (Meta+B / Meta+F) + const isCtrlLeft = + event.key === "ArrowLeft" && + event.ctrlKey && + isWindows && + !event.metaKey && + !event.altKey && + !event.shiftKey; + + if (isCtrlLeft) { + if (event.type === "keydown" && options.onWrite) { + options.onWrite("\x1bb"); // Meta+B - backward word + } + return false; + } + + const isCtrlRight = + event.key === "ArrowRight" && + event.ctrlKey && + isWindows && + !event.metaKey && + !event.altKey && + !event.shiftKey; + + if (isCtrlRight) { + if (event.type === "keydown" && options.onWrite) { + options.onWrite("\x1bf"); // Meta+F - forward word + } + return false; + } + if (isTerminalReservedEvent(event)) return true; const clearKeys = getHotkeyKeys("CLEAR_TERMINAL");