| author | |
| committer | |
| log | ef4ce2e1ec3a6e08ccc392b802462519cbb706bc |
| tree | 5ee31f7b71e8c315d30b1c0fdae7b25f0c28d9bc |
| parent | 61e9ba4b19fc4ed58bb59e23a2aaa995ee3197bd |
- 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 tests8 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 | ``` |
| 25 | 27 | ||
| ... | @@ -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 drawer | 40 | - `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 | ||
| 41 | 45 | ||
| 42 | ## Key Syntax | 46 | ## Key Syntax |
| 43 | 47 | ||
| ... | @@ -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` | ||
| 88 | 94 | ||
| 89 | If you define any rules for a command, the default rule for that command is removed on the server. | 95 | If you define any rules for a command, the default rule for that command is removed on the server. |
| 90 | 96 |
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 | ]; |
| 78 | 80 | ||
| 79 | function mergeWithDefaultKeybindings(custom: KeybindingsConfig): KeybindingsConfig { | 81 | function 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"; |
| 54 | import BranchToolbar from "./BranchToolbar"; | 54 | import BranchToolbar from "./BranchToolbar"; |
| 55 | import GitActionsControl from "./GitActionsControl"; | 55 | import GitActionsControl from "./GitActionsControl"; |
| 56 | import { | 56 | import { |
| 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 | ); |
| 281 | 282 | ||
| 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 | }; |
| 529 | 530 | ||
| 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 | } |
| 536 | 537 | ||
| 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 | } |
| 550 | 551 | ||
| 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 | ]); |
| 574 | 575 | ||
| ... | @@ -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} />} |
| 943 | 944 | ||
| ... | @@ -1519,7 +1520,7 @@ function ReasoningEffortPicker(props: { | ... | @@ -1519,7 +1520,7 @@ function ReasoningEffortPicker(props: { |
| 1519 | ); | 1520 | ); |
| 1520 | } | 1521 | } |
| 1521 | 1522 | ||
| 1522 | function OpenInPicker() { | 1523 | function 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 | ); |
| 1562 | 1563 | ||
| 1563 | // Cmd+O / Ctrl+O to open in last-used editor | 1564 | 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]); |
| 1577 | 1581 | ||
| 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 @@ |
| 1 | import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; | 1 | import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; |
| 2 | import { useCallback, useEffect, useMemo, useState } from "react"; | 2 | 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"; | ||
| 4 | import { isElectron } from "../env"; | 5 | import { isElectron } from "../env"; |
| 5 | import { useTheme } from "../hooks/useTheme"; | 6 | import { useTheme } from "../hooks/useTheme"; |
| 6 | import { DEFAULT_MODEL } from "../model-logic"; | 7 | import { DEFAULT_MODEL } from "../model-logic"; |
| 7 | import { derivePendingApprovals } from "../session-logic"; | 8 | import { derivePendingApprovals } from "../session-logic"; |
| 8 | import { useStore } from "../store"; | 9 | import { useStore } from "../store"; |
| 10 | import { isChatNewShortcut } from "../keybindings"; | ||
| 9 | import { | 11 | import { |
| 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"; |
| 15 | import { useNativeApi } from "../hooks/useNativeApi"; | 17 | import { useNativeApi } from "../hooks/useNativeApi"; |
| 16 | import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; | 18 | import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; |
| 19 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | ||
| 17 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; | 20 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 18 | 21 | ||
| 19 | const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; | 22 | const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; |
| 23 | const EMPTY_KEYBINDINGS: KeybindingsConfig = []; | ||
| 24 | |||
| 20 | function formatRelativeTime(iso: string): string { | 25 | function 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 |
| 112 | export default function Sidebar() { | 117 | export 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() { |
| 324 | 334 | ||
| 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; | ||
| 334 | 338 | ||
| 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]); |
| 348 | 352 | ||
| 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"; |
| 3 | import type { KeybindingsConfig } from "@t3tools/contracts"; | 3 | import type { KeybindingsConfig } from "@t3tools/contracts"; |
| 4 | import { | 4 | import { |
| 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 | ]; |
| 30 | 34 | ||
| 31 | describe("isTerminalToggleShortcut", () => { | 35 | describe("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 | |||
| 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 | }); | ||
| 115 | }); | 154 | }); |
| 116 | 155 | ||
| 117 | describe("formatShortcutLabel", () => { | 156 | describe("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 | } |
| 16 | 16 | ||
| 17 | export type ResolvedTerminalKeybindings = KeybindingsConfig; | 17 | export type ResolvedKeybindings = KeybindingsConfig; |
| 18 | 18 | ||
| 19 | interface ParsedShortcut { | 19 | interface 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 |
| 301 | 301 | ||
| 302 | function matchesCommandShortcut( | 302 | function 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 | } |
| 357 | 357 | ||
| 358 | export function shortcutLabelForCommand( | 358 | export 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( |
| 370 | 370 | ||
| 371 | export function isTerminalToggleShortcut( | 371 | export 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( |
| 378 | 378 | ||
| 379 | export function isTerminalSplitShortcut( | 379 | export 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( |
| 386 | 386 | ||
| 387 | export function isTerminalNewShortcut( | 387 | export 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 | } |
| 394 | 394 | ||
| 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 | |||
| 395 | export function isTerminalClearShortcut( | 411 | export 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 | ]); |
| 8 | 10 | ||
| 9 | const keybindingValueSchema = z.string().trim().min(1).max(64); | 11 | const keybindingValueSchema = z.string().trim().min(1).max(64); |