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:
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
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
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
8995If 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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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
......@@ -74,6 +74,8 @@ const DEFAULT_KEYBINDINGS: KeybindingsConfig = [
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
7981function mergeWithDefaultKeybindings(custom: KeybindingsConfig): KeybindingsConfig {
apps/web/src/components/ChatView.tsx+27-25
......@@ -54,6 +54,7 @@ import type { ChatImageAttachment } from "../types";
5454import BranchToolbar from "./BranchToolbar";
5555import GitActionsControl from "./GitActionsControl";
5656import {
57 isOpenFavoriteEditorShortcut,
5758 isTerminalNewShortcut,
5859 isTerminalSplitShortcut,
5960 isTerminalToggleShortcut,
......@@ -263,20 +264,20 @@ export default function ChatView() {
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() {
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() {
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() {
568569 createNewTerminal,
569570 dispatch,
570571 splitTerminal,
571 terminalKeybindings,
572 keybindings,
572573 toggleTerminalVisibility,
573574 ]);
574575
......@@ -937,7 +938,7 @@ export default function ChatView() {
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: {
15191520 );
15201521}
15211522
1522function OpenInPicker() {
1523function 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() {
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() {
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 @@
11import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react";
22import { 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";
45import { isElectron } from "../env";
56import { useTheme } from "../hooks/useTheme";
67import { DEFAULT_MODEL } from "../model-logic";
78import { derivePendingApprovals } from "../session-logic";
89import { useStore } from "../store";
10import { isChatNewShortcut } from "../keybindings";
911import {
1012 DEFAULT_THREAD_TERMINAL_HEIGHT,
1113 DEFAULT_THREAD_TERMINAL_ID,
......@@ -14,9 +16,12 @@ import {
1416} from "../types";
1517import { useNativeApi } from "../hooks/useNativeApi";
1618import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";
19import { serverConfigQueryOptions } from "../lib/serverReactQuery";
1720import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
1821
1922const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
23const EMPTY_KEYBINDINGS: KeybindingsConfig = [];
24
2025function 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
112117export 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() {
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() {
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
......@@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest";
33import type { KeybindingsConfig } from "@t3tools/contracts";
44import {
55 formatShortcutLabel,
6 isChatNewShortcut,
7 isOpenFavoriteEditorShortcut,
68 isTerminalClearShortcut,
79 isTerminalNewShortcut,
810 isTerminalSplitShortcut,
......@@ -26,6 +28,8 @@ const DEFAULT_BINDINGS: KeybindingsConfig = [
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
3135describe("isTerminalToggleShortcut", () => {
......@@ -112,6 +116,41 @@ describe("shortcutLabelForCommand", () => {
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
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 });
115154});
116155
117156describe("formatShortcutLabel", () => {
apps/web/src/keybindings.ts+22-6
......@@ -14,7 +14,7 @@ export interface ShortcutMatchContext {
1414 [key: string]: boolean;
1515}
1616
17export type ResolvedTerminalKeybindings = KeybindingsConfig;
17export type ResolvedKeybindings = KeybindingsConfig;
1818
1919interface ParsedShortcut {
2020 key: string;
......@@ -301,7 +301,7 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc
301301
302302function 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.
356356}
357357
358358export 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(
370370
371371export 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(
378378
379379export 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(
386386
387387export 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
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
395411export function isTerminalClearShortcut(
396412 event: ShortcutEventLike,
397413 platform = navigator.platform,
packages/contracts/src/keybindings.ts+2
......@@ -4,6 +4,8 @@ export const keybindingCommandSchema = z.enum([
44 "terminal.toggle",
55 "terminal.split",
66 "terminal.new",
7 "chat.new",
8 "editor.openFavorite",
79]);
810
911const keybindingValueSchema = z.string().trim().min(1).max(64);