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() {
))}
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 (