authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 11:19:51-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-15 15:51:05-08:00
logace58035818652ebf400fe71e102de050950e3af
tree4c2f9c452e575400864863cafd46aff04dd5a143
parent645b037c9e994ca408bec4ab658db2966eac7a25

Rename terminal-shortcuts module to keybindings

- Rename `terminal-shortcuts.ts` and its test to `keybindings` - Update ChatView and ThreadTerminalDrawer imports to use new module path

6 files changed, 637 insertions(+), 637 deletions(-)

apps/web/src/components/ChatView.tsx+1-1
...@@ -58,7 +58,7 @@ import {...@@ -58,7 +58,7 @@ import {
58 isTerminalToggleShortcut,58 isTerminalToggleShortcut,
59 resolveTerminalKeybindings,59 resolveTerminalKeybindings,
60 shortcutLabelForCommand,60 shortcutLabelForCommand,
61} from "../terminal-shortcuts";61} from "../keybindings";
62import ChatMarkdown from "./ChatMarkdown";62import ChatMarkdown from "./ChatMarkdown";
63import ThreadTerminalDrawer from "./ThreadTerminalDrawer";63import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
64import { useNativeApi } from "../hooks/useNativeApi";64import { useNativeApi } from "../hooks/useNativeApi";
apps/web/src/components/ThreadTerminalDrawer.tsx+1-1
...@@ -18,7 +18,7 @@ import {...@@ -18,7 +18,7 @@ import {
18 preferredTerminalEditor,18 preferredTerminalEditor,
19 resolvePathLinkTarget,19 resolvePathLinkTarget,
20} from "../terminal-links";20} from "../terminal-links";
21import { isTerminalClearShortcut } from "../terminal-shortcuts";21import { isTerminalClearShortcut } from "../keybindings";
22import {22import {
23 DEFAULT_THREAD_TERMINAL_HEIGHT,23 DEFAULT_THREAD_TERMINAL_HEIGHT,
24 DEFAULT_THREAD_TERMINAL_ID,24 DEFAULT_THREAD_TERMINAL_ID,
apps/web/src/keybindings.test.ts created+174
...@@ -0,0 +1,174 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 DEFAULT_TERMINAL_KEYBINDINGS,
5 formatShortcutLabel,
6 isTerminalClearShortcut,
7 isTerminalNewShortcut,
8 isTerminalSplitShortcut,
9 isTerminalToggleShortcut,
10 resolveTerminalKeybindings,
11 shortcutLabelForCommand,
12 type ShortcutEventLike,
13} from "./keybindings";
14
15function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike {
16 return {
17 key: "j",
18 metaKey: false,
19 ctrlKey: false,
20 shiftKey: false,
21 altKey: false,
22 ...overrides,
23 };
24}
25
26describe("isTerminalToggleShortcut", () => {
27 it("matches Cmd+J on macOS", () => {
28 expect(
29 isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, {
30 platform: "MacIntel",
31 }),
32 ).toBe(true);
33 });
34
35 it("matches Ctrl+J on non-macOS", () => {
36 expect(
37 isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, {
38 platform: "Win32",
39 }),
40 ).toBe(true);
41 });
42});
43
44describe("split/new terminal shortcuts", () => {
45 it("requires terminalFocus for default split/new bindings", () => {
46 expect(
47 isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, {
48 platform: "MacIntel",
49 context: { terminalFocus: false },
50 }),
51 ).toBe(false);
52 expect(
53 isTerminalNewShortcut(
54 event({ key: "d", ctrlKey: true, shiftKey: true }),
55 DEFAULT_TERMINAL_KEYBINDINGS,
56 {
57 platform: "Linux",
58 context: { terminalFocus: false },
59 },
60 ),
61 ).toBe(false);
62 });
63
64 it("matches split/new when terminalFocus is true", () => {
65 expect(
66 isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, {
67 platform: "MacIntel",
68 context: { terminalFocus: true },
69 }),
70 ).toBe(true);
71 expect(
72 isTerminalNewShortcut(
73 event({ key: "d", ctrlKey: true, shiftKey: true }),
74 DEFAULT_TERMINAL_KEYBINDINGS,
75 {
76 platform: "Linux",
77 context: { terminalFocus: true },
78 },
79 ),
80 ).toBe(true);
81 });
82
83 it("supports configured overrides with when expressions", () => {
84 const keybindings = resolveTerminalKeybindings([
85 {
86 key: "mod+\\",
87 command: "terminal.split",
88 when: "terminalOpen && !terminalFocus",
89 },
90 {
91 key: "mod+shift+n",
92 command: "terminal.new",
93 when: "terminalOpen && !terminalFocus",
94 },
95 ]);
96 expect(
97 isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, {
98 platform: "Win32",
99 context: { terminalOpen: true, terminalFocus: false },
100 }),
101 ).toBe(true);
102 expect(
103 isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, {
104 platform: "Win32",
105 context: { terminalOpen: false, terminalFocus: false },
106 }),
107 ).toBe(false);
108 expect(
109 isTerminalNewShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), keybindings, {
110 platform: "Win32",
111 context: { terminalOpen: true, terminalFocus: false },
112 }),
113 ).toBe(true);
114 });
115});
116
117describe("resolveTerminalKeybindings", () => {
118 it("uses defaults when config is empty", () => {
119 expect(resolveTerminalKeybindings(undefined)).toEqual(DEFAULT_TERMINAL_KEYBINDINGS);
120 });
121
122 it("replaces defaults for commands configured by user", () => {
123 const resolved = resolveTerminalKeybindings([
124 { key: "mod+\\", command: "terminal.split", when: "terminalFocus" },
125 ]);
126
127 expect(resolved).toEqual([
128 { key: "mod+j", command: "terminal.toggle" },
129 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
130 { key: "mod+\\", command: "terminal.split", when: "terminalFocus" },
131 ]);
132 });
133
134 it("ignores invalid configured values", () => {
135 expect(
136 resolveTerminalKeybindings([
137 { key: " ", command: "terminal.toggle" },
138 { key: "mod+d+f", command: "terminal.split" },
139 ]),
140 ).toEqual(DEFAULT_TERMINAL_KEYBINDINGS);
141 });
142});
143
144describe("shortcutLabelForCommand", () => {
145 it("returns the most recent binding label", () => {
146 const resolved = resolveTerminalKeybindings([
147 { key: "mod+\\", command: "terminal.split", when: "terminalFocus" },
148 { key: "mod+shift+\\", command: "terminal.split", when: "!terminalFocus" },
149 ]);
150
151 expect(shortcutLabelForCommand(resolved, "terminal.split", "Linux")).toBe("Ctrl+Shift+\\");
152 });
153});
154
155describe("formatShortcutLabel", () => {
156 it("formats labels for macOS", () => {
157 expect(formatShortcutLabel("mod+shift+d", "MacIntel")).toBe("⇧⌘D");
158 });
159
160 it("formats labels for non-macOS", () => {
161 expect(formatShortcutLabel("mod+shift+d", "Linux")).toBe("Ctrl+Shift+D");
162 });
163});
164
165describe("isTerminalClearShortcut", () => {
166 it("matches Ctrl+L on all platforms", () => {
167 expect(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "Linux")).toBe(true);
168 expect(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "MacIntel")).toBe(true);
169 });
170
171 it("matches Cmd+K on macOS", () => {
172 expect(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "MacIntel")).toBe(true);
173 });
174});
apps/web/src/keybindings.ts created+461
...@@ -0,0 +1,461 @@
1import type { KeybindingCommand, KeybindingsConfig } from "@t3tools/contracts";
2
3export interface ShortcutEventLike {
4 key: string;
5 metaKey: boolean;
6 ctrlKey: boolean;
7 shiftKey: boolean;
8 altKey: boolean;
9}
10
11export interface ShortcutMatchContext {
12 terminalFocus: boolean;
13 terminalOpen: boolean;
14 [key: string]: boolean;
15}
16
17export interface ResolvedTerminalKeybinding {
18 key: string;
19 command: KeybindingCommand;
20 when?: string | undefined;
21}
22
23export type ResolvedTerminalKeybindings = ResolvedTerminalKeybinding[];
24
25export const DEFAULT_TERMINAL_KEYBINDINGS: ResolvedTerminalKeybindings = [
26 { key: "mod+j", command: "terminal.toggle" },
27 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
28 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
29];
30
31interface ParsedShortcut {
32 key: string;
33 metaKey: boolean;
34 ctrlKey: boolean;
35 shiftKey: boolean;
36 altKey: boolean;
37 modKey: boolean;
38}
39
40interface ShortcutMatchOptions {
41 platform?: string;
42 context?: Partial<ShortcutMatchContext>;
43}
44
45type WhenNode =
46 | { type: "identifier"; name: string }
47 | { type: "not"; node: WhenNode }
48 | { type: "and"; left: WhenNode; right: WhenNode }
49 | { type: "or"; left: WhenNode; right: WhenNode };
50
51type WhenToken =
52 | { type: "identifier"; value: string }
53 | { type: "not" }
54 | { type: "and" }
55 | { type: "or" }
56 | { type: "lparen" }
57 | { type: "rparen" };
58
59const WHEN_AST_CACHE = new Map<string, WhenNode | null>();
60
61function isMacPlatform(platform: string): boolean {
62 return /mac|iphone|ipad|ipod/i.test(platform);
63}
64
65function normalizeKeyToken(token: string): string {
66 if (token === "space") return " ";
67 if (token === "esc") return "escape";
68 return token;
69}
70
71function parseShortcutValue(value: string): ParsedShortcut | null {
72 const tokens = value
73 .toLowerCase()
74 .split("+")
75 .map((token) => token.trim())
76 .filter((token) => token.length > 0);
77 if (tokens.length === 0) return null;
78
79 let key: string | null = null;
80 let metaKey = false;
81 let ctrlKey = false;
82 let shiftKey = false;
83 let altKey = false;
84 let modKey = false;
85
86 for (const token of tokens) {
87 switch (token) {
88 case "cmd":
89 case "meta":
90 metaKey = true;
91 break;
92 case "ctrl":
93 case "control":
94 ctrlKey = true;
95 break;
96 case "shift":
97 shiftKey = true;
98 break;
99 case "alt":
100 case "option":
101 altKey = true;
102 break;
103 case "mod":
104 modKey = true;
105 break;
106 default: {
107 if (key !== null) {
108 return null;
109 }
110 key = normalizeKeyToken(token);
111 }
112 }
113 }
114
115 if (key === null) return null;
116
117 return {
118 key,
119 metaKey,
120 ctrlKey,
121 shiftKey,
122 altKey,
123 modKey,
124 };
125}
126
127function normalizeEventKey(key: string): string {
128 const normalized = key.toLowerCase();
129 if (normalized === "esc") return "escape";
130 return normalized;
131}
132
133function matchesShortcutValue(
134 event: ShortcutEventLike,
135 shortcutValue: string,
136 platform = navigator.platform,
137): boolean {
138 const parsed = parseShortcutValue(shortcutValue);
139 if (!parsed) return false;
140
141 const key = normalizeEventKey(event.key);
142 if (key !== parsed.key) return false;
143
144 const useMetaForMod = isMacPlatform(platform);
145 const expectedMeta = parsed.metaKey || (parsed.modKey && useMetaForMod);
146 const expectedCtrl = parsed.ctrlKey || (parsed.modKey && !useMetaForMod);
147 return (
148 event.metaKey === expectedMeta &&
149 event.ctrlKey === expectedCtrl &&
150 event.shiftKey === parsed.shiftKey &&
151 event.altKey === parsed.altKey
152 );
153}
154
155function tokenizeWhenExpression(expression: string): WhenToken[] | null {
156 const tokens: WhenToken[] = [];
157 let index = 0;
158
159 while (index < expression.length) {
160 const current = expression[index];
161 if (!current) break;
162
163 if (/\s/.test(current)) {
164 index += 1;
165 continue;
166 }
167 if (expression.startsWith("&&", index)) {
168 tokens.push({ type: "and" });
169 index += 2;
170 continue;
171 }
172 if (expression.startsWith("||", index)) {
173 tokens.push({ type: "or" });
174 index += 2;
175 continue;
176 }
177 if (current === "!") {
178 tokens.push({ type: "not" });
179 index += 1;
180 continue;
181 }
182 if (current === "(") {
183 tokens.push({ type: "lparen" });
184 index += 1;
185 continue;
186 }
187 if (current === ")") {
188 tokens.push({ type: "rparen" });
189 index += 1;
190 continue;
191 }
192
193 const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index));
194 if (!identifier) {
195 return null;
196 }
197 tokens.push({ type: "identifier", value: identifier[0] });
198 index += identifier[0].length;
199 }
200
201 return tokens;
202}
203
204function parseWhenExpression(expression: string): WhenNode | null {
205 const tokens = tokenizeWhenExpression(expression);
206 if (!tokens || tokens.length === 0) return null;
207 let index = 0;
208
209 const parsePrimary = (): WhenNode | null => {
210 const token = tokens[index];
211 if (!token) return null;
212
213 if (token.type === "identifier") {
214 index += 1;
215 return { type: "identifier", name: token.value };
216 }
217
218 if (token.type === "lparen") {
219 index += 1;
220 const expressionNode = parseOr();
221 const closeToken = tokens[index];
222 if (!expressionNode || !closeToken || closeToken.type !== "rparen") {
223 return null;
224 }
225 index += 1;
226 return expressionNode;
227 }
228
229 return null;
230 };
231
232 const parseUnary = (): WhenNode | null => {
233 const token = tokens[index];
234 if (token?.type === "not") {
235 index += 1;
236 const node = parseUnary();
237 if (!node) return null;
238 return { type: "not", node };
239 }
240 return parsePrimary();
241 };
242
243 const parseAnd = (): WhenNode | null => {
244 let left = parseUnary();
245 if (!left) return null;
246
247 while (tokens[index]?.type === "and") {
248 index += 1;
249 const right = parseUnary();
250 if (!right) return null;
251 left = { type: "and", left, right };
252 }
253
254 return left;
255 };
256
257 const parseOr = (): WhenNode | null => {
258 let left = parseAnd();
259 if (!left) return null;
260
261 while (tokens[index]?.type === "or") {
262 index += 1;
263 const right = parseAnd();
264 if (!right) return null;
265 left = { type: "or", left, right };
266 }
267
268 return left;
269 };
270
271 const ast = parseOr();
272 if (!ast || index !== tokens.length) return null;
273 return ast;
274}
275
276function evaluateWhenNode(node: WhenNode, context: ShortcutMatchContext): boolean {
277 switch (node.type) {
278 case "identifier":
279 return Boolean(context[node.name]);
280 case "not":
281 return !evaluateWhenNode(node.node, context);
282 case "and":
283 return evaluateWhenNode(node.left, context) && evaluateWhenNode(node.right, context);
284 case "or":
285 return evaluateWhenNode(node.left, context) || evaluateWhenNode(node.right, context);
286 }
287}
288
289function matchesWhenExpression(when: string | undefined, context: ShortcutMatchContext): boolean {
290 if (!when) return true;
291 const normalized = when.trim();
292 if (normalized.length === 0) return true;
293
294 if (!WHEN_AST_CACHE.has(normalized)) {
295 WHEN_AST_CACHE.set(normalized, parseWhenExpression(normalized));
296 }
297 const ast = WHEN_AST_CACHE.get(normalized);
298 if (!ast) return false;
299 return evaluateWhenNode(ast, context);
300}
301
302function resolvePlatform(options: ShortcutMatchOptions | undefined): string {
303 return options?.platform ?? navigator.platform;
304}
305
306function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatchContext {
307 return {
308 terminalFocus: false,
309 terminalOpen: false,
310 ...options?.context,
311 };
312}
313
314function normalizeConfiguredKeybinding(
315 keybinding: KeybindingsConfig[number],
316): ResolvedTerminalKeybinding | null {
317 const key = keybinding.key.trim();
318 if (key.length === 0) return null;
319 if (!parseShortcutValue(key)) return null;
320
321 const when = keybinding.when?.trim();
322 return {
323 key,
324 command: keybinding.command,
325 ...(when ? { when } : {}),
326 };
327}
328
329function matchesCommandShortcut(
330 event: ShortcutEventLike,
331 keybindings: ResolvedTerminalKeybindings,
332 command: KeybindingCommand,
333 options?: ShortcutMatchOptions,
334): boolean {
335 const platform = resolvePlatform(options);
336 const context = resolveContext(options);
337
338 for (let index = keybindings.length - 1; index >= 0; index -= 1) {
339 const binding = keybindings[index];
340 if (!binding || binding.command !== command) continue;
341 if (!matchesWhenExpression(binding.when, context)) continue;
342 if (!matchesShortcutValue(event, binding.key, platform)) continue;
343 return true;
344 }
345 return false;
346}
347
348export function resolveTerminalKeybindings(
349 input: KeybindingsConfig | null | undefined,
350): ResolvedTerminalKeybindings {
351 if (!input || input.length === 0) {
352 return [...DEFAULT_TERMINAL_KEYBINDINGS];
353 }
354
355 const normalizedInput = input
356 .map((binding) => normalizeConfiguredKeybinding(binding))
357 .filter((binding): binding is ResolvedTerminalKeybinding => binding !== null);
358 if (normalizedInput.length === 0) {
359 return [...DEFAULT_TERMINAL_KEYBINDINGS];
360 }
361
362 const overriddenCommands = new Set(normalizedInput.map((binding) => binding.command));
363 const retainedDefaults = DEFAULT_TERMINAL_KEYBINDINGS.filter(
364 (binding) => !overriddenCommands.has(binding.command),
365 );
366 return [...retainedDefaults, ...normalizedInput];
367}
368
369function formatShortcutKeyLabel(key: string): string {
370 if (key === " ") return "Space";
371 if (key.length === 1) return key.toUpperCase();
372 if (key === "escape") return "Esc";
373 if (key === "arrowup") return "Up";
374 if (key === "arrowdown") return "Down";
375 if (key === "arrowleft") return "Left";
376 if (key === "arrowright") return "Right";
377 return key.slice(0, 1).toUpperCase() + key.slice(1);
378}
379
380export function formatShortcutLabel(shortcutValue: string, platform = navigator.platform): string {
381 const parsed = parseShortcutValue(shortcutValue);
382 if (!parsed) {
383 return shortcutValue;
384 }
385
386 const keyLabel = formatShortcutKeyLabel(parsed.key);
387 const useMetaForMod = isMacPlatform(platform);
388 const showMeta = parsed.metaKey || (parsed.modKey && useMetaForMod);
389 const showCtrl = parsed.ctrlKey || (parsed.modKey && !useMetaForMod);
390 const showAlt = parsed.altKey;
391 const showShift = parsed.shiftKey;
392
393 if (useMetaForMod) {
394 return `${showCtrl ? "\u2303" : ""}${showAlt ? "\u2325" : ""}${showShift ? "\u21e7" : ""}${showMeta ? "\u2318" : ""}${keyLabel}`;
395 }
396
397 const parts: string[] = [];
398 if (showCtrl) parts.push("Ctrl");
399 if (showAlt) parts.push("Alt");
400 if (showShift) parts.push("Shift");
401 if (showMeta) parts.push("Meta");
402 parts.push(keyLabel);
403 return parts.join("+");
404}
405
406export function shortcutLabelForCommand(
407 keybindings: ResolvedTerminalKeybindings,
408 command: KeybindingCommand,
409 platform = navigator.platform,
410): string | null {
411 for (let index = keybindings.length - 1; index >= 0; index -= 1) {
412 const binding = keybindings[index];
413 if (!binding || binding.command !== command) continue;
414 return formatShortcutLabel(binding.key, platform);
415 }
416 return null;
417}
418
419export function isTerminalToggleShortcut(
420 event: ShortcutEventLike,
421 keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS,
422 options?: ShortcutMatchOptions,
423): boolean {
424 return matchesCommandShortcut(event, keybindings, "terminal.toggle", options);
425}
426
427export function isTerminalSplitShortcut(
428 event: ShortcutEventLike,
429 keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS,
430 options?: ShortcutMatchOptions,
431): boolean {
432 return matchesCommandShortcut(event, keybindings, "terminal.split", options);
433}
434
435export function isTerminalNewShortcut(
436 event: ShortcutEventLike,
437 keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS,
438 options?: ShortcutMatchOptions,
439): boolean {
440 return matchesCommandShortcut(event, keybindings, "terminal.new", options);
441}
442
443export function isTerminalClearShortcut(
444 event: ShortcutEventLike,
445 platform = navigator.platform,
446): boolean {
447 const key = event.key.toLowerCase();
448
449 if (key === "l" && event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
450 return true;
451 }
452
453 return (
454 isMacPlatform(platform) &&
455 key === "k" &&
456 event.metaKey &&
457 !event.ctrlKey &&
458 !event.altKey &&
459 !event.shiftKey
460 );
461}
apps/web/src/terminal-shortcuts.test.ts deleted-174
...@@ -1,174 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 DEFAULT_TERMINAL_KEYBINDINGS,
5 formatShortcutLabel,
6 isTerminalClearShortcut,
7 isTerminalNewShortcut,
8 isTerminalSplitShortcut,
9 isTerminalToggleShortcut,
10 resolveTerminalKeybindings,
11 shortcutLabelForCommand,
12 type ShortcutEventLike,
13} from "./terminal-shortcuts";
14
15function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike {
16 return {
17 key: "j",
18 metaKey: false,
19 ctrlKey: false,
20 shiftKey: false,
21 altKey: false,
22 ...overrides,
23 };
24}
25
26describe("isTerminalToggleShortcut", () => {
27 it("matches Cmd+J on macOS", () => {
28 expect(
29 isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, {
30 platform: "MacIntel",
31 }),
32 ).toBe(true);
33 });
34
35 it("matches Ctrl+J on non-macOS", () => {
36 expect(
37 isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, {
38 platform: "Win32",
39 }),
40 ).toBe(true);
41 });
42});
43
44describe("split/new terminal shortcuts", () => {
45 it("requires terminalFocus for default split/new bindings", () => {
46 expect(
47 isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, {
48 platform: "MacIntel",
49 context: { terminalFocus: false },
50 }),
51 ).toBe(false);
52 expect(
53 isTerminalNewShortcut(
54 event({ key: "d", ctrlKey: true, shiftKey: true }),
55 DEFAULT_TERMINAL_KEYBINDINGS,
56 {
57 platform: "Linux",
58 context: { terminalFocus: false },
59 },
60 ),
61 ).toBe(false);
62 });
63
64 it("matches split/new when terminalFocus is true", () => {
65 expect(
66 isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, {
67 platform: "MacIntel",
68 context: { terminalFocus: true },
69 }),
70 ).toBe(true);
71 expect(
72 isTerminalNewShortcut(
73 event({ key: "d", ctrlKey: true, shiftKey: true }),
74 DEFAULT_TERMINAL_KEYBINDINGS,
75 {
76 platform: "Linux",
77 context: { terminalFocus: true },
78 },
79 ),
80 ).toBe(true);
81 });
82
83 it("supports configured overrides with when expressions", () => {
84 const keybindings = resolveTerminalKeybindings([
85 {
86 key: "mod+\\",
87 command: "terminal.split",
88 when: "terminalOpen && !terminalFocus",
89 },
90 {
91 key: "mod+shift+n",
92 command: "terminal.new",
93 when: "terminalOpen && !terminalFocus",
94 },
95 ]);
96 expect(
97 isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, {
98 platform: "Win32",
99 context: { terminalOpen: true, terminalFocus: false },
100 }),
101 ).toBe(true);
102 expect(
103 isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, {
104 platform: "Win32",
105 context: { terminalOpen: false, terminalFocus: false },
106 }),
107 ).toBe(false);
108 expect(
109 isTerminalNewShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), keybindings, {
110 platform: "Win32",
111 context: { terminalOpen: true, terminalFocus: false },
112 }),
113 ).toBe(true);
114 });
115});
116
117describe("resolveTerminalKeybindings", () => {
118 it("uses defaults when config is empty", () => {
119 expect(resolveTerminalKeybindings(undefined)).toEqual(DEFAULT_TERMINAL_KEYBINDINGS);
120 });
121
122 it("replaces defaults for commands configured by user", () => {
123 const resolved = resolveTerminalKeybindings([
124 { key: "mod+\\", command: "terminal.split", when: "terminalFocus" },
125 ]);
126
127 expect(resolved).toEqual([
128 { key: "mod+j", command: "terminal.toggle" },
129 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
130 { key: "mod+\\", command: "terminal.split", when: "terminalFocus" },
131 ]);
132 });
133
134 it("ignores invalid configured values", () => {
135 expect(
136 resolveTerminalKeybindings([
137 { key: " ", command: "terminal.toggle" },
138 { key: "mod+d+f", command: "terminal.split" },
139 ]),
140 ).toEqual(DEFAULT_TERMINAL_KEYBINDINGS);
141 });
142});
143
144describe("shortcutLabelForCommand", () => {
145 it("returns the most recent binding label", () => {
146 const resolved = resolveTerminalKeybindings([
147 { key: "mod+\\", command: "terminal.split", when: "terminalFocus" },
148 { key: "mod+shift+\\", command: "terminal.split", when: "!terminalFocus" },
149 ]);
150
151 expect(shortcutLabelForCommand(resolved, "terminal.split", "Linux")).toBe("Ctrl+Shift+\\");
152 });
153});
154
155describe("formatShortcutLabel", () => {
156 it("formats labels for macOS", () => {
157 expect(formatShortcutLabel("mod+shift+d", "MacIntel")).toBe("⇧⌘D");
158 });
159
160 it("formats labels for non-macOS", () => {
161 expect(formatShortcutLabel("mod+shift+d", "Linux")).toBe("Ctrl+Shift+D");
162 });
163});
164
165describe("isTerminalClearShortcut", () => {
166 it("matches Ctrl+L on all platforms", () => {
167 expect(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "Linux")).toBe(true);
168 expect(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "MacIntel")).toBe(true);
169 });
170
171 it("matches Cmd+K on macOS", () => {
172 expect(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "MacIntel")).toBe(true);
173 });
174});
apps/web/src/terminal-shortcuts.ts deleted-461
...@@ -1,461 +0,0 @@
1import type { KeybindingCommand, KeybindingsConfig } from "@t3tools/contracts";
2
3export interface ShortcutEventLike {
4 key: string;
5 metaKey: boolean;
6 ctrlKey: boolean;
7 shiftKey: boolean;
8 altKey: boolean;
9}
10
11export interface ShortcutMatchContext {
12 terminalFocus: boolean;
13 terminalOpen: boolean;
14 [key: string]: boolean;
15}
16
17export interface ResolvedTerminalKeybinding {
18 key: string;
19 command: KeybindingCommand;
20 when?: string | undefined;
21}
22
23export type ResolvedTerminalKeybindings = ResolvedTerminalKeybinding[];
24
25export const DEFAULT_TERMINAL_KEYBINDINGS: ResolvedTerminalKeybindings = [
26 { key: "mod+j", command: "terminal.toggle" },
27 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
28 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
29];
30
31interface ParsedShortcut {
32 key: string;
33 metaKey: boolean;
34 ctrlKey: boolean;
35 shiftKey: boolean;
36 altKey: boolean;
37 modKey: boolean;
38}
39
40interface ShortcutMatchOptions {
41 platform?: string;
42 context?: Partial<ShortcutMatchContext>;
43}
44
45type WhenNode =
46 | { type: "identifier"; name: string }
47 | { type: "not"; node: WhenNode }
48 | { type: "and"; left: WhenNode; right: WhenNode }
49 | { type: "or"; left: WhenNode; right: WhenNode };
50
51type WhenToken =
52 | { type: "identifier"; value: string }
53 | { type: "not" }
54 | { type: "and" }
55 | { type: "or" }
56 | { type: "lparen" }
57 | { type: "rparen" };
58
59const WHEN_AST_CACHE = new Map<string, WhenNode | null>();
60
61function isMacPlatform(platform: string): boolean {
62 return /mac|iphone|ipad|ipod/i.test(platform);
63}
64
65function normalizeKeyToken(token: string): string {
66 if (token === "space") return " ";
67 if (token === "esc") return "escape";
68 return token;
69}
70
71function parseShortcutValue(value: string): ParsedShortcut | null {
72 const tokens = value
73 .toLowerCase()
74 .split("+")
75 .map((token) => token.trim())
76 .filter((token) => token.length > 0);
77 if (tokens.length === 0) return null;
78
79 let key: string | null = null;
80 let metaKey = false;
81 let ctrlKey = false;
82 let shiftKey = false;
83 let altKey = false;
84 let modKey = false;
85
86 for (const token of tokens) {
87 switch (token) {
88 case "cmd":
89 case "meta":
90 metaKey = true;
91 break;
92 case "ctrl":
93 case "control":
94 ctrlKey = true;
95 break;
96 case "shift":
97 shiftKey = true;
98 break;
99 case "alt":
100 case "option":
101 altKey = true;
102 break;
103 case "mod":
104 modKey = true;
105 break;
106 default: {
107 if (key !== null) {
108 return null;
109 }
110 key = normalizeKeyToken(token);
111 }
112 }
113 }
114
115 if (key === null) return null;
116
117 return {
118 key,
119 metaKey,
120 ctrlKey,
121 shiftKey,
122 altKey,
123 modKey,
124 };
125}
126
127function normalizeEventKey(key: string): string {
128 const normalized = key.toLowerCase();
129 if (normalized === "esc") return "escape";
130 return normalized;
131}
132
133function matchesShortcutValue(
134 event: ShortcutEventLike,
135 shortcutValue: string,
136 platform = navigator.platform,
137): boolean {
138 const parsed = parseShortcutValue(shortcutValue);
139 if (!parsed) return false;
140
141 const key = normalizeEventKey(event.key);
142 if (key !== parsed.key) return false;
143
144 const useMetaForMod = isMacPlatform(platform);
145 const expectedMeta = parsed.metaKey || (parsed.modKey && useMetaForMod);
146 const expectedCtrl = parsed.ctrlKey || (parsed.modKey && !useMetaForMod);
147 return (
148 event.metaKey === expectedMeta &&
149 event.ctrlKey === expectedCtrl &&
150 event.shiftKey === parsed.shiftKey &&
151 event.altKey === parsed.altKey
152 );
153}
154
155function tokenizeWhenExpression(expression: string): WhenToken[] | null {
156 const tokens: WhenToken[] = [];
157 let index = 0;
158
159 while (index < expression.length) {
160 const current = expression[index];
161 if (!current) break;
162
163 if (/\s/.test(current)) {
164 index += 1;
165 continue;
166 }
167 if (expression.startsWith("&&", index)) {
168 tokens.push({ type: "and" });
169 index += 2;
170 continue;
171 }
172 if (expression.startsWith("||", index)) {
173 tokens.push({ type: "or" });
174 index += 2;
175 continue;
176 }
177 if (current === "!") {
178 tokens.push({ type: "not" });
179 index += 1;
180 continue;
181 }
182 if (current === "(") {
183 tokens.push({ type: "lparen" });
184 index += 1;
185 continue;
186 }
187 if (current === ")") {
188 tokens.push({ type: "rparen" });
189 index += 1;
190 continue;
191 }
192
193 const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index));
194 if (!identifier) {
195 return null;
196 }
197 tokens.push({ type: "identifier", value: identifier[0] });
198 index += identifier[0].length;
199 }
200
201 return tokens;
202}
203
204function parseWhenExpression(expression: string): WhenNode | null {
205 const tokens = tokenizeWhenExpression(expression);
206 if (!tokens || tokens.length === 0) return null;
207 let index = 0;
208
209 const parsePrimary = (): WhenNode | null => {
210 const token = tokens[index];
211 if (!token) return null;
212
213 if (token.type === "identifier") {
214 index += 1;
215 return { type: "identifier", name: token.value };
216 }
217
218 if (token.type === "lparen") {
219 index += 1;
220 const expressionNode = parseOr();
221 const closeToken = tokens[index];
222 if (!expressionNode || !closeToken || closeToken.type !== "rparen") {
223 return null;
224 }
225 index += 1;
226 return expressionNode;
227 }
228
229 return null;
230 };
231
232 const parseUnary = (): WhenNode | null => {
233 const token = tokens[index];
234 if (token?.type === "not") {
235 index += 1;
236 const node = parseUnary();
237 if (!node) return null;
238 return { type: "not", node };
239 }
240 return parsePrimary();
241 };
242
243 const parseAnd = (): WhenNode | null => {
244 let left = parseUnary();
245 if (!left) return null;
246
247 while (tokens[index]?.type === "and") {
248 index += 1;
249 const right = parseUnary();
250 if (!right) return null;
251 left = { type: "and", left, right };
252 }
253
254 return left;
255 };
256
257 const parseOr = (): WhenNode | null => {
258 let left = parseAnd();
259 if (!left) return null;
260
261 while (tokens[index]?.type === "or") {
262 index += 1;
263 const right = parseAnd();
264 if (!right) return null;
265 left = { type: "or", left, right };
266 }
267
268 return left;
269 };
270
271 const ast = parseOr();
272 if (!ast || index !== tokens.length) return null;
273 return ast;
274}
275
276function evaluateWhenNode(node: WhenNode, context: ShortcutMatchContext): boolean {
277 switch (node.type) {
278 case "identifier":
279 return Boolean(context[node.name]);
280 case "not":
281 return !evaluateWhenNode(node.node, context);
282 case "and":
283 return evaluateWhenNode(node.left, context) && evaluateWhenNode(node.right, context);
284 case "or":
285 return evaluateWhenNode(node.left, context) || evaluateWhenNode(node.right, context);
286 }
287}
288
289function matchesWhenExpression(when: string | undefined, context: ShortcutMatchContext): boolean {
290 if (!when) return true;
291 const normalized = when.trim();
292 if (normalized.length === 0) return true;
293
294 if (!WHEN_AST_CACHE.has(normalized)) {
295 WHEN_AST_CACHE.set(normalized, parseWhenExpression(normalized));
296 }
297 const ast = WHEN_AST_CACHE.get(normalized);
298 if (!ast) return false;
299 return evaluateWhenNode(ast, context);
300}
301
302function resolvePlatform(options: ShortcutMatchOptions | undefined): string {
303 return options?.platform ?? navigator.platform;
304}
305
306function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatchContext {
307 return {
308 terminalFocus: false,
309 terminalOpen: false,
310 ...options?.context,
311 };
312}
313
314function normalizeConfiguredKeybinding(
315 keybinding: KeybindingsConfig[number],
316): ResolvedTerminalKeybinding | null {
317 const key = keybinding.key.trim();
318 if (key.length === 0) return null;
319 if (!parseShortcutValue(key)) return null;
320
321 const when = keybinding.when?.trim();
322 return {
323 key,
324 command: keybinding.command,
325 ...(when ? { when } : {}),
326 };
327}
328
329function matchesCommandShortcut(
330 event: ShortcutEventLike,
331 keybindings: ResolvedTerminalKeybindings,
332 command: KeybindingCommand,
333 options?: ShortcutMatchOptions,
334): boolean {
335 const platform = resolvePlatform(options);
336 const context = resolveContext(options);
337
338 for (let index = keybindings.length - 1; index >= 0; index -= 1) {
339 const binding = keybindings[index];
340 if (!binding || binding.command !== command) continue;
341 if (!matchesWhenExpression(binding.when, context)) continue;
342 if (!matchesShortcutValue(event, binding.key, platform)) continue;
343 return true;
344 }
345 return false;
346}
347
348export function resolveTerminalKeybindings(
349 input: KeybindingsConfig | null | undefined,
350): ResolvedTerminalKeybindings {
351 if (!input || input.length === 0) {
352 return [...DEFAULT_TERMINAL_KEYBINDINGS];
353 }
354
355 const normalizedInput = input
356 .map((binding) => normalizeConfiguredKeybinding(binding))
357 .filter((binding): binding is ResolvedTerminalKeybinding => binding !== null);
358 if (normalizedInput.length === 0) {
359 return [...DEFAULT_TERMINAL_KEYBINDINGS];
360 }
361
362 const overriddenCommands = new Set(normalizedInput.map((binding) => binding.command));
363 const retainedDefaults = DEFAULT_TERMINAL_KEYBINDINGS.filter(
364 (binding) => !overriddenCommands.has(binding.command),
365 );
366 return [...retainedDefaults, ...normalizedInput];
367}
368
369function formatShortcutKeyLabel(key: string): string {
370 if (key === " ") return "Space";
371 if (key.length === 1) return key.toUpperCase();
372 if (key === "escape") return "Esc";
373 if (key === "arrowup") return "Up";
374 if (key === "arrowdown") return "Down";
375 if (key === "arrowleft") return "Left";
376 if (key === "arrowright") return "Right";
377 return key.slice(0, 1).toUpperCase() + key.slice(1);
378}
379
380export function formatShortcutLabel(shortcutValue: string, platform = navigator.platform): string {
381 const parsed = parseShortcutValue(shortcutValue);
382 if (!parsed) {
383 return shortcutValue;
384 }
385
386 const keyLabel = formatShortcutKeyLabel(parsed.key);
387 const useMetaForMod = isMacPlatform(platform);
388 const showMeta = parsed.metaKey || (parsed.modKey && useMetaForMod);
389 const showCtrl = parsed.ctrlKey || (parsed.modKey && !useMetaForMod);
390 const showAlt = parsed.altKey;
391 const showShift = parsed.shiftKey;
392
393 if (useMetaForMod) {
394 return `${showCtrl ? "\u2303" : ""}${showAlt ? "\u2325" : ""}${showShift ? "\u21e7" : ""}${showMeta ? "\u2318" : ""}${keyLabel}`;
395 }
396
397 const parts: string[] = [];
398 if (showCtrl) parts.push("Ctrl");
399 if (showAlt) parts.push("Alt");
400 if (showShift) parts.push("Shift");
401 if (showMeta) parts.push("Meta");
402 parts.push(keyLabel);
403 return parts.join("+");
404}
405
406export function shortcutLabelForCommand(
407 keybindings: ResolvedTerminalKeybindings,
408 command: KeybindingCommand,
409 platform = navigator.platform,
410): string | null {
411 for (let index = keybindings.length - 1; index >= 0; index -= 1) {
412 const binding = keybindings[index];
413 if (!binding || binding.command !== command) continue;
414 return formatShortcutLabel(binding.key, platform);
415 }
416 return null;
417}
418
419export function isTerminalToggleShortcut(
420 event: ShortcutEventLike,
421 keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS,
422 options?: ShortcutMatchOptions,
423): boolean {
424 return matchesCommandShortcut(event, keybindings, "terminal.toggle", options);
425}
426
427export function isTerminalSplitShortcut(
428 event: ShortcutEventLike,
429 keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS,
430 options?: ShortcutMatchOptions,
431): boolean {
432 return matchesCommandShortcut(event, keybindings, "terminal.split", options);
433}
434
435export function isTerminalNewShortcut(
436 event: ShortcutEventLike,
437 keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS,
438 options?: ShortcutMatchOptions,
439): boolean {
440 return matchesCommandShortcut(event, keybindings, "terminal.new", options);
441}
442
443export function isTerminalClearShortcut(
444 event: ShortcutEventLike,
445 platform = navigator.platform,
446): boolean {
447 const key = event.key.toLowerCase();
448
449 if (key === "l" && event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
450 return true;
451 }
452
453 return (
454 isMacPlatform(platform) &&
455 key === "k" &&
456 event.metaKey &&
457 !event.ctrlKey &&
458 !event.altKey &&
459 !event.shiftKey
460 );
461}