From d08233a3288396ad56559a3309e62a7873f69af2 Mon Sep 17 00:00:00 2001 From: Matyas Kumzak Date: Sat, 14 Mar 2026 10:18:57 +0100 Subject: [PATCH] feat: keybind menu within settings and chat header, and move keybinds stuff to shared space --- apps/server/src/keybindings.ts | 13 +- apps/web/src/appSettings.test.ts | 7 + apps/web/src/appSettings.ts | 4 + .../src/components/ProjectScriptsControl.tsx | 65 +--- apps/web/src/components/chat/ChatHeader.tsx | 7 + .../components/chat/KeybindingsControl.tsx | 328 ++++++++++++++++++ apps/web/src/keybindings.test.ts | 28 +- apps/web/src/keybindings.ts | 188 ++++++++++ apps/web/src/lib/projectScriptKeybindings.ts | 28 +- apps/web/src/routes/_chat.settings.tsx | 55 ++- packages/shared/package.json | 4 + packages/shared/src/keybindings.ts | 13 + 12 files changed, 641 insertions(+), 99 deletions(-) create mode 100644 apps/web/src/components/chat/KeybindingsControl.tsx create mode 100644 packages/shared/src/keybindings.ts diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index bf5846782..a17b1f02d 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -17,6 +17,7 @@ import { ResolvedKeybindingsConfig, type ServerConfigIssue, } from "@t3tools/contracts"; +import { DEFAULT_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { Mutable } from "effect/Types"; import { Array, @@ -64,17 +65,7 @@ type WhenToken = | { type: "lparen" } | { type: "rparen" }; -export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ - { key: "mod+j", command: "terminal.toggle" }, - { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, - { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, - { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, - { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, - { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, - { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, - { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, - { key: "mod+o", command: "editor.openFavorite" }, -]; +export { DEFAULT_KEYBINDINGS }; function normalizeKeyToken(token: string): string { if (token === "space") return " "; diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 326bceaac..d51f12e37 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + DEFAULT_SHOW_HEADER_KEYBINDINGS_BUTTON, DEFAULT_TIMESTAMP_FORMAT, getAppModelOptions, normalizeCustomModelSlugs, @@ -64,3 +65,9 @@ describe("timestamp format defaults", () => { expect(DEFAULT_TIMESTAMP_FORMAT).toBe("locale"); }); }); + +describe("header keybindings button defaults", () => { + it("shows the chat header keybindings button by default", () => { + expect(DEFAULT_SHOW_HEADER_KEYBINDINGS_BUTTON).toBe(true); + }); +}); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 18e76d2f9..d8eb968ce 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -10,6 +10,7 @@ export const MAX_CUSTOM_MODEL_LENGTH = 256; export const TIMESTAMP_FORMAT_OPTIONS = ["locale", "12-hour", "24-hour"] as const; export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number]; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; +export const DEFAULT_SHOW_HEADER_KEYBINDINGS_BUTTON = true; const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), }; @@ -28,6 +29,9 @@ const AppSettingsSchema = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe( Schema.withConstructorDefault(() => Option.some(false)), ), + showHeaderKeybindingsButton: Schema.Boolean.pipe( + Schema.withConstructorDefault(() => Option.some(DEFAULT_SHOW_HEADER_KEYBINDINGS_BUTTON)), + ), timestampFormat: Schema.Literals(["locale", "12-hour", "24-hour"]).pipe( Schema.withConstructorDefault(() => Option.some(DEFAULT_TIMESTAMP_FORMAT)), ), diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 4d6c5eef7..453e35ba4 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -16,17 +16,17 @@ import { } from "lucide-react"; import React, { type FormEvent, type KeyboardEvent, useCallback, useMemo, useState } from "react"; -import { - keybindingValueForCommand, - decodeProjectScriptKeybindingRule, -} from "~/lib/projectScriptKeybindings"; +import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { commandForProjectScript, nextProjectScriptId, primaryProjectScript, } from "~/projectScripts"; -import { shortcutLabelForCommand } from "~/keybindings"; -import { isMacPlatform } from "~/lib/utils"; +import { + keybindingValueForCommand, + keybindingValueFromEvent, + shortcutLabelForCommand, +} from "~/keybindings"; import { AlertDialog, AlertDialogClose, @@ -96,57 +96,6 @@ interface ProjectScriptsControlProps { onDeleteScript: (scriptId: string) => Promise | void; } -function normalizeShortcutKeyToken(key: string): string | null { - const normalized = key.toLowerCase(); - if ( - normalized === "meta" || - normalized === "control" || - normalized === "ctrl" || - normalized === "shift" || - normalized === "alt" || - normalized === "option" - ) { - return null; - } - if (normalized === " ") return "space"; - if (normalized === "escape") return "esc"; - if (normalized === "arrowup") return "arrowup"; - if (normalized === "arrowdown") return "arrowdown"; - if (normalized === "arrowleft") return "arrowleft"; - if (normalized === "arrowright") return "arrowright"; - if (normalized.length === 1) return normalized; - if (normalized.startsWith("f") && normalized.length <= 3) return normalized; - if (normalized === "enter" || normalized === "tab" || normalized === "backspace") { - return normalized; - } - if (normalized === "delete" || normalized === "home" || normalized === "end") { - return normalized; - } - if (normalized === "pageup" || normalized === "pagedown") return normalized; - return null; -} - -function keybindingFromEvent(event: KeyboardEvent): string | null { - const keyToken = normalizeShortcutKeyToken(event.key); - if (!keyToken) return null; - - const parts: string[] = []; - if (isMacPlatform(navigator.platform)) { - if (event.metaKey) parts.push("mod"); - if (event.ctrlKey) parts.push("ctrl"); - } else { - if (event.ctrlKey) parts.push("mod"); - if (event.metaKey) parts.push("meta"); - } - if (event.altKey) parts.push("alt"); - if (event.shiftKey) parts.push("shift"); - if (parts.length === 0) { - return null; - } - parts.push(keyToken); - return parts.join("+"); -} - export default function ProjectScriptsControl({ scripts, keybindings, @@ -186,7 +135,7 @@ export default function ProjectScriptsControl({ setKeybinding(""); return; } - const next = keybindingFromEvent(event); + const next = keybindingValueFromEvent(event, navigator.platform); if (!next) return; setKeybinding(next); }; diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index ea7f911be..877e9db1e 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -7,11 +7,13 @@ import { import { memo } from "react"; import GitActionsControl from "../GitActionsControl"; import { DiffIcon } from "lucide-react"; +import { useAppSettings } from "~/appSettings"; import { Badge } from "../ui/badge"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; import { Toggle } from "../ui/toggle"; import { SidebarTrigger } from "../ui/sidebar"; +import { KeybindingsControl } from "./KeybindingsControl"; import { OpenInPicker } from "./OpenInPicker"; interface ChatHeaderProps { @@ -53,6 +55,8 @@ export const ChatHeader = memo(function ChatHeader({ onDeleteProjectScript, onToggleDiff, }: ChatHeaderProps) { + const { settings } = useAppSettings(); + return (
@@ -86,6 +90,9 @@ export const ChatHeader = memo(function ChatHeader({ onDeleteScript={onDeleteProjectScript} /> )} + {settings.showHeaderKeybindingsButton ? ( + + ) : null} {activeProjectName && ( { + return Object.fromEntries( + STATIC_KEYBINDING_DEFINITIONS.map((definition) => [ + definition.command, + keybindingValueForCommand(keybindings, definition.command) ?? + primaryDefaultShortcutValueForCommand(definition.command) ?? + "", + ]), + ) as Record; +} + +interface KeybindingsControlProps { + keybindings: ResolvedKeybindingsConfig; + triggerLabel?: string; + triggerClassName?: string; +} + +export function KeybindingsControl({ + keybindings, + triggerLabel = "Keys", + triggerClassName, +}: KeybindingsControlProps) { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [draftValues, setDraftValues] = useState>( + buildDraftValues(keybindings), + ); + const [savingCommand, setSavingCommand] = useState(null); + const [errorByCommand, setErrorByCommand] = useState>>( + {}, + ); + + useEffect(() => { + if (!open) return; + setDraftValues(buildDraftValues(keybindings)); + setErrorByCommand({}); + }, [keybindings, open]); + + const sections = useMemo( + () => + STATIC_KEYBINDING_SECTIONS.map((section) => ({ + id: section.id, + title: section.title, + description: section.description, + definitions: STATIC_KEYBINDING_DEFINITIONS.filter( + (definition) => definition.section === section.id, + ), + })), + [], + ); + + const updateDraftValue = (command: KeybindingCommand, value: string) => { + setDraftValues((current) => ({ + ...current, + [command]: value, + })); + setErrorByCommand((current) => ({ + ...current, + [command]: undefined, + })); + }; + + const saveShortcut = async (command: KeybindingCommand) => { + const nextValue = + draftValues[command]?.trim() || primaryDefaultShortcutValueForCommand(command) || ""; + const when = defaultWhenForCommand(command); + const decoded = Schema.decodeUnknownOption(KeybindingRuleSchema)({ + key: nextValue, + command, + ...(when ? { when } : {}), + }); + + if (decoded._tag === "None") { + setErrorByCommand((current) => ({ + ...current, + [command]: INVALID_KEYBINDING_MESSAGE, + })); + return; + } + + setSavingCommand(command); + try { + const api = ensureNativeApi(); + const result = await api.server.upsertKeybinding(decoded.value); + queryClient.setQueryData(serverQueryKeys.config(), (current) => + current + ? { + ...current, + keybindings: result.keybindings, + issues: result.issues, + } + : current, + ); + await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); + } catch (error) { + setErrorByCommand((current) => ({ + ...current, + [command]: error instanceof Error ? error.message : "Unable to save shortcut.", + })); + } finally { + setSavingCommand(null); + } + }; + + return ( + <> + + + + + + Keybindings + + Review active shortcuts, update them inline, or restore a command to its default + shortcut. Press a shortcut into any field and use Backspace to reset. + + + + {sections.map((section) => ( +
+
+

{section.title}

+

{section.description}

+
+ +
+
+
Action
+
Current
+
Default
+
Edit
+
Apply
+
+ +
+ {section.definitions.map((definition) => { + const currentLabels = shortcutLabelsForCommand( + keybindings, + definition.command, + ); + const defaultValues = defaultShortcutValuesForCommand(definition.command); + const draftValue = draftValues[definition.command] ?? ""; + const normalizedDraftValue = draftValue.trim(); + const currentValue = keybindingValueForCommand( + keybindings, + definition.command, + ); + const willReplaceMany = currentLabels.length > 1 || defaultValues.length > 1; + const canSave = + normalizedDraftValue.length > 0 && normalizedDraftValue !== currentValue; + const isSaving = savingCommand === definition.command; + const error = errorByCommand[definition.command]; + + return ( +
+
+
+ {definition.title} +
+

+ {definition.description} +

+ + {definition.command} + +
+ +
+
+ Current +
+
+ {currentLabels.length > 0 ? ( + currentLabels.map((label) => {label}) + ) : ( + None + )} +
+ {willReplaceMany ? ( +

+ Saving here replaces all active shortcuts for this command with one + binding. +

+ ) : null} +
+ +
+
+ Default +
+
+ {defaultValues.map((value) => ( + {value} + ))} +
+
+ +
+
+ Edit +
+ { + if (event.key === "Tab") return; + event.preventDefault(); + if (event.key === "Backspace" || event.key === "Delete") { + updateDraftValue( + definition.command, + primaryDefaultShortcutValueForCommand(definition.command) ?? "", + ); + return; + } + + const nextValue = keybindingValueFromEvent( + event, + navigator.platform, + ); + if (!nextValue) return; + updateDraftValue(definition.command, nextValue); + }} + /> +

+ Press a shortcut. Use Backspace to restore default. +

+ {error ?

{error}

: null} +
+ +
+ + +
+
+ ); + })} +
+
+
+ ))} +
+
+
+ + ); +} diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 0ecccf43f..68b67fdef 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -1,4 +1,4 @@ -import { assert, describe, it } from "vitest"; +import { assert, describe, expect, it } from "vitest"; import { type KeybindingCommand, @@ -11,6 +11,8 @@ import { isChatNewShortcut, isChatNewLocalShortcut, isDiffToggleShortcut, + keybindingValueForCommand, + keybindingValueFromEvent, isOpenFavoriteEditorShortcut, isTerminalClearShortcut, isTerminalCloseShortcut, @@ -300,6 +302,30 @@ describe("chat/editor shortcuts", () => { }); }); +describe("keybindingValueFromEvent", () => { + it("normalizes modifiers using mod on macOS", () => { + expect( + keybindingValueFromEvent(event({ key: "k", metaKey: true, shiftKey: true }), "MacIntel"), + ).toBe("mod+shift+k"); + }); + + it("normalizes modifiers using mod on non-macOS", () => { + expect( + keybindingValueFromEvent(event({ key: "k", ctrlKey: true, altKey: true }), "Win32"), + ).toBe("mod+alt+k"); + }); + + it("rejects shortcuts without a modifier", () => { + expect(keybindingValueFromEvent(event({ key: "k" }), "Linux")).toBeNull(); + }); +}); + +describe("keybindingValueForCommand", () => { + it("returns the latest serialized binding for a command", () => { + expect(keybindingValueForCommand(DEFAULT_BINDINGS, "chat.new")).toBe("mod+shift+o"); + }); +}); + describe("cross-command precedence", () => { it("uses when + order so a later focused rule overrides a global rule", () => { const keybindings = compile([ diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 09d9308aa..83be01c03 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -4,6 +4,7 @@ import { type KeybindingWhenNode, type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; +import { DEFAULT_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { isMacPlatform } from "./lib/utils"; export interface ShortcutEventLike { @@ -26,6 +27,82 @@ interface ShortcutMatchOptions { context?: Partial; } +export interface StaticKeybindingDefinition { + command: KeybindingCommand; + title: string; + description: string; + section: "workspace" | "chat" | "terminal"; +} + +export const STATIC_KEYBINDING_SECTIONS = [ + { + id: "workspace", + title: "Workspace", + description: "Project-level shortcuts for navigation and review.", + }, + { + id: "chat", + title: "Chat", + description: "Shortcuts for starting new threads and switching context.", + }, + { + id: "terminal", + title: "Terminal", + description: "Shortcuts that control the integrated terminal.", + }, +] as const; + +export const STATIC_KEYBINDING_DEFINITIONS: ReadonlyArray = [ + { + command: "diff.toggle", + title: "Toggle diff panel", + description: "Open or hide the current thread diff without leaving chat.", + section: "workspace", + }, + { + command: "editor.openFavorite", + title: "Open in editor", + description: "Open the active project or worktree in your preferred editor.", + section: "workspace", + }, + { + command: "chat.new", + title: "New chat", + description: "Start a new thread while preserving the active branch or worktree context.", + section: "chat", + }, + { + command: "chat.newLocal", + title: "New environment thread", + description: "Start a new thread in a fresh local or worktree environment.", + section: "chat", + }, + { + command: "terminal.toggle", + title: "Toggle terminal", + description: "Open or hide the terminal drawer without changing threads.", + section: "terminal", + }, + { + command: "terminal.split", + title: "Split terminal", + description: "Create another terminal pane when terminal focus is active.", + section: "terminal", + }, + { + command: "terminal.new", + title: "New terminal", + description: "Create a fresh terminal when terminal focus is active.", + section: "terminal", + }, + { + command: "terminal.close", + title: "Close terminal", + description: "Close the active terminal when terminal focus is active.", + section: "terminal", + }, +] as const; + const TERMINAL_WORD_BACKWARD = "\u001bb"; const TERMINAL_WORD_FORWARD = "\u001bf"; const TERMINAL_LINE_START = "\u0001"; @@ -129,6 +206,23 @@ function formatShortcutKeyLabel(key: string): string { return key.slice(0, 1).toUpperCase() + key.slice(1); } +function encodeShortcutKeyToken(key: string): string { + if (key === " ") return "space"; + if (key === "escape") return "esc"; + return key; +} + +export function shortcutValueFromShortcut(shortcut: KeybindingShortcut): string { + const parts: string[] = []; + if (shortcut.modKey) parts.push("mod"); + if (shortcut.ctrlKey) parts.push("ctrl"); + if (shortcut.metaKey) parts.push("meta"); + if (shortcut.altKey) parts.push("alt"); + if (shortcut.shiftKey) parts.push("shift"); + parts.push(encodeShortcutKeyToken(shortcut.key)); + return parts.join("+"); +} + export function formatShortcutLabel( shortcut: KeybindingShortcut, platform = navigator.platform, @@ -166,6 +260,100 @@ export function shortcutLabelForCommand( return null; } +export function shortcutLabelsForCommand( + keybindings: ResolvedKeybindingsConfig, + command: KeybindingCommand, + platform = navigator.platform, +): string[] { + return keybindings + .filter((binding) => binding.command === command) + .map((binding) => formatShortcutLabel(binding.shortcut, platform)); +} + +export function keybindingValueForCommand( + keybindings: ResolvedKeybindingsConfig, + command: KeybindingCommand, +): string | null { + for (let index = keybindings.length - 1; index >= 0; index -= 1) { + const binding = keybindings[index]; + if (!binding || binding.command !== command) continue; + return shortcutValueFromShortcut(binding.shortcut); + } + return null; +} + +export function normalizeShortcutKeyToken(key: string): string | null { + const normalized = key.toLowerCase(); + if ( + normalized === "meta" || + normalized === "control" || + normalized === "ctrl" || + normalized === "shift" || + normalized === "alt" || + normalized === "option" + ) { + return null; + } + if (normalized === " ") return "space"; + if (normalized === "escape") return "esc"; + if (normalized === "arrowup") return "arrowup"; + if (normalized === "arrowdown") return "arrowdown"; + if (normalized === "arrowleft") return "arrowleft"; + if (normalized === "arrowright") return "arrowright"; + if (normalized.length === 1) return normalized; + if (normalized.startsWith("f") && normalized.length <= 3) return normalized; + if (normalized === "enter" || normalized === "tab" || normalized === "backspace") { + return normalized; + } + if (normalized === "delete" || normalized === "home" || normalized === "end") { + return normalized; + } + if (normalized === "pageup" || normalized === "pagedown") return normalized; + return null; +} + +export function keybindingValueFromEvent( + event: ShortcutEventLike, + platform = navigator.platform, +): string | null { + const keyToken = normalizeShortcutKeyToken(event.key); + if (!keyToken) return null; + + const parts: string[] = []; + if (isMacPlatform(platform)) { + if (event.metaKey) parts.push("mod"); + if (event.ctrlKey) parts.push("ctrl"); + } else { + if (event.ctrlKey) parts.push("mod"); + if (event.metaKey) parts.push("meta"); + } + if (event.altKey) parts.push("alt"); + if (event.shiftKey) parts.push("shift"); + if (parts.length === 0) { + return null; + } + parts.push(keyToken); + return parts.join("+"); +} + +export function defaultRulesForCommand(command: KeybindingCommand) { + return DEFAULT_KEYBINDINGS.filter((rule) => rule.command === command); +} + +export function defaultWhenForCommand(command: KeybindingCommand): string | undefined { + const [firstRule] = defaultRulesForCommand(command); + return firstRule?.when; +} + +export function defaultShortcutValuesForCommand(command: KeybindingCommand): string[] { + return defaultRulesForCommand(command).map((rule) => rule.key); +} + +export function primaryDefaultShortcutValueForCommand(command: KeybindingCommand): string | null { + const values = defaultShortcutValuesForCommand(command); + return values.at(-1) ?? null; +} + export function isTerminalToggleShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/apps/web/src/lib/projectScriptKeybindings.ts b/apps/web/src/lib/projectScriptKeybindings.ts index 4ea80cf82..b54c93e11 100644 --- a/apps/web/src/lib/projectScriptKeybindings.ts +++ b/apps/web/src/lib/projectScriptKeybindings.ts @@ -2,9 +2,9 @@ import { KeybindingRule as KeybindingRuleSchema, type KeybindingCommand, type KeybindingRule, - type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { Schema } from "effect"; +import { keybindingValueForCommand } from "~/keybindings"; export const PROJECT_SCRIPT_KEYBINDING_INVALID_MESSAGE = "Invalid keybinding."; @@ -32,28 +32,4 @@ export function decodeProjectScriptKeybindingRule(input: { return decoded.value; } -export function keybindingValueForCommand( - keybindings: ResolvedKeybindingsConfig, - command: KeybindingCommand, -): string | null { - for (let index = keybindings.length - 1; index >= 0; index -= 1) { - const binding = keybindings[index]; - if (!binding || binding.command !== command) continue; - - const parts: string[] = []; - if (binding.shortcut.modKey) parts.push("mod"); - if (binding.shortcut.ctrlKey) parts.push("ctrl"); - if (binding.shortcut.metaKey) parts.push("meta"); - if (binding.shortcut.altKey) parts.push("alt"); - if (binding.shortcut.shiftKey) parts.push("shift"); - const keyToken = - binding.shortcut.key === " " - ? "space" - : binding.shortcut.key === "escape" - ? "esc" - : binding.shortcut.key; - parts.push(keyToken); - return parts.join("+"); - } - return null; -} +export { keybindingValueForCommand }; diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index b4afcdefa..b94993b10 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -20,6 +20,7 @@ import { } from "../components/ui/select"; import { Switch } from "../components/ui/switch"; import { APP_VERSION } from "../branding"; +import { KeybindingsControl } from "../components/chat/KeybindingsControl"; import { SidebarInset } from "~/components/ui/sidebar"; const THEME_OPTIONS = [ @@ -111,6 +112,7 @@ function SettingsRouteView() { const codexHomePath = settings.codexHomePath; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; + const keybindings = serverConfigQuery.data?.keybindings ?? []; const openKeybindingsFile = useCallback(() => { if (!keybindingsConfigPath) return; @@ -592,12 +594,58 @@ function SettingsRouteView() {

Keybindings

- Open the persisted keybindings.json file to edit advanced bindings - directly. + Manage the built-in shortcuts from the app and use the JSON file for advanced + overrides.

+
+
+

+ Show keybindings button in chat header +

+

+ Keep the Keys shortcut manager button visible in the top bar. +

+
+ + updateSettings({ + showHeaderKeybindingsButton: Boolean(checked), + }) + } + aria-label="Show keybindings button in chat header" + /> +
+ + {settings.showHeaderKeybindingsButton !== defaults.showHeaderKeybindingsButton ? ( +
+ +
+ ) : null} + +
+
+

Keybinds manager

+

+ Update built-in bindings without leaving settings. +

+
+ +
+

Config file path

@@ -616,7 +664,8 @@ function SettingsRouteView() {

- Opens in your preferred editor selection. + The JSON file is still available for advanced bindings and manual edits. It opens + in your preferred editor selection.

{openKeybindingsError ? (

{openKeybindingsError}

diff --git a/packages/shared/package.json b/packages/shared/package.json index 02ae794d6..91d3f790e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -31,6 +31,10 @@ "./schemaJson": { "types": "./src/schemaJson.ts", "import": "./src/schemaJson.ts" + }, + "./keybindings": { + "types": "./src/keybindings.ts", + "import": "./src/keybindings.ts" } }, "scripts": { diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts new file mode 100644 index 000000000..09fd9474e --- /dev/null +++ b/packages/shared/src/keybindings.ts @@ -0,0 +1,13 @@ +import { type KeybindingRule } from "@t3tools/contracts"; + +export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, + { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, + { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, + { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, + { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, + { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, + { key: "mod+o", command: "editor.openFavorite" }, +] as const;