| author | |
| committer | |
| log | 765f4cd00bb05b4111f1279a8dac47b36665fccd |
| tree | 9115001e3b8a21e04ba8274a6590fb7dec2f4192 |
| parent | 748ea2ae205122f8571287273599c5767bff1d55 |
- handle platform-specific modifier+arrow combos in terminal input
- reuse terminal write path for clear and navigation actions
- add shortcut tests for macOS and non-macOS mappings3 files changed, 103 insertions(+), 5 deletions(-)
apps/web/src/components/ThreadTerminalDrawer.tsx+16-5| ... | ... | @@ -18,7 +18,10 @@ import { |
| 18 | 18 | preferredTerminalEditor, |
| 19 | 19 | resolvePathLinkTarget, |
| 20 | 20 | } from "../terminal-links"; |
| 21 | import { isTerminalClearShortcut } from "../keybindings"; | |
| 21 | import { | |
| 22 | isTerminalClearShortcut, | |
| 23 | terminalNavigationShortcutData, | |
| 24 | } from "../keybindings"; | |
| 22 | 25 | import { |
| 23 | 26 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 24 | 27 | DEFAULT_THREAD_TERMINAL_ID, |
| ... | ... | @@ -152,24 +155,32 @@ function TerminalViewport({ |
| 152 | 155 | terminalRef.current = terminal; |
| 153 | 156 | fitAddonRef.current = fitAddon; |
| 154 | 157 | |
| 155 | const sendClearShortcut = async () => { | |
| 158 | const sendTerminalInput = async (data: string, fallbackError: string) => { | |
| 156 | 159 | const activeTerminal = terminalRef.current; |
| 157 | 160 | if (!activeTerminal) return; |
| 158 | 161 | try { |
| 159 | await api.terminal.write({ threadId, terminalId, data: "\u000c" }); | |
| 162 | await api.terminal.write({ threadId, terminalId, data }); | |
| 160 | 163 | } catch (error) { |
| 161 | 164 | writeSystemMessage( |
| 162 | 165 | activeTerminal, |
| 163 | error instanceof Error ? error.message : "Failed to clear terminal", | |
| 166 | error instanceof Error ? error.message : fallbackError, | |
| 164 | 167 | ); |
| 165 | 168 | } |
| 166 | 169 | }; |
| 167 | 170 | |
| 168 | 171 | terminal.attachCustomKeyEventHandler((event) => { |
| 172 | const navigationData = terminalNavigationShortcutData(event); | |
| 173 | if (navigationData !== null) { | |
| 174 | event.preventDefault(); | |
| 175 | event.stopPropagation(); | |
| 176 | void sendTerminalInput(navigationData, "Failed to move cursor"); | |
| 177 | return false; | |
| 178 | } | |
| 179 | ||
| 169 | 180 | if (!isTerminalClearShortcut(event)) return true; |
| 170 | 181 | event.preventDefault(); |
| 171 | 182 | event.stopPropagation(); |
| 172 | void sendClearShortcut(); | |
| 183 | void sendTerminalInput("\u000c", "Failed to clear terminal"); | |
| 173 | 184 | return false; |
| 174 | 185 | }); |
| 175 | 186 |
apps/web/src/keybindings.test.ts+47| ... | ... | @@ -15,6 +15,7 @@ import { |
| 15 | 15 | isTerminalSplitShortcut, |
| 16 | 16 | isTerminalToggleShortcut, |
| 17 | 17 | shortcutLabelForCommand, |
| 18 | terminalNavigationShortcutData, | |
| 18 | 19 | type ShortcutEventLike, |
| 19 | 20 | } from "./keybindings"; |
| 20 | 21 | |
| ... | ... | @@ -326,6 +327,52 @@ describe("isTerminalClearShortcut", () => { |
| 326 | 327 | }); |
| 327 | 328 | }); |
| 328 | 329 | |
| 330 | describe("terminalNavigationShortcutData", () => { | |
| 331 | it("maps Option+Arrow on macOS to word movement", () => { | |
| 332 | assert.strictEqual( | |
| 333 | terminalNavigationShortcutData(event({ key: "ArrowLeft", altKey: true }), "MacIntel"), | |
| 334 | "\u001bb", | |
| 335 | ); | |
| 336 | assert.strictEqual( | |
| 337 | terminalNavigationShortcutData(event({ key: "ArrowRight", altKey: true }), "MacIntel"), | |
| 338 | "\u001bf", | |
| 339 | ); | |
| 340 | }); | |
| 341 | ||
| 342 | it("maps Cmd+Arrow on macOS to line movement", () => { | |
| 343 | assert.strictEqual( | |
| 344 | terminalNavigationShortcutData(event({ key: "ArrowLeft", metaKey: true }), "MacIntel"), | |
| 345 | "\u0001", | |
| 346 | ); | |
| 347 | assert.strictEqual( | |
| 348 | terminalNavigationShortcutData(event({ key: "ArrowRight", metaKey: true }), "MacIntel"), | |
| 349 | "\u0005", | |
| 350 | ); | |
| 351 | }); | |
| 352 | ||
| 353 | it("maps Ctrl+Arrow on non-macOS to word movement", () => { | |
| 354 | assert.strictEqual( | |
| 355 | terminalNavigationShortcutData(event({ key: "ArrowLeft", ctrlKey: true }), "Win32"), | |
| 356 | "\u001bb", | |
| 357 | ); | |
| 358 | assert.strictEqual( | |
| 359 | terminalNavigationShortcutData(event({ key: "ArrowRight", ctrlKey: true }), "Linux"), | |
| 360 | "\u001bf", | |
| 361 | ); | |
| 362 | }); | |
| 363 | ||
| 364 | it("rejects unsupported combinations", () => { | |
| 365 | assert.isNull( | |
| 366 | terminalNavigationShortcutData( | |
| 367 | event({ key: "ArrowLeft", shiftKey: true, altKey: true }), | |
| 368 | "MacIntel", | |
| 369 | ), | |
| 370 | ); | |
| 371 | assert.isNull(terminalNavigationShortcutData(event({ key: "ArrowLeft", metaKey: true }), "Linux")); | |
| 372 | assert.isNull(terminalNavigationShortcutData(event({ key: "a", altKey: true }), "MacIntel")); | |
| 373 | }); | |
| 374 | }); | |
| 375 | ||
| 329 | 376 | describe("plus key parsing", () => { |
| 330 | 377 | it("matches the plus key shortcut", () => { |
| 331 | 378 | const plusBindings = compile([{ shortcut: modShortcut("+"), command: "terminal.toggle" }]); |
apps/web/src/keybindings.ts+40| ... | ... | @@ -25,6 +25,11 @@ interface ShortcutMatchOptions { |
| 25 | 25 | context?: Partial<ShortcutMatchContext>; |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | const TERMINAL_WORD_BACKWARD = "\u001bb"; | |
| 29 | const TERMINAL_WORD_FORWARD = "\u001bf"; | |
| 30 | const TERMINAL_LINE_START = "\u0001"; | |
| 31 | const TERMINAL_LINE_END = "\u0005"; | |
| 32 | ||
| 28 | 33 | function normalizeEventKey(key: string): string { |
| 29 | 34 | const normalized = key.toLowerCase(); |
| 30 | 35 | if (normalized === "esc") return "escape"; |
| ... | ... | @@ -219,3 +224,38 @@ export function isTerminalClearShortcut( |
| 219 | 224 | !event.shiftKey |
| 220 | 225 | ); |
| 221 | 226 | } |
| 227 | ||
| 228 | export function terminalNavigationShortcutData( | |
| 229 | event: ShortcutEventLike, | |
| 230 | platform = navigator.platform, | |
| 231 | ): string | null { | |
| 232 | if (event.shiftKey) return null; | |
| 233 | ||
| 234 | const key = normalizeEventKey(event.key); | |
| 235 | if (key !== "arrowleft" && key !== "arrowright") { | |
| 236 | return null; | |
| 237 | } | |
| 238 | ||
| 239 | const moveWord = key === "arrowleft" ? TERMINAL_WORD_BACKWARD : TERMINAL_WORD_FORWARD; | |
| 240 | const moveLine = key === "arrowleft" ? TERMINAL_LINE_START : TERMINAL_LINE_END; | |
| 241 | ||
| 242 | if (isMacPlatform(platform)) { | |
| 243 | if (event.altKey && !event.metaKey && !event.ctrlKey) { | |
| 244 | return moveWord; | |
| 245 | } | |
| 246 | if (event.metaKey && !event.altKey && !event.ctrlKey) { | |
| 247 | return moveLine; | |
| 248 | } | |
| 249 | return null; | |
| 250 | } | |
| 251 | ||
| 252 | if (event.ctrlKey && !event.metaKey && !event.altKey) { | |
| 253 | return moveWord; | |
| 254 | } | |
| 255 | ||
| 256 | if (event.altKey && !event.metaKey && !event.ctrlKey) { | |
| 257 | return moveWord; | |
| 258 | } | |
| 259 | ||
| 260 | return null; | |
| 261 | } |