authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 11:48:43-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-15 15:51:05-08:00
logef4ce2e1ec3a6e08ccc392b802462519cbb706bc
tree5ee31f7b71e8c315d30b1c0fdae7b25f0c28d9bc
parent61e9ba4b19fc4ed58bb59e23a2aaa995ee3197bd

Add configurable shortcuts for new chat and open-in-editor

- add `chat.new` and `editor.openFavorite` to keybinding contracts and server defaults - use shared keybinding matching in sidebar/chat view instead of hardcoded Cmd/Ctrl combos - document new commands/defaults and extend server/web keybinding tests

8 files changed, 122 insertions(+), 41 deletions(-)

KEYBINDINGS.md+7-1
...@@ -19,7 +19,9 @@ The file must be a JSON array of rules:...@@ -19,7 +19,9 @@ The file must be a JSON array of rules:
19[19[
20 { "key": "mod+j", "command": "terminal.toggle" },20 { "key": "mod+j", "command": "terminal.toggle" },
21 { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" },21 { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" },
22 { "key": "mod+shift+d", "command": "terminal.new", "when": "terminalFocus" }22 { "key": "mod+shift+d", "command": "terminal.new", "when": "terminalFocus" },
23 { "key": "mod+shift+o", "command": "chat.new" },
24 { "key": "mod+o", "command": "editor.openFavorite" }
23]25]
24```26```
2527
...@@ -38,6 +40,8 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged...@@ -38,6 +40,8 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged
38- `terminal.toggle`: open/close terminal drawer40- `terminal.toggle`: open/close terminal drawer
39- `terminal.split`: split terminal (in focused terminal context by default)41- `terminal.split`: split terminal (in focused terminal context by default)
40- `terminal.new`: create new terminal (in focused terminal context by default)42- `terminal.new`: create new terminal (in focused terminal context by default)
43- `chat.new`: create a new chat thread for the active project
44- `editor.openFavorite`: open current project/worktree in the last-used editor
4145
42## Key Syntax46## Key Syntax
4347
...@@ -85,6 +89,8 @@ Built-in defaults are resolved on the server and sent to the web client via `ser...@@ -85,6 +89,8 @@ Built-in defaults are resolved on the server and sent to the web client via `ser
85- `terminal.toggle`: `mod+j`89- `terminal.toggle`: `mod+j`
86- `terminal.split`: `mod+d` when `terminalFocus`90- `terminal.split`: `mod+d` when `terminalFocus`
87- `terminal.new`: `mod+shift+d` when `terminalFocus`91- `terminal.new`: `mod+shift+d` when `terminalFocus`
92- `chat.new`: `mod+shift+o`
93- `editor.openFavorite`: `mod+o`
8894
89If you define any rules for a command, the default rule for that command is removed on the server.95If you define any rules for a command, the default rule for that command is removed on the server.
9096
apps/server/src/wsServer.test.ts+10
...@@ -313,6 +313,8 @@ describe("WebSocket Server", () => {...@@ -313,6 +313,8 @@ describe("WebSocket Server", () => {
313 { key: "mod+j", command: "terminal.toggle" },313 { key: "mod+j", command: "terminal.toggle" },
314 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },314 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
315 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },315 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
316 { key: "mod+shift+o", command: "chat.new" },
317 { key: "mod+o", command: "editor.openFavorite" },
316 ],318 ],
317 });319 });
318 });320 });
...@@ -347,6 +349,8 @@ describe("WebSocket Server", () => {...@@ -347,6 +349,8 @@ describe("WebSocket Server", () => {
347 expect(response.result).toEqual({349 expect(response.result).toEqual({
348 cwd: "/my/workspace",350 cwd: "/my/workspace",
349 keybindings: [351 keybindings: [
352 { key: "mod+shift+o", command: "chat.new" },
353 { key: "mod+o", command: "editor.openFavorite" },
350 { key: "cmd+j", command: "terminal.toggle" },354 { key: "cmd+j", command: "terminal.toggle" },
351 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },355 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
352 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },356 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
...@@ -386,6 +390,8 @@ describe("WebSocket Server", () => {...@@ -386,6 +390,8 @@ describe("WebSocket Server", () => {
386 keybindings: [390 keybindings: [
387 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },391 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
388 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },392 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
393 { key: "mod+shift+o", command: "chat.new" },
394 { key: "mod+o", command: "editor.openFavorite" },
389 { key: "mod+j", command: "terminal.toggle" },395 { key: "mod+j", command: "terminal.toggle" },
390 ],396 ],
391 });397 });
...@@ -426,6 +432,8 @@ describe("WebSocket Server", () => {...@@ -426,6 +432,8 @@ describe("WebSocket Server", () => {
426 { key: "mod+j", command: "terminal.toggle" },432 { key: "mod+j", command: "terminal.toggle" },
427 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },433 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
428 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },434 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
435 { key: "mod+shift+o", command: "chat.new" },
436 { key: "mod+o", command: "editor.openFavorite" },
429 ],437 ],
430 });438 });
431 expect(439 expect(
...@@ -461,6 +469,8 @@ describe("WebSocket Server", () => {...@@ -461,6 +469,8 @@ describe("WebSocket Server", () => {
461 { key: "mod+j", command: "terminal.toggle" },469 { key: "mod+j", command: "terminal.toggle" },
462 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },470 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
463 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },471 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
472 { key: "mod+shift+o", command: "chat.new" },
473 { key: "mod+o", command: "editor.openFavorite" },
464 ],474 ],
465 });475 });
466 expect(476 expect(
apps/server/src/wsServer.ts+2
...@@ -74,6 +74,8 @@ const DEFAULT_KEYBINDINGS: KeybindingsConfig = [...@@ -74,6 +74,8 @@ const DEFAULT_KEYBINDINGS: KeybindingsConfig = [
74 { key: "mod+j", command: "terminal.toggle" },74 { key: "mod+j", command: "terminal.toggle" },
75 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },75 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
76 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },76 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
77 { key: "mod+shift+o", command: "chat.new" },
78 { key: "mod+o", command: "editor.openFavorite" },
77];79];
7880
79function mergeWithDefaultKeybindings(custom: KeybindingsConfig): KeybindingsConfig {81function mergeWithDefaultKeybindings(custom: KeybindingsConfig): KeybindingsConfig {
apps/web/src/components/ChatView.tsx+27-25
...@@ -54,6 +54,7 @@ import type { ChatImageAttachment } from "../types";...@@ -54,6 +54,7 @@ import type { ChatImageAttachment } from "../types";
54import BranchToolbar from "./BranchToolbar";54import BranchToolbar from "./BranchToolbar";
55import GitActionsControl from "./GitActionsControl";55import GitActionsControl from "./GitActionsControl";
56import {56import {
57 isOpenFavoriteEditorShortcut,
57 isTerminalNewShortcut,58 isTerminalNewShortcut,
58 isTerminalSplitShortcut,59 isTerminalSplitShortcut,
59 isTerminalToggleShortcut,60 isTerminalToggleShortcut,
...@@ -263,20 +264,20 @@ export default function ChatView() {...@@ -263,20 +264,20 @@ export default function ChatView() {
263 } as const);264 } as const);
264 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;265 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
265 const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd));266 const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd));
266 const terminalKeybindingsQuery = useQuery({267 const keybindingsQuery = useQuery({
267 ...serverConfigQueryOptions(api),268 ...serverConfigQueryOptions(api),
268 select: (config) => config.keybindings,269 select: (config) => config.keybindings,
269 });270 });
270 const terminalKeybindings = terminalKeybindingsQuery.data ?? EMPTY_KEYBINDINGS;271 const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS;
271 // Default true while loading to avoid toolbar flicker.272 // Default true while loading to avoid toolbar flicker.
272 const isGitRepo = branchesQuery.data?.isRepo ?? true;273 const isGitRepo = branchesQuery.data?.isRepo ?? true;
273 const splitTerminalShortcutLabel = useMemo(274 const splitTerminalShortcutLabel = useMemo(
274 () => shortcutLabelForCommand(terminalKeybindings, "terminal.split"),275 () => shortcutLabelForCommand(keybindings, "terminal.split"),
275 [terminalKeybindings],276 [keybindings],
276 );277 );
277 const newTerminalShortcutLabel = useMemo(278 const newTerminalShortcutLabel = useMemo(
278 () => shortcutLabelForCommand(terminalKeybindings, "terminal.new"),279 () => shortcutLabelForCommand(keybindings, "terminal.new"),
279 [terminalKeybindings],280 [keybindings],
280 );281 );
281282
282 const envLocked = Boolean(283 const envLocked = Boolean(
...@@ -527,14 +528,14 @@ export default function ChatView() {...@@ -527,14 +528,14 @@ export default function ChatView() {
527 terminalOpen: Boolean(activeThread?.terminalOpen),528 terminalOpen: Boolean(activeThread?.terminalOpen),
528 };529 };
529530
530 if (isTerminalToggleShortcut(event, terminalKeybindings, { context: shortcutContext })) {531 if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) {
531 event.preventDefault();532 event.preventDefault();
532 event.stopPropagation();533 event.stopPropagation();
533 toggleTerminalVisibility();534 toggleTerminalVisibility();
534 return;535 return;
535 }536 }
536537
537 if (isTerminalSplitShortcut(event, terminalKeybindings, { context: shortcutContext })) {538 if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) {
538 event.preventDefault();539 event.preventDefault();
539 event.stopPropagation();540 event.stopPropagation();
540 if (!activeThread?.terminalOpen) {541 if (!activeThread?.terminalOpen) {
...@@ -548,7 +549,7 @@ export default function ChatView() {...@@ -548,7 +549,7 @@ export default function ChatView() {
548 return;549 return;
549 }550 }
550551
551 if (!isTerminalNewShortcut(event, terminalKeybindings, { context: shortcutContext })) return;552 if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return;
552 event.preventDefault();553 event.preventDefault();
553 event.stopPropagation();554 event.stopPropagation();
554 if (!activeThread?.terminalOpen) {555 if (!activeThread?.terminalOpen) {
...@@ -568,7 +569,7 @@ export default function ChatView() {...@@ -568,7 +569,7 @@ export default function ChatView() {
568 createNewTerminal,569 createNewTerminal,
569 dispatch,570 dispatch,
570 splitTerminal,571 splitTerminal,
571 terminalKeybindings,572 keybindings,
572 toggleTerminalVisibility,573 toggleTerminalVisibility,
573 ]);574 ]);
574575
...@@ -937,7 +938,7 @@ export default function ChatView() {...@@ -937,7 +938,7 @@ export default function ChatView() {
937 </div>938 </div>
938 <div className="flex items-center gap-3">939 <div className="flex items-center gap-3">
939 {/* Open in editor */}940 {/* Open in editor */}
940 {activeProject && <OpenInPicker />}941 {activeProject && <OpenInPicker keybindings={keybindings} />}
941 {/* Git actions */}942 {/* Git actions */}
942 {activeProject && <GitActionsControl api={api} gitCwd={gitCwd} />}943 {activeProject && <GitActionsControl api={api} gitCwd={gitCwd} />}
943944
...@@ -1519,7 +1520,7 @@ function ReasoningEffortPicker(props: {...@@ -1519,7 +1520,7 @@ function ReasoningEffortPicker(props: {
1519 );1520 );
1520}1521}
15211522
1522function OpenInPicker() {1523function OpenInPicker({ keybindings }: { keybindings: KeybindingsConfig }) {
1523 const [lastEditor, setLastEditor] = useState<EditorId>(() => {1524 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
1524 const stored = localStorage.getItem(LAST_EDITOR_KEY);1525 const stored = localStorage.getItem(LAST_EDITOR_KEY);
1525 return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id;1526 return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id;
...@@ -1560,20 +1561,23 @@ function OpenInPicker() {...@@ -1560,20 +1561,23 @@ function OpenInPicker() {
1560 [api, activeProject, activeThread, lastEditor, setLastEditor],1561 [api, activeProject, activeThread, lastEditor, setLastEditor],
1561 );1562 );
15621563
1563 // Cmd+O / Ctrl+O to open in last-used editor1564 const openFavoriteEditorShortcutLabel = useMemo(
1565 () => shortcutLabelForCommand(keybindings, "editor.openFavorite"),
1566 [keybindings],
1567 );
1568
1564 useEffect(() => {1569 useEffect(() => {
1565 const handler = (e: globalThis.KeyboardEvent) => {1570 const handler = (e: globalThis.KeyboardEvent) => {
1566 if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) {1571 if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
1567 if (api && activeProject) {1572 if (!api || !activeProject) return;
1568 e.preventDefault();1573
1569 const cwd = activeThread?.worktreePath ?? activeProject.cwd;1574 e.preventDefault();
1570 void api.shell.openInEditor(cwd, lastEditor);1575 const cwd = activeThread?.worktreePath ?? activeProject.cwd;
1571 }1576 void api.shell.openInEditor(cwd, lastEditor);
1572 }
1573 };1577 };
1574 window.addEventListener("keydown", handler);1578 window.addEventListener("keydown", handler);
1575 return () => window.removeEventListener("keydown", handler);1579 return () => window.removeEventListener("keydown", handler);
1576 }, [api, activeProject, activeThread, lastEditor]);1580 }, [api, activeProject, activeThread, keybindings, lastEditor]);
15771581
1578 return (1582 return (
1579 <Group aria-label="Subscription actions">1583 <Group aria-label="Subscription actions">
...@@ -1591,10 +1595,8 @@ function OpenInPicker() {...@@ -1591,10 +1595,8 @@ function OpenInPicker() {
1591 <MenuItem key={value} onClick={() => openInEditor(value)}>1595 <MenuItem key={value} onClick={() => openInEditor(value)}>
1592 <Icon aria-hidden="true" className="text-muted-foreground" />1596 <Icon aria-hidden="true" className="text-muted-foreground" />
1593 {label}1597 {label}
1594 {value === lastEditor && (1598 {value === lastEditor && openFavoriteEditorShortcutLabel && (
1595 <MenuShortcut>1599 <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut>
1596 {navigator.platform.includes("Mac") ? "\u2318O" : "Ctrl+O"}
1597 </MenuShortcut>
1598 )}1600 )}
1599 </MenuItem>1601 </MenuItem>
1600 ))}1602 ))}
apps/web/src/components/Sidebar.tsx+13-9
...@@ -1,11 +1,13 @@...@@ -1,11 +1,13 @@
1import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react";1import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react";
2import { useCallback, useEffect, useMemo, useState } from "react";2import { useCallback, useEffect, useMemo, useState } from "react";
3import { useMutation, useQueryClient } from "@tanstack/react-query";3import type { KeybindingsConfig } from "@t3tools/contracts";
4import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4import { isElectron } from "../env";5import { isElectron } from "../env";
5import { useTheme } from "../hooks/useTheme";6import { useTheme } from "../hooks/useTheme";
6import { DEFAULT_MODEL } from "../model-logic";7import { DEFAULT_MODEL } from "../model-logic";
7import { derivePendingApprovals } from "../session-logic";8import { derivePendingApprovals } from "../session-logic";
8import { useStore } from "../store";9import { useStore } from "../store";
10import { isChatNewShortcut } from "../keybindings";
9import {11import {
10 DEFAULT_THREAD_TERMINAL_HEIGHT,12 DEFAULT_THREAD_TERMINAL_HEIGHT,
11 DEFAULT_THREAD_TERMINAL_ID,13 DEFAULT_THREAD_TERMINAL_ID,
...@@ -14,9 +16,12 @@ import {...@@ -14,9 +16,12 @@ import {
14} from "../types";16} from "../types";
15import { useNativeApi } from "../hooks/useNativeApi";17import { useNativeApi } from "../hooks/useNativeApi";
16import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";18import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";
19import { serverConfigQueryOptions } from "../lib/serverReactQuery";
17import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";20import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
1821
19const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;22const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
23const EMPTY_KEYBINDINGS: KeybindingsConfig = [];
24
20function formatRelativeTime(iso: string): string {25function formatRelativeTime(iso: string): string {
21 const diff = Date.now() - new Date(iso).getTime();26 const diff = Date.now() - new Date(iso).getTime();
22 const minutes = Math.floor(diff / 60_000);27 const minutes = Math.floor(diff / 60_000);
...@@ -112,6 +117,11 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null...@@ -112,6 +117,11 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null
112export default function Sidebar() {117export default function Sidebar() {
113 const { state, dispatch } = useStore();118 const { state, dispatch } = useStore();
114 const api = useNativeApi();119 const api = useNativeApi();
120 const keybindingsQuery = useQuery({
121 ...serverConfigQueryOptions(api),
122 select: (config) => config.keybindings,
123 });
124 const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS;
115 const queryClient = useQueryClient();125 const queryClient = useQueryClient();
116 const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ api, queryClient }));126 const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ api, queryClient }));
117 const { theme, setTheme } = useTheme();127 const { theme, setTheme } = useTheme();
...@@ -324,13 +334,7 @@ export default function Sidebar() {...@@ -324,13 +334,7 @@ export default function Sidebar() {
324334
325 useEffect(() => {335 useEffect(() => {
326 const onWindowKeyDown = (event: KeyboardEvent) => {336 const onWindowKeyDown = (event: KeyboardEvent) => {
327 const isNewThreadShortcut =337 if (!isChatNewShortcut(event, keybindings)) return;
328 event.metaKey &&
329 event.shiftKey &&
330 !event.ctrlKey &&
331 !event.altKey &&
332 event.key.toLowerCase() === "o";
333 if (!isNewThreadShortcut) return;
334338
335 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);339 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
336 const projectId = activeThread?.projectId ?? state.projects[0]?.id;340 const projectId = activeThread?.projectId ?? state.projects[0]?.id;
...@@ -344,7 +348,7 @@ export default function Sidebar() {...@@ -344,7 +348,7 @@ export default function Sidebar() {
344 return () => {348 return () => {
345 window.removeEventListener("keydown", onWindowKeyDown);349 window.removeEventListener("keydown", onWindowKeyDown);
346 };350 };
347 }, [handleNewThread, state.activeThreadId, state.projects, state.threads]);351 }, [handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
348352
349 return (353 return (
350 <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card">354 <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card">
apps/web/src/keybindings.test.ts+39
...@@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest";...@@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest";
3import type { KeybindingsConfig } from "@t3tools/contracts";3import type { KeybindingsConfig } from "@t3tools/contracts";
4import {4import {
5 formatShortcutLabel,5 formatShortcutLabel,
6 isChatNewShortcut,
7 isOpenFavoriteEditorShortcut,
6 isTerminalClearShortcut,8 isTerminalClearShortcut,
7 isTerminalNewShortcut,9 isTerminalNewShortcut,
8 isTerminalSplitShortcut,10 isTerminalSplitShortcut,
...@@ -26,6 +28,8 @@ const DEFAULT_BINDINGS: KeybindingsConfig = [...@@ -26,6 +28,8 @@ const DEFAULT_BINDINGS: KeybindingsConfig = [
26 { key: "mod+j", command: "terminal.toggle" },28 { key: "mod+j", command: "terminal.toggle" },
27 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },29 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
28 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },30 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
31 { key: "mod+shift+o", command: "chat.new" },
32 { key: "mod+o", command: "editor.openFavorite" },
29];33];
3034
31describe("isTerminalToggleShortcut", () => {35describe("isTerminalToggleShortcut", () => {
...@@ -112,6 +116,41 @@ describe("shortcutLabelForCommand", () => {...@@ -112,6 +116,41 @@ describe("shortcutLabelForCommand", () => {
112 ];116 ];
113 expect(shortcutLabelForCommand(bindings, "terminal.split", "Linux")).toBe("Ctrl+Shift+\\");117 expect(shortcutLabelForCommand(bindings, "terminal.split", "Linux")).toBe("Ctrl+Shift+\\");
114 });118 });
119
120 it("returns labels for non-terminal commands", () => {
121 expect(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel")).toBe("⇧⌘O");
122 expect(shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux")).toBe(
123 "Ctrl+O",
124 );
125 });
126});
127
128describe("chat/editor shortcuts", () => {
129 it("matches chat.new shortcut", () => {
130 expect(
131 isChatNewShortcut(event({ key: "o", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
132 platform: "MacIntel",
133 }),
134 ).toBe(true);
135 expect(
136 isChatNewShortcut(event({ key: "o", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
137 platform: "Linux",
138 }),
139 ).toBe(true);
140 });
141
142 it("matches editor.openFavorite shortcut", () => {
143 expect(
144 isOpenFavoriteEditorShortcut(event({ key: "o", metaKey: true }), DEFAULT_BINDINGS, {
145 platform: "MacIntel",
146 }),
147 ).toBe(true);
148 expect(
149 isOpenFavoriteEditorShortcut(event({ key: "o", ctrlKey: true }), DEFAULT_BINDINGS, {
150 platform: "Linux",
151 }),
152 ).toBe(true);
153 });
115});154});
116155
117describe("formatShortcutLabel", () => {156describe("formatShortcutLabel", () => {
apps/web/src/keybindings.ts+22-6
...@@ -14,7 +14,7 @@ export interface ShortcutMatchContext {...@@ -14,7 +14,7 @@ export interface ShortcutMatchContext {
14 [key: string]: boolean;14 [key: string]: boolean;
15}15}
1616
17export type ResolvedTerminalKeybindings = KeybindingsConfig;17export type ResolvedKeybindings = KeybindingsConfig;
1818
19interface ParsedShortcut {19interface ParsedShortcut {
20 key: string;20 key: string;
...@@ -301,7 +301,7 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc...@@ -301,7 +301,7 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc
301301
302function matchesCommandShortcut(302function matchesCommandShortcut(
303 event: ShortcutEventLike,303 event: ShortcutEventLike,
304 keybindings: ResolvedTerminalKeybindings,304 keybindings: ResolvedKeybindings,
305 command: KeybindingCommand,305 command: KeybindingCommand,
306 options?: ShortcutMatchOptions,306 options?: ShortcutMatchOptions,
307): boolean {307): boolean {
...@@ -356,7 +356,7 @@ export function formatShortcutLabel(shortcutValue: string, platform = navigator....@@ -356,7 +356,7 @@ export function formatShortcutLabel(shortcutValue: string, platform = navigator.
356}356}
357357
358export function shortcutLabelForCommand(358export function shortcutLabelForCommand(
359 keybindings: ResolvedTerminalKeybindings,359 keybindings: ResolvedKeybindings,
360 command: KeybindingCommand,360 command: KeybindingCommand,
361 platform = navigator.platform,361 platform = navigator.platform,
362): string | null {362): string | null {
...@@ -370,7 +370,7 @@ export function shortcutLabelForCommand(...@@ -370,7 +370,7 @@ export function shortcutLabelForCommand(
370370
371export function isTerminalToggleShortcut(371export function isTerminalToggleShortcut(
372 event: ShortcutEventLike,372 event: ShortcutEventLike,
373 keybindings: ResolvedTerminalKeybindings,373 keybindings: ResolvedKeybindings,
374 options?: ShortcutMatchOptions,374 options?: ShortcutMatchOptions,
375): boolean {375): boolean {
376 return matchesCommandShortcut(event, keybindings, "terminal.toggle", options);376 return matchesCommandShortcut(event, keybindings, "terminal.toggle", options);
...@@ -378,7 +378,7 @@ export function isTerminalToggleShortcut(...@@ -378,7 +378,7 @@ export function isTerminalToggleShortcut(
378378
379export function isTerminalSplitShortcut(379export function isTerminalSplitShortcut(
380 event: ShortcutEventLike,380 event: ShortcutEventLike,
381 keybindings: ResolvedTerminalKeybindings,381 keybindings: ResolvedKeybindings,
382 options?: ShortcutMatchOptions,382 options?: ShortcutMatchOptions,
383): boolean {383): boolean {
384 return matchesCommandShortcut(event, keybindings, "terminal.split", options);384 return matchesCommandShortcut(event, keybindings, "terminal.split", options);
...@@ -386,12 +386,28 @@ export function isTerminalSplitShortcut(...@@ -386,12 +386,28 @@ export function isTerminalSplitShortcut(
386386
387export function isTerminalNewShortcut(387export function isTerminalNewShortcut(
388 event: ShortcutEventLike,388 event: ShortcutEventLike,
389 keybindings: ResolvedTerminalKeybindings,389 keybindings: ResolvedKeybindings,
390 options?: ShortcutMatchOptions,390 options?: ShortcutMatchOptions,
391): boolean {391): boolean {
392 return matchesCommandShortcut(event, keybindings, "terminal.new", options);392 return matchesCommandShortcut(event, keybindings, "terminal.new", options);
393}393}
394394
395export function isChatNewShortcut(
396 event: ShortcutEventLike,
397 keybindings: ResolvedKeybindings,
398 options?: ShortcutMatchOptions,
399): boolean {
400 return matchesCommandShortcut(event, keybindings, "chat.new", options);
401}
402
403export function isOpenFavoriteEditorShortcut(
404 event: ShortcutEventLike,
405 keybindings: ResolvedKeybindings,
406 options?: ShortcutMatchOptions,
407): boolean {
408 return matchesCommandShortcut(event, keybindings, "editor.openFavorite", options);
409}
410
395export function isTerminalClearShortcut(411export function isTerminalClearShortcut(
396 event: ShortcutEventLike,412 event: ShortcutEventLike,
397 platform = navigator.platform,413 platform = navigator.platform,
packages/contracts/src/keybindings.ts+2
...@@ -4,6 +4,8 @@ export const keybindingCommandSchema = z.enum([...@@ -4,6 +4,8 @@ export const keybindingCommandSchema = z.enum([
4 "terminal.toggle",4 "terminal.toggle",
5 "terminal.split",5 "terminal.split",
6 "terminal.new",6 "terminal.new",
7 "chat.new",
8 "editor.openFavorite",
7]);9]);
810
9const keybindingValueSchema = z.string().trim().min(1).max(64);11const keybindingValueSchema = z.string().trim().min(1).max(64);