diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index c37ad2cf0a929c19e768e43fd608fc8005cf002b..8cae18ebc8f4cb37e931bb6e1e880bcacf40ff09 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -19,7 +19,9 @@ The file must be a JSON array of rules: [ { "key": "mod+j", "command": "terminal.toggle" }, { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, - { "key": "mod+shift+d", "command": "terminal.new", "when": "terminalFocus" } + { "key": "mod+shift+d", "command": "terminal.new", "when": "terminalFocus" }, + { "key": "mod+shift+o", "command": "chat.new" }, + { "key": "mod+o", "command": "editor.openFavorite" } ] ``` @@ -38,6 +40,8 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `terminal.toggle`: open/close terminal drawer - `terminal.split`: split terminal (in focused terminal context by default) - `terminal.new`: create new terminal (in focused terminal context by default) +- `chat.new`: create a new chat thread for the active project +- `editor.openFavorite`: open current project/worktree in the last-used editor ## Key Syntax @@ -85,6 +89,8 @@ Built-in defaults are resolved on the server and sent to the web client via `ser - `terminal.toggle`: `mod+j` - `terminal.split`: `mod+d` when `terminalFocus` - `terminal.new`: `mod+shift+d` when `terminalFocus` +- `chat.new`: `mod+shift+o` +- `editor.openFavorite`: `mod+o` If you define any rules for a command, the default rule for that command is removed on the server. diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 0b4726c51b59bdb6472403d4abcac3ce1ecb0bfa..a769193fb7f6f6eaae8d912badde10e88a5224fe 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -313,6 +313,8 @@ describe("WebSocket Server", () => { { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, + { key: "mod+shift+o", command: "chat.new" }, + { key: "mod+o", command: "editor.openFavorite" }, ], }); }); @@ -347,6 +349,8 @@ describe("WebSocket Server", () => { expect(response.result).toEqual({ cwd: "/my/workspace", keybindings: [ + { key: "mod+shift+o", command: "chat.new" }, + { key: "mod+o", command: "editor.openFavorite" }, { key: "cmd+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, @@ -386,6 +390,8 @@ describe("WebSocket Server", () => { keybindings: [ { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, + { key: "mod+shift+o", command: "chat.new" }, + { key: "mod+o", command: "editor.openFavorite" }, { key: "mod+j", command: "terminal.toggle" }, ], }); @@ -426,6 +432,8 @@ describe("WebSocket Server", () => { { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, + { key: "mod+shift+o", command: "chat.new" }, + { key: "mod+o", command: "editor.openFavorite" }, ], }); expect( @@ -461,6 +469,8 @@ describe("WebSocket Server", () => { { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, + { key: "mod+shift+o", command: "chat.new" }, + { key: "mod+o", command: "editor.openFavorite" }, ], }); expect( diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 640b3af76c5b5fda0e5c2725946eb94e04f33bff..b22b906ae6400c730d85defaa7b4309552c336f7 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -74,6 +74,8 @@ const DEFAULT_KEYBINDINGS: KeybindingsConfig = [ { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, + { key: "mod+shift+o", command: "chat.new" }, + { key: "mod+o", command: "editor.openFavorite" }, ]; function mergeWithDefaultKeybindings(custom: KeybindingsConfig): KeybindingsConfig { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a11d28930946f491b34e6e6ab404189a5460bcaa..61b60f60508a22376f780800caa22d8d7901fde2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -54,6 +54,7 @@ import type { ChatImageAttachment } from "../types"; import BranchToolbar from "./BranchToolbar"; import GitActionsControl from "./GitActionsControl"; import { + isOpenFavoriteEditorShortcut, isTerminalNewShortcut, isTerminalSplitShortcut, isTerminalToggleShortcut, @@ -263,20 +264,20 @@ export default function ChatView() { } as const); const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); - const terminalKeybindingsQuery = useQuery({ + const keybindingsQuery = useQuery({ ...serverConfigQueryOptions(api), select: (config) => config.keybindings, }); - const terminalKeybindings = terminalKeybindingsQuery.data ?? EMPTY_KEYBINDINGS; + const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; // Default true while loading to avoid toolbar flicker. const isGitRepo = branchesQuery.data?.isRepo ?? true; const splitTerminalShortcutLabel = useMemo( - () => shortcutLabelForCommand(terminalKeybindings, "terminal.split"), - [terminalKeybindings], + () => shortcutLabelForCommand(keybindings, "terminal.split"), + [keybindings], ); const newTerminalShortcutLabel = useMemo( - () => shortcutLabelForCommand(terminalKeybindings, "terminal.new"), - [terminalKeybindings], + () => shortcutLabelForCommand(keybindings, "terminal.new"), + [keybindings], ); const envLocked = Boolean( @@ -527,14 +528,14 @@ export default function ChatView() { terminalOpen: Boolean(activeThread?.terminalOpen), }; - if (isTerminalToggleShortcut(event, terminalKeybindings, { context: shortcutContext })) { + if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) { event.preventDefault(); event.stopPropagation(); toggleTerminalVisibility(); return; } - if (isTerminalSplitShortcut(event, terminalKeybindings, { context: shortcutContext })) { + if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) { event.preventDefault(); event.stopPropagation(); if (!activeThread?.terminalOpen) { @@ -548,7 +549,7 @@ export default function ChatView() { return; } - if (!isTerminalNewShortcut(event, terminalKeybindings, { context: shortcutContext })) return; + if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return; event.preventDefault(); event.stopPropagation(); if (!activeThread?.terminalOpen) { @@ -568,7 +569,7 @@ export default function ChatView() { createNewTerminal, dispatch, splitTerminal, - terminalKeybindings, + keybindings, toggleTerminalVisibility, ]); @@ -937,7 +938,7 @@ export default function ChatView() {
{/* Open in editor */} - {activeProject && } + {activeProject && } {/* Git actions */} {activeProject && } @@ -1519,7 +1520,7 @@ function ReasoningEffortPicker(props: { ); } -function OpenInPicker() { +function OpenInPicker({ keybindings }: { keybindings: KeybindingsConfig }) { const [lastEditor, setLastEditor] = useState(() => { const stored = localStorage.getItem(LAST_EDITOR_KEY); return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; @@ -1560,20 +1561,23 @@ function OpenInPicker() { [api, activeProject, activeThread, lastEditor, setLastEditor], ); - // Cmd+O / Ctrl+O to open in last-used editor + const openFavoriteEditorShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "editor.openFavorite"), + [keybindings], + ); + useEffect(() => { const handler = (e: globalThis.KeyboardEvent) => { - if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) { - if (api && activeProject) { - e.preventDefault(); - const cwd = activeThread?.worktreePath ?? activeProject.cwd; - void api.shell.openInEditor(cwd, lastEditor); - } - } + if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; + if (!api || !activeProject) return; + + e.preventDefault(); + const cwd = activeThread?.worktreePath ?? activeProject.cwd; + void api.shell.openInEditor(cwd, lastEditor); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [api, activeProject, activeThread, lastEditor]); + }, [api, activeProject, activeThread, keybindings, lastEditor]); return ( @@ -1591,10 +1595,8 @@ function OpenInPicker() { openInEditor(value)}> ))} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e851468280cf090ff252b2633d664425556fd190..cb2c059ad83cba3f7e8c1d24676d2b08f2d33456 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,11 +1,13 @@ import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { KeybindingsConfig } from "@t3tools/contracts"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; import { DEFAULT_MODEL } from "../model-logic"; import { derivePendingApprovals } from "../session-logic"; import { useStore } from "../store"; +import { isChatNewShortcut } from "../keybindings"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, @@ -14,9 +16,12 @@ import { } from "../types"; import { useNativeApi } from "../hooks/useNativeApi"; import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; +import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; +const EMPTY_KEYBINDINGS: KeybindingsConfig = []; + function formatRelativeTime(iso: string): string { const diff = Date.now() - new Date(iso).getTime(); const minutes = Math.floor(diff / 60_000); @@ -112,6 +117,11 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null export default function Sidebar() { const { state, dispatch } = useStore(); const api = useNativeApi(); + const keybindingsQuery = useQuery({ + ...serverConfigQueryOptions(api), + select: (config) => config.keybindings, + }); + const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; const queryClient = useQueryClient(); const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ api, queryClient })); const { theme, setTheme } = useTheme(); @@ -324,13 +334,7 @@ export default function Sidebar() { useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { - const isNewThreadShortcut = - event.metaKey && - event.shiftKey && - !event.ctrlKey && - !event.altKey && - event.key.toLowerCase() === "o"; - if (!isNewThreadShortcut) return; + if (!isChatNewShortcut(event, keybindings)) return; const activeThread = state.threads.find((t) => t.id === state.activeThreadId); const projectId = activeThread?.projectId ?? state.projects[0]?.id; @@ -344,7 +348,7 @@ export default function Sidebar() { return () => { window.removeEventListener("keydown", onWindowKeyDown); }; - }, [handleNewThread, state.activeThreadId, state.projects, state.threads]); + }, [handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]); return (