History

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

Julius Marminge committed 8 months ago

Commit ef4ce2e1 parent 61e9ba48 files · +122 −41Browse files

Unified · Split

8 files
  1. KEYBINDINGS.md +7 −1
  2. apps/server/src/wsServer.test.ts +10 −0
  3. apps/server/src/wsServer.ts +2 −0
  4. apps/web/src/components/ChatView.tsx +27 −25
  5. apps/web/src/components/Sidebar.tsx +13 −9
  6. apps/web/src/keybindings.test.ts +39 −0
  7. apps/web/src/keybindings.ts +22 −6
  8. packages/contracts/src/keybindings.ts +2 −0
KEYBINDINGS.md+7−1
@@ -19,7 +19,9 @@ The file must be a JSON array of rules: · 18 unmodified lines
1919 [
2020 { "key": "mod+j", "command": "terminal.toggle" },
2121 { "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" }
2325 ]
2426 ```
2527
@@ -38,6 +40,8 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged · 12 unmodified lines
3840 - `terminal.toggle`: open/close terminal drawer
3941 - `terminal.split`: split terminal (in focused terminal context by default)
4042 - `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
4246 ## Key Syntax
4347
@@ -85,6 +89,8 @@ Built-in defaults are resolved on the server and sent to the web client via `ser · 41 unmodified lines
8589 - `terminal.toggle`: `mod+j`
8690 - `terminal.split`: `mod+d` when `terminalFocus`
8791 - `terminal.new`: `mod+shift+d` when `terminalFocus`
92+- `chat.new`: `mod+shift+o`
93+- `editor.openFavorite`: `mod+o`
8894
8995 If 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−0
@@ -313,6 +313,8 @@ describe("WebSocket Server", () => { · 312 unmodified lines
313313 { key: "mod+j", command: "terminal.toggle" },
314314 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
315315 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
316+ { key: "mod+shift+o", command: "chat.new" },
317+ { key: "mod+o", command: "editor.openFavorite" },
316318 ],
317319 });
318320 });
@@ -347,6 +349,8 @@ describe("WebSocket Server", () => { · 28 unmodified lines
347349 expect(response.result).toEqual({
348350 cwd: "/my/workspace",
349351 keybindings: [
352+ { key: "mod+shift+o", command: "chat.new" },
353+ { key: "mod+o", command: "editor.openFavorite" },
350354 { key: "cmd+j", command: "terminal.toggle" },
351355 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
352356 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
@@ -386,6 +390,8 @@ describe("WebSocket Server", () => { · 33 unmodified lines
386390 keybindings: [
387391 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
388392 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
393+ { key: "mod+shift+o", command: "chat.new" },
394+ { key: "mod+o", command: "editor.openFavorite" },
389395 { key: "mod+j", command: "terminal.toggle" },
390396 ],
391397 });
@@ -426,6 +432,8 @@ describe("WebSocket Server", () => { · 34 unmodified lines
426432 { key: "mod+j", command: "terminal.toggle" },
427433 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
428434 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
435+ { key: "mod+shift+o", command: "chat.new" },
436+ { key: "mod+o", command: "editor.openFavorite" },
429437 ],
430438 });
431439 expect(
@@ -461,6 +469,8 @@ describe("WebSocket Server", () => { · 29 unmodified lines
461469 { key: "mod+j", command: "terminal.toggle" },
462470 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
463471 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
472+ { key: "mod+shift+o", command: "chat.new" },
473+ { key: "mod+o", command: "editor.openFavorite" },
464474 ],
465475 });
466476 expect(
apps/server/src/wsServer.ts+2−0
@@ -74,6 +74,8 @@ const DEFAULT_KEYBINDINGS: KeybindingsConfig = [ · 73 unmodified lines
7474 { key: "mod+j", command: "terminal.toggle" },
7575 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
7676 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
77+ { key: "mod+shift+o", command: "chat.new" },
78+ { key: "mod+o", command: "editor.openFavorite" },
7779 ];
7880
7981 function mergeWithDefaultKeybindings(custom: KeybindingsConfig): KeybindingsConfig {
apps/web/src/components/ChatView.tsx+27−25
@@ -54,6 +54,7 @@ import type { ChatImageAttachment } from "../types"; · 53 unmodified lines
5454 import BranchToolbar from "./BranchToolbar";
5555 import GitActionsControl from "./GitActionsControl";
5656 import {
57+ isOpenFavoriteEditorShortcut,
5758 isTerminalNewShortcut,
5859 isTerminalSplitShortcut,
5960 isTerminalToggleShortcut,
@@ -263,20 +264,20 @@ export default function ChatView() { · 203 unmodified lines
263264 } as const);
264265 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
265266 const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd));
266− const terminalKeybindingsQuery = useQuery({
267+ const keybindingsQuery = useQuery({
267268 ...serverConfigQueryOptions(api),
268269 select: (config) => config.keybindings,
269270 });
270− const terminalKeybindings = terminalKeybindingsQuery.data ?? EMPTY_KEYBINDINGS;
271+ const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS;
271272 // Default true while loading to avoid toolbar flicker.
272273 const isGitRepo = branchesQuery.data?.isRepo ?? true;
273274 const splitTerminalShortcutLabel = useMemo(
274− () => shortcutLabelForCommand(terminalKeybindings, "terminal.split"),
275− [terminalKeybindings],
275+ () => shortcutLabelForCommand(keybindings, "terminal.split"),
276+ [keybindings],
276277 );
277278 const newTerminalShortcutLabel = useMemo(
278− () => shortcutLabelForCommand(terminalKeybindings, "terminal.new"),
279− [terminalKeybindings],
279+ () => shortcutLabelForCommand(keybindings, "terminal.new"),
280+ [keybindings],
280281 );
281282
282283 const envLocked = Boolean(
@@ -527,14 +528,14 @@ export default function ChatView() { · 244 unmodified lines
527528 terminalOpen: Boolean(activeThread?.terminalOpen),
528529 };
529530
530− if (isTerminalToggleShortcut(event, terminalKeybindings, { context: shortcutContext })) {
531+ if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) {
531532 event.preventDefault();
532533 event.stopPropagation();
533534 toggleTerminalVisibility();
534535 return;
535536 }
536537
537− if (isTerminalSplitShortcut(event, terminalKeybindings, { context: shortcutContext })) {
538+ if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) {
538539 event.preventDefault();
539540 event.stopPropagation();
540541 if (!activeThread?.terminalOpen) {
@@ -548,7 +549,7 @@ export default function ChatView() { · 7 unmodified lines
548549 return;
549550 }
550551
551− if (!isTerminalNewShortcut(event, terminalKeybindings, { context: shortcutContext })) return;
552+ if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return;
552553 event.preventDefault();
553554 event.stopPropagation();
554555 if (!activeThread?.terminalOpen) {
@@ -568,7 +569,7 @@ export default function ChatView() { · 13 unmodified lines
568569 createNewTerminal,
569570 dispatch,
570571 splitTerminal,
571− terminalKeybindings,
572+ keybindings,
572573 toggleTerminalVisibility,
573574 ]);
574575
@@ -937,7 +938,7 @@ export default function ChatView() { · 362 unmodified lines
937938 </div>
938939 <div className="flex items-center gap-3">
939940 {/* Open in editor */}
940− {activeProject && <OpenInPicker />}
941+ {activeProject && <OpenInPicker keybindings={keybindings} />}
941942 {/* Git actions */}
942943 {activeProject && <GitActionsControl api={api} gitCwd={gitCwd} />}
943944
@@ -1519,7 +1520,7 @@ function ReasoningEffortPicker(props: { · 575 unmodified lines
15191520 );
15201521 }
15211522
1522−function OpenInPicker() {
1523+function OpenInPicker({ keybindings }: { keybindings: KeybindingsConfig }) {
15231524 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
15241525 const stored = localStorage.getItem(LAST_EDITOR_KEY);
15251526 return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id;
@@ -1560,20 +1561,23 @@ function OpenInPicker() { · 34 unmodified lines
15601561 [api, activeProject, activeThread, lastEditor, setLastEditor],
15611562 );
15621563
1563− // Cmd+O / Ctrl+O to open in last-used editor
1564+ const openFavoriteEditorShortcutLabel = useMemo(
1565+ () => shortcutLabelForCommand(keybindings, "editor.openFavorite"),
1566+ [keybindings],
1567+ );
1568+
15641569 useEffect(() => {
15651570 const handler = (e: globalThis.KeyboardEvent) => {
1566− if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
1567− if (api && activeProject) {
1568− e.preventDefault();
1569− const cwd = activeThread?.worktreePath ?? activeProject.cwd;
1570− void api.shell.openInEditor(cwd, lastEditor);
1571− }
1572− }
1571+ if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
1572+ if (!api || !activeProject) return;
1573+
1574+ e.preventDefault();
1575+ const cwd = activeThread?.worktreePath ?? activeProject.cwd;
1576+ void api.shell.openInEditor(cwd, lastEditor);
15731577 };
15741578 window.addEventListener("keydown", handler);
15751579 return () => window.removeEventListener("keydown", handler);
1576− }, [api, activeProject, activeThread, lastEditor]);
1580+ }, [api, activeProject, activeThread, keybindings, lastEditor]);
15771581
15781582 return (
15791583 <Group aria-label="Subscription actions">
@@ -1591,10 +1595,8 @@ function OpenInPicker() { · 11 unmodified lines
15911595 <MenuItem key={value} onClick={() => openInEditor(value)}>
15921596 <Icon aria-hidden="true" className="text-muted-foreground" />
15931597 {label}
1594− {value === lastEditor && (
1595− <MenuShortcut>
1596− {navigator.platform.includes("Mac") ? "\u2318O" : "Ctrl+O"}
1597− </MenuShortcut>
1598+ {value === lastEditor && openFavoriteEditorShortcutLabel && (
1599+ <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut>
15981600 )}
15991601 </MenuItem>
16001602 ))}
apps/web/src/components/Sidebar.tsx+13−9
@@ -1,11 +1,13 @@
11 import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react";
22 import { useCallback, useEffect, useMemo, useState } from "react";
3−import { useMutation, useQueryClient } from "@tanstack/react-query";
3+import type { KeybindingsConfig } from "@t3tools/contracts";
4+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
45 import { isElectron } from "../env";
56 import { useTheme } from "../hooks/useTheme";
67 import { DEFAULT_MODEL } from "../model-logic";
78 import { derivePendingApprovals } from "../session-logic";
89 import { useStore } from "../store";
10+import { isChatNewShortcut } from "../keybindings";
911 import {
1012 DEFAULT_THREAD_TERMINAL_HEIGHT,
1113 DEFAULT_THREAD_TERMINAL_ID,
@@ -14,9 +16,12 @@ import { · 2 unmodified lines
1416 } from "../types";
1517 import { useNativeApi } from "../hooks/useNativeApi";
1618 import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";
19+import { serverConfigQueryOptions } from "../lib/serverReactQuery";
1720 import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
1821
1922 const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
23+const EMPTY_KEYBINDINGS: KeybindingsConfig = [];
24+
2025 function formatRelativeTime(iso: string): string {
2126 const diff = Date.now() - new Date(iso).getTime();
2227 const minutes = Math.floor(diff / 60_000);
@@ -112,6 +117,11 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null · 89 unmodified lines
112117 export default function Sidebar() {
113118 const { state, dispatch } = useStore();
114119 const api = useNativeApi();
120+ const keybindingsQuery = useQuery({
121+ ...serverConfigQueryOptions(api),
122+ select: (config) => config.keybindings,
123+ });
124+ const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS;
115125 const queryClient = useQueryClient();
116126 const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ api, queryClient }));
117127 const { theme, setTheme } = useTheme();
@@ -324,13 +334,7 @@ export default function Sidebar() { · 206 unmodified lines
324334
325335 useEffect(() => {
326336 const onWindowKeyDown = (event: KeyboardEvent) => {
327− const isNewThreadShortcut =
328− event.metaKey &&
329− event.shiftKey &&
330− !event.ctrlKey &&
331− !event.altKey &&
332− event.key.toLowerCase() === "o";
333− if (!isNewThreadShortcut) return;
337+ if (!isChatNewShortcut(event, keybindings)) return;
334338
335339 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
336340 const projectId = activeThread?.projectId ?? state.projects[0]?.id;
@@ -344,7 +348,7 @@ export default function Sidebar() { · 7 unmodified lines
344348 return () => {
345349 window.removeEventListener("keydown", onWindowKeyDown);
346350 };
347− }, [handleNewThread, state.activeThreadId, state.projects, state.threads]);
351+ }, [handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
348352
349353 return (
350354 <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−0
@@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest"; · 2 unmodified lines
33 import type { KeybindingsConfig } from "@t3tools/contracts";
44 import {
55 formatShortcutLabel,
6+ isChatNewShortcut,
7+ isOpenFavoriteEditorShortcut,
68 isTerminalClearShortcut,
79 isTerminalNewShortcut,
810 isTerminalSplitShortcut,
@@ -26,6 +28,8 @@ const DEFAULT_BINDINGS: KeybindingsConfig = [ · 17 unmodified lines
2628 { key: "mod+j", command: "terminal.toggle" },
2729 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
2830 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
31+ { key: "mod+shift+o", command: "chat.new" },
32+ { key: "mod+o", command: "editor.openFavorite" },
2933 ];
3034
3135 describe("isTerminalToggleShortcut", () => {
@@ -112,6 +116,41 @@ describe("shortcutLabelForCommand", () => { · 80 unmodified lines
112116 ];
113117 expect(shortcutLabelForCommand(bindings, "terminal.split", "Linux")).toBe("Ctrl+Shift+\\");
114118 });
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+
128+describe("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+ });
115154 });
116155
117156 describe("formatShortcutLabel", () => {
apps/web/src/keybindings.ts+22−6
@@ -14,7 +14,7 @@ export interface ShortcutMatchContext { · 13 unmodified lines
1414 [key: string]: boolean;
1515 }
1616
17−export type ResolvedTerminalKeybindings = KeybindingsConfig;
17+export type ResolvedKeybindings = KeybindingsConfig;
1818
1919 interface ParsedShortcut {
2020 key: string;
@@ -301,7 +301,7 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc · 280 unmodified lines
301301
302302 function matchesCommandShortcut(
303303 event: ShortcutEventLike,
304− keybindings: ResolvedTerminalKeybindings,
304+ keybindings: ResolvedKeybindings,
305305 command: KeybindingCommand,
306306 options?: ShortcutMatchOptions,
307307 ): boolean {
@@ -356,7 +356,7 @@ export function formatShortcutLabel(shortcutValue: string, platform = navigator. · 48 unmodified lines
356356 }
357357
358358 export function shortcutLabelForCommand(
359− keybindings: ResolvedTerminalKeybindings,
359+ keybindings: ResolvedKeybindings,
360360 command: KeybindingCommand,
361361 platform = navigator.platform,
362362 ): string | null {
@@ -370,7 +370,7 @@ export function shortcutLabelForCommand( · 7 unmodified lines
370370
371371 export function isTerminalToggleShortcut(
372372 event: ShortcutEventLike,
373− keybindings: ResolvedTerminalKeybindings,
373+ keybindings: ResolvedKeybindings,
374374 options?: ShortcutMatchOptions,
375375 ): boolean {
376376 return matchesCommandShortcut(event, keybindings, "terminal.toggle", options);
@@ -378,7 +378,7 @@ export function isTerminalToggleShortcut( · 1 unmodified line
378378
379379 export function isTerminalSplitShortcut(
380380 event: ShortcutEventLike,
381− keybindings: ResolvedTerminalKeybindings,
381+ keybindings: ResolvedKeybindings,
382382 options?: ShortcutMatchOptions,
383383 ): boolean {
384384 return matchesCommandShortcut(event, keybindings, "terminal.split", options);
@@ -386,12 +386,28 @@ export function isTerminalSplitShortcut( · 1 unmodified line
386386
387387 export function isTerminalNewShortcut(
388388 event: ShortcutEventLike,
389− keybindings: ResolvedTerminalKeybindings,
389+ keybindings: ResolvedKeybindings,
390390 options?: ShortcutMatchOptions,
391391 ): boolean {
392392 return matchesCommandShortcut(event, keybindings, "terminal.new", options);
393393 }
394394
395+export function isChatNewShortcut(
396+ event: ShortcutEventLike,
397+ keybindings: ResolvedKeybindings,
398+ options?: ShortcutMatchOptions,
399+): boolean {
400+ return matchesCommandShortcut(event, keybindings, "chat.new", options);
401+}
402+
403+export function isOpenFavoriteEditorShortcut(
404+ event: ShortcutEventLike,
405+ keybindings: ResolvedKeybindings,
406+ options?: ShortcutMatchOptions,
407+): boolean {
408+ return matchesCommandShortcut(event, keybindings, "editor.openFavorite", options);
409+}
410+
395411 export function isTerminalClearShortcut(
396412 event: ShortcutEventLike,
397413 platform = navigator.platform,
packages/contracts/src/keybindings.ts+2−0
@@ -4,6 +4,8 @@ export const keybindingCommandSchema = z.enum([ · 3 unmodified lines
44 "terminal.toggle",
55 "terminal.split",
66 "terminal.new",
7+ "chat.new",
8+ "editor.openFavorite",
79 ]);
810
911 const keybindingValueSchema = z.string().trim().min(1).max(64);