authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-13 16:19:51-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-15 17:06:16-08:00
log765f4cd00bb05b4111f1279a8dac47b36665fccd
tree9115001e3b8a21e04ba8274a6590fb7dec2f4192
parent748ea2ae205122f8571287273599c5767bff1d55

Add terminal word and line navigation arrow shortcuts

- 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 mappings

3 files changed, 103 insertions(+), 5 deletions(-)

apps/web/src/components/ThreadTerminalDrawer.tsx+16-5
......@@ -18,7 +18,10 @@ import {
1818 preferredTerminalEditor,
1919 resolvePathLinkTarget,
2020} from "../terminal-links";
21import { isTerminalClearShortcut } from "../keybindings";
21import {
22 isTerminalClearShortcut,
23 terminalNavigationShortcutData,
24} from "../keybindings";
2225import {
2326 DEFAULT_THREAD_TERMINAL_HEIGHT,
2427 DEFAULT_THREAD_TERMINAL_ID,
......@@ -152,24 +155,32 @@ function TerminalViewport({
152155 terminalRef.current = terminal;
153156 fitAddonRef.current = fitAddon;
154157
155 const sendClearShortcut = async () => {
158 const sendTerminalInput = async (data: string, fallbackError: string) => {
156159 const activeTerminal = terminalRef.current;
157160 if (!activeTerminal) return;
158161 try {
159 await api.terminal.write({ threadId, terminalId, data: "\u000c" });
162 await api.terminal.write({ threadId, terminalId, data });
160163 } catch (error) {
161164 writeSystemMessage(
162165 activeTerminal,
163 error instanceof Error ? error.message : "Failed to clear terminal",
166 error instanceof Error ? error.message : fallbackError,
164167 );
165168 }
166169 };
167170
168171 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
169180 if (!isTerminalClearShortcut(event)) return true;
170181 event.preventDefault();
171182 event.stopPropagation();
172 void sendClearShortcut();
183 void sendTerminalInput("\u000c", "Failed to clear terminal");
173184 return false;
174185 });
175186
apps/web/src/keybindings.test.ts+47
......@@ -15,6 +15,7 @@ import {
1515 isTerminalSplitShortcut,
1616 isTerminalToggleShortcut,
1717 shortcutLabelForCommand,
18 terminalNavigationShortcutData,
1819 type ShortcutEventLike,
1920} from "./keybindings";
2021
......@@ -326,6 +327,52 @@ describe("isTerminalClearShortcut", () => {
326327 });
327328});
328329
330describe("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
329376describe("plus key parsing", () => {
330377 it("matches the plus key shortcut", () => {
331378 const plusBindings = compile([{ shortcut: modShortcut("+"), command: "terminal.toggle" }]);
apps/web/src/keybindings.ts+40
......@@ -25,6 +25,11 @@ interface ShortcutMatchOptions {
2525 context?: Partial<ShortcutMatchContext>;
2626}
2727
28const TERMINAL_WORD_BACKWARD = "\u001bb";
29const TERMINAL_WORD_FORWARD = "\u001bf";
30const TERMINAL_LINE_START = "\u0001";
31const TERMINAL_LINE_END = "\u0005";
32
2833function normalizeEventKey(key: string): string {
2934 const normalized = key.toLowerCase();
3035 if (normalized === "esc") return "escape";
......@@ -219,3 +224,38 @@ export function isTerminalClearShortcut(
219224 !event.shiftKey
220225 );
221226}
227
228export 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}